Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/core-card-kit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@stats-forge/github-stats-forge-core': minor
---

refactor!: draw the cards with `@stats-forge/card-kit`, which now holds the frame, themes and query-string boundary

`CardError.fromGraphQL` is gone, and `CardError` and the themes are the kit's, re-exported unchanged.
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ their reasons in `oxlint.config.ts`.
for GitHub, a `502` for WakaTime. A rejected fetch, `networkError()` or a `FetchMock` with no
handler is a transport failure, which the retryer waits out for 4.1s; when the transport failure
is the subject, use fake timers, as `retryer.spec.ts` does.
- **A spec that re-imports core inlines card-kit** (`server.deps.inline`, as `packages/cli` does):
vitest keeps a dependency loaded across `vi.resetModules()`, and core registering its failures a
second time throws.
- **`packages/core` runs `pool: 'vmThreads'`; `isolate: false` breaks the `fetchTopLanguages` error
tests** ([#122]), and `fsModuleCache` gained nothing. Re-measure before revisiting either.
- **The type test is `fetchRepo.types.spec-d.ts`**, checked by `tsc`; vitest would need
Expand Down Expand Up @@ -288,7 +291,7 @@ pnpm is **12.x** ([#110]); `pnpm-workspace.yaml` gives each setting's reason, an
`pnpm why <pkg>`, since nothing polices an override ([#80]).
- **A stale lockfile can hold an orphan**: delete `node_modules` and `pnpm-lock.yaml`, then
reinstall; deleting the lockfile alone reuses what is installed.
- **`minimumReleaseAge` exempts the first-party configs alone, by name** ([#216]).
- **`minimumReleaseAge` exempts the first-party configs and card-kit alone, by name** ([#216]).

[#15]: https://github.com/stats-forge/github-stats-forge/pull/15
[#30]: https://github.com/stats-forge/github-stats-forge/pull/30
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { defineProject } from 'vitest/config';
export default defineProject({
test: {
include: ['./tests/*.spec.ts'],
// loaded with core, so `vi.resetModules()` clears the failures core registers with it
server: { deps: { inline: ['@stats-forge/card-kit'] } },
},
/*
* Read core's TypeScript rather than its build, so a stale `build/` cannot
Expand Down
40 changes: 25 additions & 15 deletions packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

The cards have rules of their own, in `src/cards/AGENTS.md`.

**What a card draws with lives in [`@stats-forge/card-kit`](https://github.com/stats-forge/card-kit)**:
the frame, themes, markup, text, locales, `CardError`, the generic param helpers and the handler.
This package holds what is GitHub's or WakaTime's: fetchers, cards, their failures and params. A
change to how every card looks is made there, then checked here by linking the checkout
(`'@stats-forge/card-kit': link:../card-kit` under `overrides` in `pnpm-workspace.yaml`, never
committed) and running `pnpm check-all`.

## The api layer is the trust boundary

`api/*` turns a query string into typed card options: **parse and validate there once**, and leave
Expand Down Expand Up @@ -35,14 +42,14 @@ every default to the renderer (a card's default width, which only the card knows

### How an endpoint is built

- **A `zod/mini` schema built from `api/params.ts`, passed to `cardHandler(schema, identities, render)`**
([#48]), which parses the theme, then the query, checks the allowlists and renders. Its two
`catch`es are the only callers of `errorResult`.
- **A `zod/mini` schema built from the kit's param helpers and `api/params.ts`'s, passed to
`cardHandler(schema, identities, render)`** ([#48]), the kit's handler bound to `CardConfig`, which
parses the theme, then the query, checks the allowlists and renders.
- **The theme parses first**: a rejected theme cannot draw its own error card.
- **A param several cards take goes in a shared fragment** ([#234]) (`commonParams`, `titleParams`,
`statRowParams`), spread after the identity param.
- **Every param helper records its kind with `kinded()`, and the handler carries them as `PARAMS`**
([#235]), throwing at import on one without. The catalog's test reads them.
- **Every param helper records its kind with the kit's `kinded()`, and the handler carries them as
`PARAMS`** ([#235]), throwing at import on one without. The catalog's test reads them.
- **The query type comes from the schema**, so a consumer is checked at its call site.
- **A card's accepted values ride on its renderer as `OPTIONS`, keyed by option name** ([#46]). The
handler spreads them, adds only the fetcher's own (`affiliation`), and reads them back, as in
Expand All @@ -51,35 +58,38 @@ every default to the renderer (a card's default width, which only the card knows
- **A closed-set list is an `enumListParam` in `OPTIONS`** ([#230]). `show` and `hide` accept every
stat the card draws, and the handler fetches only what is drawn; the second argument is what a
form offers. Each card checks `show` through a `shows()` typed to its own list.
- **`NUMBER_FORMATS` lives in `common/render.ts`**, beside its one reader.
- **`theme` is one theme name or a `light,dark` pair, each matched as an `enumParam` matches**;
one name never switches, and no theme is `DEFAULT_THEME`, a pair. `getCardColors` still maps an
unknown name to `ember_light`; `isThemeName` is `Object.hasOwn`, so `?theme=toString` misses
([#231]).
- **Each kind of rejection has one wording, in `REJECTION_MESSAGES`**, declared through
`rejects(kind, passes)`; the param name comes from the issue's path. Only the first is reported.
- **Each kind of rejection has one wording**: the kit's own params' in its table, this package's in
`api/params.ts`'s `REJECTION_MESSAGES`, passed to the kit's `rejects(wording, passes)`; the param
name comes from the issue's path. Only the first is reported.
- **Write plain properties, not conditional spreads**: `CardOptions<T>` takes an explicit `undefined`.

### Errors

- **Core never writes to `console` itself**: a diagnostic goes to `config.logger`, which a host
points at its own log; the server writes JSON lines, the CLI stderr.
- **Everything throws `CardError`** (`common/error.ts`): a `code`, the card's two lines and the param
at fault. `CardError.from(err)` wraps anything else as a retryable `upstream`, so a permanent
failure must throw a `CardError`.
- **Everything throws the kit's `CardError`, imported from `common/error.ts`**, which registers this
package's failures on import: a `code`, the card's two lines and the param at fault.
`CardError.from(err)` wraps anything else as a retryable `upstream`, so a permanent failure must
throw a `CardError`.
- **A failure is not `retryable` unless it declares it**, as one whose repeat could answer
differently does: upstream's outage, a spent or missing token, a `forbidden` the next token may
pass. Each entry says so itself, not a table by code.
- **Every failure lives in the module-level `FAILURES`, unexported.** Not a `static #` field: the
types derive from it, a `#` field's type cannot be queried, and a TS-`private` one emits untyped.
- **Every failure of this package lives in `common/error.ts`'s `FAILURES`, unexported**, whose type
augments the kit's `Failures` and which `registerFailures` words once; the boundary's own
(`invalid_param`, `missing_param`, `not_allowed`) are the kit's.
- **An entry is a function from its data to a `Failure`**; its parameter is what the reason needs,
and `because` takes exactly that, so a missing value fails `tsc`. Pass in only what the table
cannot word itself.
- **`FailureReason` derives from `CardError.because`**, never written out.
- **`FailureReason` derives from `Failures`**, never written out.
- **A reason one card family throws carries its prefix** (`gist_`, `org_`, `org_activity_`,
`repo_`, `stats_`, `wakatime_`); a shared one keeps a plain name.
- **Throw by reason, `CardError.because(reason, …data)`, and test on `reason`, not text.** The
constructor is `private`; `from` and `fromGraphQL` are the only other builders.
constructor is `private`; `from` is the only other builder, and `fromGraphQL` words a GraphQL
reply's errors through `because`.
- **A failure is `reportable` unless it declares otherwise**, which decides the error card's report
line: off for a spent rate limit, upstream's outage, a missing param (whose card links the docs)
and whatever `from` wraps. A `not_found` is never reportable, whatever it declares: the
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"generate-emoji-map": "node scripts/generate-emoji-map.ts"
},
"dependencies": {
"@stats-forge/card-kit": "^0.1.1",
"zod": "^4.6.7"
},
"devDependencies": {
Expand Down
91 changes: 0 additions & 91 deletions packages/core/src/api/api-result.ts

This file was deleted.

19 changes: 8 additions & 11 deletions packages/core/src/api/contributed-to.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import * as z from 'zod/mini';

import { renderContributedToCard } from '../cards/contributed-to/index.ts';
import { fetchContributedTo } from '../fetchers/contributed-to/index.ts';

import { cardHandler } from './handler.ts';
import {
booleanParam,
commonParams,
fromParam,
intParam,
ORDERED_RANGE,
safeListParam,
titleParams,
toParam,
usernameParam,
} from './params.ts';
} from '@stats-forge/card-kit';
import * as z from 'zod/mini';

import { renderContributedToCard } from '../cards/contributed-to/index.ts';
import { fetchContributedTo } from '../fetchers/contributed-to/index.ts';

import { cardHandler } from './handler.ts';
import { fromParam, ORDERED_RANGE, toParam, usernameParam } from './params.ts';

/** What the contributed-to endpoint accepts, on top of the shared color params. */
const contributedToQuery = z
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/api/gist.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { booleanParam, commonParams, safeParam } from '@stats-forge/card-kit';
import * as z from 'zod/mini';

import { renderGistCard } from '../cards/gist/index.ts';
import { fetchGist } from '../fetchers/gist/index.ts';

import { cardHandler } from './handler.ts';
import { booleanParam, commonParams, safeParam } from './params.ts';

/** What the gist endpoint accepts, on top of the shared color params. */
const gistQuery = z.object({
Expand Down
71 changes: 5 additions & 66 deletions packages/core/src/api/handler.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,12 @@
import type * as z from 'zod/mini';
import { cardHandlerFor } from '@stats-forge/card-kit';
import type { CardHandler } from '@stats-forge/card-kit';

import type { ThemeParams } from '../common/color.ts';
import type { AllowlistKind, CardConfig } from '../common/config.ts';
import { CardError } from '../common/error.ts';

import type { ApiResult } from './api-result.ts';
import { errorResult } from './api-result.ts';
import type { ApiQuery, ParamShape } from './params.ts';
import { describeParams, parseParams, parseThemeParams, resolveEmpty } from './params.ts';

/** Which of an endpoint's params name an identity, and which list guards each. */
type Identities<TSchema extends z.ZodMiniObject> = Partial<
Record<Extract<keyof z.output<TSchema>, string>, AllowlistKind>
>;

/** An endpoint, the identities a pinned deployment may refuse it, and how a form asks for each param. */
type CardEndpoint<TSchema extends z.ZodMiniObject> = ((
query: ApiQuery<TSchema>,
config: CardConfig,
) => Promise<ApiResult>) & {
IDENTITIES: Identities<TSchema>;
PARAMS: Readonly<Record<string, ParamShape>>;
};
import type { CardConfig } from '../common/config.ts';

/**
* An endpoint: the schema it accepts, the identities a pinned deployment may refuse,
* and what it draws from the parsed params.
*
* The theme parses first and on its own, because a rejected theme cannot be used to draw its own error card.
* Parsing throws, the allowlist throws, fetching throws, and one `catch` turns whatever was thrown into the answer.
*
* @returns The handler, taking the query the schema describes.
* and what it draws from the parsed params, given this package's config.
*/
const cardHandler = <TSchema extends z.ZodMiniObject>(
schema: TSchema,
identities: Identities<TSchema>,
render: (params: z.output<TSchema>, theme: ThemeParams, config: CardConfig) => Promise<string>,
): CardEndpoint<TSchema> => {
const PARAMS = describeParams(schema.shape);

const handler = async (raw: ApiQuery<TSchema>, config: CardConfig): Promise<ApiResult> => {
const query = resolveEmpty(raw, PARAMS);
let theme: ThemeParams;
try {
theme = parseThemeParams(query);
} catch (error) {
return errorResult(error, config.hints);
}

try {
const params = parseParams(schema, query);

// before the render, so a refusal costs no rate-limit point; an absent identity is the
// fetcher's `missing_param` to report
for (const [param, kind] of Object.entries(identities) as Array<[string, AllowlistKind]>) {
const value: unknown = (params as Record<string, unknown>)[param];
if (typeof value === 'string' && !config.isAllowed(value, kind)) {
throw CardError.because('not_allowed', { param });
}
}

return { status: 'success', content: await render(params, theme, config) };
} catch (error) {
return errorResult(error, config.hints, theme);
}
};

// carried like `OPTIONS`: `tests/allowlist.spec.ts` reads the one, the catalog's test the other
return Object.assign(handler, { IDENTITIES: identities, PARAMS });
};
const cardHandler: CardHandler<CardConfig> = cardHandlerFor<CardConfig>();

export { cardHandler };
17 changes: 9 additions & 8 deletions packages/core/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,20 @@ export { pin } from './pin.ts';
export { stats } from './stats.ts';
export { topLangs } from './top-langs.ts';
export { wakatime } from './wakatime.ts';
export { queryOf } from './query.ts';
export { readBoolean } from './params.ts';

export type { ApiError, ApiResult } from './api-result.ts';
export type { ParamKind, ParamShape } from './params.ts';

// every handler but `wakatime` needs the PAT pool, and that one still needs the transport.
export { CardConfig } from '../common/config.ts';
export type { CardConfigInit, Logger, PersonalAccessToken } from '../common/config.ts';
export type { FetchLike } from '../common/http.ts';
export type { ErrorCode, FailureReason } from '../common/error.ts';

export { cardGround } from '../common/color.ts';
export type { CardGround } from '../common/color.ts';
export { DEFAULT_THEME, themes } from '../themes/index.ts';
export type { ThemeName } from '../themes/index.ts';
export { cardGround, queryOf, readBoolean } from '@stats-forge/card-kit';
export type {
ApiError,
ApiResult,
CardGround,
FetchLike,
ParamKind,
ParamShape,
} from '@stats-forge/card-kit';
20 changes: 8 additions & 12 deletions packages/core/src/api/org-activity.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
import * as z from 'zod/mini';

import { renderOrgActivityCard } from '../cards/org-activity/index.ts';
import { fetchOrgActivity } from '../fetchers/org-activity/index.ts';

import { cardHandler } from './handler.ts';
import {
commonParams,
DAYS_OR_RANGE,
enumListParam,
fromParam,
intParam,
ORDERED_RANGE,
statRowParams,
titleParams,
toParam,
usernameParam,
} from './params.ts';
} from '@stats-forge/card-kit';
import * as z from 'zod/mini';

import { renderOrgActivityCard } from '../cards/org-activity/index.ts';
import { fetchOrgActivity } from '../fetchers/org-activity/index.ts';

import { cardHandler } from './handler.ts';
import { DAYS_OR_RANGE, fromParam, ORDERED_RANGE, toParam, usernameParam } from './params.ts';

/** Every stat the card draws: `show` and `hide` each accept any of them. */
const STATS = [
Expand Down
Loading
Loading