From a227cd7a7a179bccf07226ef0fde94642dbb1313 Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti <24919330+marcalexiei@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:38:24 +0100 Subject: [PATCH 1/2] refactor!: draw the cards with @stats-forge/card-kit --- .changeset/core-card-kit.md | 7 + packages/core/AGENTS.md | 40 +- packages/core/package.json | 1 + packages/core/src/api/api-result.ts | 91 ---- packages/core/src/api/contributed-to.ts | 19 +- packages/core/src/api/gist.ts | 2 +- packages/core/src/api/handler.ts | 71 +-- packages/core/src/api/index.ts | 17 +- packages/core/src/api/org-activity.ts | 20 +- packages/core/src/api/org-calendar.ts | 10 +- packages/core/src/api/organization.ts | 16 +- packages/core/src/api/params.ts | 472 +---------------- packages/core/src/api/pin.ts | 18 +- packages/core/src/api/query.ts | 9 - packages/core/src/api/stats.ts | 18 +- packages/core/src/api/top-langs.ts | 20 +- packages/core/src/api/wakatime.ts | 19 +- packages/core/src/cards/AGENTS.md | 10 +- .../core/src/cards/contributed-to/index.ts | 30 +- .../core/src/cards/contributed-to/locales.ts | 2 +- packages/core/src/cards/gist/index.ts | 28 +- packages/core/src/cards/gist/locales.ts | 2 +- packages/core/src/cards/options.ts | 2 +- packages/core/src/cards/org-activity/index.ts | 33 +- .../core/src/cards/org-activity/locales.ts | 2 +- packages/core/src/cards/org-calendar/index.ts | 37 +- .../core/src/cards/org-calendar/locales.ts | 2 +- packages/core/src/cards/organization/index.ts | 30 +- .../core/src/cards/organization/locales.ts | 2 +- packages/core/src/cards/repo/index.ts | 32 +- packages/core/src/cards/repo/locales.ts | 2 +- packages/core/src/cards/stats/index.ts | 30 +- packages/core/src/cards/stats/locales.ts | 2 +- packages/core/src/cards/stats/rankIcon.ts | 73 +++ packages/core/src/cards/stats/rows.ts | 8 +- packages/core/src/cards/stats/styles.ts | 5 +- .../core/src/cards/top-languages/index.ts | 24 +- .../core/src/cards/top-languages/layouts.ts | 19 +- .../core/src/cards/top-languages/locales.ts | 2 +- packages/core/src/cards/wakatime/index.ts | 34 +- packages/core/src/cards/wakatime/locales.ts | 2 +- packages/core/src/common/Card.ts | 498 ------------------ packages/core/src/common/brand.ts | 204 +------ packages/core/src/common/color.ts | 256 --------- packages/core/src/common/config.ts | 11 +- packages/core/src/common/error.ts | 219 ++------ packages/core/src/common/fmt.ts | 84 --- packages/core/src/common/http.ts | 75 +-- packages/core/src/common/icons.ts | 183 ------- packages/core/src/common/locales.ts | 2 +- packages/core/src/common/localize.ts | 275 ---------- packages/core/src/common/log.ts | 22 - packages/core/src/common/ops.ts | 34 +- packages/core/src/common/render.ts | 464 ---------------- packages/core/src/common/retryer.ts | 4 +- packages/core/src/common/text.ts | 185 ------- .../core/src/fetchers/contributed-to/index.ts | 20 +- packages/core/src/fetchers/index.ts | 2 +- .../core/src/fetchers/org-activity/index.ts | 19 +- .../core/src/fetchers/org-calendar/index.ts | 14 +- .../core/src/fetchers/organization/index.ts | 11 +- packages/core/src/fetchers/repo/index.ts | 4 +- packages/core/src/fetchers/stats/index.ts | 10 +- .../core/src/fetchers/top-languages/index.ts | 4 +- .../src/fetchers/top-languages/jupyter.ts | 5 +- packages/core/src/fetchers/wakatime/index.ts | 3 +- packages/core/src/markup/css.ts | 155 ------ packages/core/src/markup/element.ts | 253 --------- packages/core/src/markup/html.ts | 34 -- packages/core/src/markup/index.ts | 9 - packages/core/src/markup/serialize.ts | 27 - packages/core/src/themes/index.ts | 171 +----- packages/core/tests/_cards.ts | 3 +- packages/core/tests/_config.ts | 2 +- packages/core/tests/_fetch-mock.ts | 4 +- packages/core/tests/accessibility.spec.ts | 2 +- packages/core/tests/allowlist.spec.ts | 2 +- packages/core/tests/apiErrors.spec.ts | 2 +- packages/core/tests/apiFuzz.spec.ts | 3 +- .../core/tests/apiInputValidation.spec.ts | 2 +- packages/core/tests/apiParams.spec.ts | 16 +- packages/core/tests/card.spec.ts | 369 ------------- packages/core/tests/color.spec.ts | 116 ---- packages/core/tests/config.spec.ts | 3 +- packages/core/tests/flexLayout.spec.ts | 79 --- packages/core/tests/fmt.spec.ts | 60 --- packages/core/tests/html.spec.ts | 29 - packages/core/tests/locales.spec.ts | 4 +- packages/core/tests/localize.spec.ts | 140 ----- packages/core/tests/markup.spec.ts | 176 ------- packages/core/tests/markup.types.spec-d.ts | 27 - packages/core/tests/ops.spec.ts | 10 +- packages/core/tests/query.spec.ts | 16 - packages/core/tests/render.spec.ts | 278 ---------- .../core/tests/renderOrgActivityCard.spec.ts | 2 +- .../core/tests/renderOrgCalendarCard.spec.ts | 3 +- packages/core/tests/renderRepoCard.spec.ts | 6 +- packages/core/tests/renderStatsCard.spec.ts | 3 +- .../core/tests/renderTopLanguagesCard.spec.ts | 3 +- .../core/tests/renderWakatimeCard.spec.ts | 4 +- packages/core/tests/xss.spec.ts | 2 +- 101 files changed, 543 insertions(+), 5343 deletions(-) create mode 100644 .changeset/core-card-kit.md delete mode 100644 packages/core/src/api/api-result.ts delete mode 100644 packages/core/src/api/query.ts create mode 100644 packages/core/src/cards/stats/rankIcon.ts delete mode 100644 packages/core/src/common/Card.ts delete mode 100644 packages/core/src/common/color.ts delete mode 100644 packages/core/src/common/fmt.ts delete mode 100644 packages/core/src/common/icons.ts delete mode 100644 packages/core/src/common/localize.ts delete mode 100644 packages/core/src/common/log.ts delete mode 100644 packages/core/src/common/render.ts delete mode 100644 packages/core/src/common/text.ts delete mode 100644 packages/core/src/markup/css.ts delete mode 100644 packages/core/src/markup/element.ts delete mode 100644 packages/core/src/markup/html.ts delete mode 100644 packages/core/src/markup/index.ts delete mode 100644 packages/core/src/markup/serialize.ts delete mode 100644 packages/core/tests/card.spec.ts delete mode 100644 packages/core/tests/color.spec.ts delete mode 100644 packages/core/tests/flexLayout.spec.ts delete mode 100644 packages/core/tests/fmt.spec.ts delete mode 100644 packages/core/tests/html.spec.ts delete mode 100644 packages/core/tests/localize.spec.ts delete mode 100644 packages/core/tests/markup.spec.ts delete mode 100644 packages/core/tests/markup.types.spec-d.ts delete mode 100644 packages/core/tests/query.spec.ts delete mode 100644 packages/core/tests/render.spec.ts diff --git a/.changeset/core-card-kit.md b/.changeset/core-card-kit.md new file mode 100644 index 00000000..f6d8dd50 --- /dev/null +++ b/.changeset/core-card-kit.md @@ -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. diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index 541ae6b5..a5af3649 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -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 @@ -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 @@ -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` 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 diff --git a/packages/core/package.json b/packages/core/package.json index 80709653..172d3ba1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,6 +82,7 @@ "generate-emoji-map": "node scripts/generate-emoji-map.ts" }, "dependencies": { + "@stats-forge/card-kit": "^0.1.0", "zod": "^4.6.7" }, "devDependencies": { diff --git a/packages/core/src/api/api-result.ts b/packages/core/src/api/api-result.ts deleted file mode 100644 index 5f3493a1..00000000 --- a/packages/core/src/api/api-result.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { ThemeParams } from '../common/color.ts'; -import type { CardConfig } from '../common/config.ts'; -import type { ErrorCode, FailureReason } from '../common/error.ts'; -import { CardError } from '../common/error.ts'; -import { renderError } from '../common/render.ts'; - -/** - * Why a card could not be rendered, in a form a host can act on. - * The same failure is also drawn onto the result's `content`, - * so a host never has to read the SVG to find out what happened. - */ -export interface ApiError { - /** Why it failed. */ - code: ErrorCode; - /** Which failure this is, finer than `code`; absent for an error core did not raise itself. */ - reason: FailureReason | undefined; - /** First line of the error card. */ - message: string; - /** Second line of the error card, when the code has more to say: the host's hint, when it gave one. */ - secondaryMessage: string | undefined; - /** Both lines as one string of sentences, for a host to print as it is. */ - text: string; - /** - * The first param at fault, when the failure names one. - * `missing_param` names every missing param in `message`, and not every - * `invalid_param` comes from a single param, so this can be absent. - */ - param: string | undefined; -} - -/** What every api handler returns: a rendered card, or a rendered error. */ -export type ApiResult = - | { status: 'success'; content: string } - | { - status: 'error'; - /** Whether repeating the request could produce a different answer. */ - retryable: boolean; - error: ApiError; - /** The error, drawn as a card. */ - content: string; - }; - -/** @returns The line, ending in sentence punctuation. */ -const asSentence = (line: string): string => { - if (/[.!?]$/.test(line)) { - return line; - } - return `${line}.`; -}; - -/** - * Turns anything a handler threw into the one shape it answers with. - * Anything that is not a `CardError` is treated as upstream's. - * - * @returns The rendered error. - */ -export const errorResult = ( - err: unknown, - hints: CardConfig['hints'], - /** omitted when it was the theme that was rejected, which cannot draw its own error card */ - renderOptions?: ThemeParams, -): ApiResult => { - const error = CardError.from(err); - const hint = error.reason === undefined ? undefined : hints[error.reason]; - const secondaryMessage = hint ?? error.secondaryMessage; - - return { - status: 'error', - retryable: error.retryable, - error: { - code: error.code, - reason: error.reason, - message: error.message, - secondaryMessage, - text: [error.message, secondaryMessage] - .map((line) => line?.trim() ?? '') - .filter((line) => line !== '') - .map((line) => asSentence(line)) - .join(' '), - param: error.param, - }, - content: renderError({ - message: error.message, - secondaryMessage, - renderOptions: { - ...renderOptions, - reportable: error.reportable, - }, - }), - }; -}; diff --git a/packages/core/src/api/contributed-to.ts b/packages/core/src/api/contributed-to.ts index f680c7aa..34b802cc 100644 --- a/packages/core/src/api/contributed-to.ts +++ b/packages/core/src/api/contributed-to.ts @@ -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 diff --git a/packages/core/src/api/gist.ts b/packages/core/src/api/gist.ts index 9fbb7ef8..1e43eb87 100644 --- a/packages/core/src/api/gist.ts +++ b/packages/core/src/api/gist.ts @@ -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({ diff --git a/packages/core/src/api/handler.ts b/packages/core/src/api/handler.ts index c8865890..6ef04d2f 100644 --- a/packages/core/src/api/handler.ts +++ b/packages/core/src/api/handler.ts @@ -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 = Partial< - Record, string>, AllowlistKind> ->; - -/** An endpoint, the identities a pinned deployment may refuse it, and how a form asks for each param. */ -type CardEndpoint = (( - query: ApiQuery, - config: CardConfig, -) => Promise) & { - IDENTITIES: Identities; - PARAMS: Readonly>; -}; +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 = ( - schema: TSchema, - identities: Identities, - render: (params: z.output, theme: ThemeParams, config: CardConfig) => Promise, -): CardEndpoint => { - const PARAMS = describeParams(schema.shape); - - const handler = async (raw: ApiQuery, config: CardConfig): Promise => { - 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)[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 = cardHandlerFor(); export { cardHandler }; diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index fcb177a3..a3bbb9f1 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -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'; diff --git a/packages/core/src/api/org-activity.ts b/packages/core/src/api/org-activity.ts index 8345d36d..f80ba8f5 100644 --- a/packages/core/src/api/org-activity.ts +++ b/packages/core/src/api/org-activity.ts @@ -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 = [ diff --git a/packages/core/src/api/org-calendar.ts b/packages/core/src/api/org-calendar.ts index f5e8e844..71defd15 100644 --- a/packages/core/src/api/org-calendar.ts +++ b/packages/core/src/api/org-calendar.ts @@ -1,12 +1,18 @@ +import { + NUMBER_FORMATS, + commonParams, + enumParam, + intParam, + titleParams, +} from '@stats-forge/card-kit'; import * as z from 'zod/mini'; import { renderOrgCalendarCard } from '../cards/org-calendar/index.ts'; import { ORG_CALENDAR_COUNTS, ORG_CALENDAR_MAX_WEEKS } from '../common/constants.ts'; -import { NUMBER_FORMATS } from '../common/render.ts'; import { fetchOrgCalendar } from '../fetchers/org-calendar/index.ts'; import { cardHandler } from './handler.ts'; -import { commonParams, enumParam, intParam, titleParams, usernameParam } from './params.ts'; +import { usernameParam } from './params.ts'; /** What the organization calendar endpoint accepts, on top of the shared color params. */ const orgCalendarQuery = z.object({ diff --git a/packages/core/src/api/organization.ts b/packages/core/src/api/organization.ts index 6e1d75c4..c2bff749 100644 --- a/packages/core/src/api/organization.ts +++ b/packages/core/src/api/organization.ts @@ -1,9 +1,3 @@ -import * as z from 'zod/mini'; - -import { renderOrganizationCard } from '../cards/organization/index.ts'; -import { fetchOrganization } from '../fetchers/organization/index.ts'; - -import { cardHandler } from './handler.ts'; import { booleanParam, commonParams, @@ -11,8 +5,14 @@ import { intParam, statRowParams, titleParams, - usernameParam, -} from './params.ts'; +} from '@stats-forge/card-kit'; +import * as z from 'zod/mini'; + +import { renderOrganizationCard } from '../cards/organization/index.ts'; +import { fetchOrganization } from '../fetchers/organization/index.ts'; + +import { cardHandler } from './handler.ts'; +import { usernameParam } from './params.ts'; /** Every stat the card draws: `show` and `hide` each accept any of them. */ const STATS = [ diff --git a/packages/core/src/api/params.ts b/packages/core/src/api/params.ts index afa37d9f..88fa0eb5 100644 --- a/packages/core/src/api/params.ts +++ b/packages/core/src/api/params.ts @@ -1,58 +1,18 @@ /** - * @file The api layer is the trust boundary: - * a query string arrives as strings, and each endpoint declares what it accepts as a schema over them. - * Parsing happens here once; - * the render functions are handed typed values and keep their own defaults. - * - * `zod/mini` rather than `zod`: - * this package ships to the browser, and the functional API tree-shakes down to the checks used below. + * @file The params only GitHub's and WakaTime's cards take, built on the kit's helpers: a range of + * dates, a scope of repositories, a login and a host. */ +import { kinded, parseArray, patternParam, rawParam, rejects } from '@stats-forge/card-kit'; +import type { RejectionWording } from '@stats-forge/card-kit'; import * as z from 'zod/mini'; -import type { ThemeParams } from '../common/color.ts'; import { GITHUB_USERNAME_PATTERN, HOSTNAME_PATTERN, WAKATIME_USERNAME_PATTERN, } from '../common/constants.ts'; import { getWidestRange, parseRangeDate } from '../common/date.ts'; -import { CardError } from '../common/error.ts'; -import { AVAILABLE_LOCALES, LOCALE_ALIASES, resolveLocale } from '../common/localize.ts'; -import type { Locale } from '../common/localize.ts'; -import { parseArray } from '../common/ops.ts'; -import { NUMBER_FORMATS } from '../common/render.ts'; -import { THEME_PARAM_VALUES } from '../themes/index.ts'; - -/** How a form asks for a param: the vocabulary of the catalog, which holds each option to it. */ -type ParamKind = 'text' | 'boolean' | 'number' | 'integer' | 'list' | 'choice'; - -/** What a form needs to know of a param to ask for it. */ -interface ParamShape { - kind: ParamKind; - /** The accepted values, where they are a fixed set. */ - choices?: ReadonlyArray; - /** Values accepted beside the choices, each mapped to the choice it reads as. */ - aliases?: ReadonlyMap; -} - -const SHAPES = new WeakMap(); - -/** - * Records how a form asks for the param a schema reads. - * - * @returns The schema itself. - */ -const kinded = (shape: ParamKind | ParamShape, schema: T): T => { - SHAPES.set(schema, typeof shape === 'string' ? { kind: shape } : shape); - return schema; -}; - -/** Every param arrives as a string, or not at all. */ -const rawParam = kinded('text', z.optional(z.string())); - -/** Characters a repository, owner or gist id may contain. */ -const SAFE_PATTERN = /^[-\w/.,]+$/; /** One repository, `owner/name`, or everything an owner holds, `owner/*`. */ const SCOPE_PATTERN = /^[\w.-]+\/(?:\*|[\w.-]+)$/; @@ -60,29 +20,21 @@ const SCOPE_PATTERN = /^[\w.-]+\/(?:\*|[\w.-]+)$/; /** Past this a search query nears the length GitHub refuses, which would read as an upstream error. */ const MAX_SCOPES = 10; -/** What a check rejected a param for. */ -type Rejection = - | 'boolean' - | 'number' +/** + * Every rejection these params can put on an error card, in one place; the kit words its own. + * The param comes from the issue's own path, so no schema repeats its own name. + */ +const REJECTION_MESSAGES: Record< | 'date' | 'out_of_range' | 'inverted_range' | 'range_conflict' | 'scope' | 'scope_unused' - | 'unsafe' | 'username' - | 'hostname' - | 'locale' - | 'enum'; - -/** - * Every rejection the api can put on an error card, in one place. - * The param comes from the issue's own path, so no schema repeats its own name. - */ -const REJECTION_MESSAGES: Record string> = { - boolean: (param) => `Invalid boolean input for parameter "${param}"`, - number: (param) => `Invalid number input for parameter "${param}"`, + | 'hostname', + RejectionWording +> = { date: (param) => `Invalid date input for parameter "${param}"`, out_of_range: (param) => `Out of range date for parameter "${param}"`, inverted_range: () => 'Range "from" is after "to"', @@ -90,133 +42,8 @@ const REJECTION_MESSAGES: Record string> = { scope: (param) => `Parameter "${param}" takes up to ${MAX_SCOPES} entries, each owner/name or owner/*`, scope_unused: (param) => `Parameter "${param}" applies only to stats counted by search`, - unsafe: (param) => `Parameter "${param}" contains unsafe characters`, username: (param) => `Invalid username input for parameter "${param}"`, hostname: (param) => `Invalid hostname input for parameter "${param}"`, - locale: () => 'Locale not found', - enum: (param) => `Incorrect ${param} input`, -}; - -/** - * Words a rejection of one kind, naming the param from the issue's path. - * - * @returns An `error` option for a zod schema or check. - */ -const wordedAs = - (kind: Rejection) => - (issue: { path?: ReadonlyArray | undefined }): string => - REJECTION_MESSAGES[kind](String(issue.path?.[0] ?? '')); - -/** - * A check that words its own rejection: - * the message is built from the kind and the param's path rather than passed in. - * - * @returns The check, ready for `.check()`. - */ -const rejects = (kind: Rejection, passes: (value: string) => boolean): z.core.$ZodCheck => - z.refine((value: unknown) => typeof value !== 'string' || passes(value), { - error: wordedAs(kind), - }); - -/** - * `true`, `1` or `on`, and `false`, `0` or `off`, in any case. - * Absent is `undefined`, which leaves the card's own default in place; anything else is rejected. - */ -const booleanParam = kinded( - 'boolean', - z.optional( - z.stringbool({ - truthy: ['true', '1', 'on'], - falsy: ['false', '0', 'off'], - error: wordedAs('boolean'), - }), - ), -); - -/** - * Read one boolean value as a handler does, empty being set as {@link resolveEmpty} has it. - * - * @returns The boolean, or `undefined` for a word no boolean param accepts. - */ -const readBoolean = (value: string): boolean | undefined => { - if (value === '') { - return true; - } - const parsed = z.safeParse(booleanParam, value); - return parsed.success ? parsed.data : undefined; -}; - -/** Comma-separated values, empty when the param is absent. */ -const listParam = kinded('list', z.pipe(rawParam, z.transform(parseArray))); - -/** What a numeric param may be written with. */ -interface NumericFormat { - /** The smallest value accepted. */ - min?: number; - /** The largest value accepted. */ - max?: number; - /** Whether a trailing `px` is accepted, for the params a reader thinks of as lengths. */ - px?: boolean; -} - -/** - * A pattern for a non-negative number, with nothing after it but an optional `px`. - * - * @returns The pattern. - */ -const numericPattern = (digits: string, px: boolean): RegExp => - new RegExp(`^${digits}${px ? '(?:px)?' : ''}$`, 'i'); - -/** - * A decimal number — a radius, a weight. Nothing but digits and an optional `px` is accepted: - * `parseFloat` alone read `1e3` as 1000 and `5abc` as 5. - * - * @returns Schema yielding the number, or `undefined` when the param is absent. - */ -const numberParam = ({ min = 0, max = Infinity, px = false }: NumericFormat = {}): z.ZodMiniType< - number | undefined, - string | undefined -> => { - const pattern = numericPattern(String.raw`(?:\d+(?:\.\d*)?|\.\d+)`, px); - return kinded( - 'number', - z.pipe( - rawParam.check( - rejects('number', (value) => { - const parsed = Number.parseFloat(value); - return pattern.test(value) && parsed >= min && parsed <= max; - }), - ), - z.transform((value) => (value === undefined ? undefined : Number.parseFloat(value))), - ), - ); -}; - -/** - * A whole number — a width, a count, a line height — at least 1 unless said otherwise: - * a negative width or height drew a broken SVG. - * - * @returns Schema yielding the number, or `undefined` when the param is absent. - */ -const intParam = ({ min = 1, max = Infinity, px = false }: NumericFormat = {}): z.ZodMiniType< - number | undefined, - string | undefined -> => { - const pattern = numericPattern(String.raw`\d+`, px); - return kinded( - 'integer', - z.pipe( - rawParam.check( - rejects('number', (value) => { - const parsed = Number.parseInt(value, 10); - return ( - pattern.test(value) && Number.isSafeInteger(parsed) && parsed >= min && parsed <= max - ); - }), - ), - z.transform((value) => (value === undefined ? undefined : Number.parseInt(value, 10))), - ), - ); }; /** @@ -229,8 +56,8 @@ const rangeParam = (end: 'from' | 'to'): z.ZodMiniType parse(value) !== undefined)).check( - rejects('out_of_range', (value) => { + rawParam.check(rejects(REJECTION_MESSAGES.date, (value) => parse(value) !== undefined)).check( + rejects(REJECTION_MESSAGES.out_of_range, (value) => { const date = parse(value); const widest = getWidestRange(); // a bad shape is already reported by the check above @@ -267,122 +94,20 @@ const DAYS_OR_RANGE = z.refine<{ path: ['days'], }); -/** - * A text param that must match a pattern, yielded unchanged. - * - * @returns Schema yielding the value, or `undefined` when absent. - */ -const patternParam = ( - kind: Rejection, - pattern: RegExp, -): z.ZodMiniType => - kinded('text', rawParam.check(rejects(kind, (value) => pattern.test(value)))); - -/** An id the fetchers put in a URL, rejected before any request is made. */ -const safeParam = patternParam('unsafe', SAFE_PATTERN); - /** * A GitHub login, stricter than `safeParam`: no `_`, `.` or `/`, and 39 characters at most. * Rejected here so a malformed one is a permanent error rather than the NOT_FOUND GitHub answers. */ -const usernameParam = patternParam('username', GITHUB_USERNAME_PATTERN); +const usernameParam = patternParam(REJECTION_MESSAGES.username, GITHUB_USERNAME_PATTERN); /** A WakaTime profile, which the fetcher puts in a URL path as one segment. */ -const wakatimeUsernameParam = patternParam('username', WAKATIME_USERNAME_PATTERN); +const wakatimeUsernameParam = patternParam(REJECTION_MESSAGES.username, WAKATIME_USERNAME_PATTERN); /** * A host the server is asked to fetch from. * A path, a query or credentials would let the query steer the request past the host it names. */ -const hostnameParam = patternParam('hostname', HOSTNAME_PATTERN); - -/** - * A comma-separated list of ids, each checked as {@link safeParam} checks one. - * Yields the split values, empty when the param is absent. - */ -const safeListParam: z.ZodMiniType, string | undefined> = kinded( - 'list', - z.pipe( - rawParam.check( - rejects('unsafe', (value) => parseArray(value).every((entry) => SAFE_PATTERN.test(entry))), - ), - z.transform(parseArray), - ), -); - -/** - * A locale the cards have translations for, offering the listed codes; an alias is accepted, not - * offered, and resolved here so a card only ever reads a listed code. A form gets a copy of the - * aliases, so none can change what the api accepts. - * One wording for every endpoint: the message names the param, as the number and color rejections do. - */ -const localeParam = kinded( - { kind: 'choice', choices: AVAILABLE_LOCALES, aliases: new Map(LOCALE_ALIASES) }, - z.pipe( - rawParam.check(rejects('locale', (value) => resolveLocale(value) !== undefined)), - z.transform((value): Locale | undefined => { - if (value === undefined) { - return undefined; - } - return resolveLocale(value); - }), - ), -); - -/** - * Looks an entry up in a fixed set, ignoring case. - * - * @returns The entry as the set spells it, or `undefined` when the set lacks it. - */ -const inSet = (values: ReadonlyArray): ((entry: string) => T | undefined) => { - const byLowercase = new Map(values.map((value) => [value.toLowerCase(), value])); - return (entry) => byLowercase.get(entry.toLowerCase()); -}; - -/** - * A param the card only renders as one of a fixed set of values. - * Matched case-insensitively and yielded as the set spells it. - * - * @returns Schema yielding one of `values`, or `undefined` when absent. - */ -const enumParam = >( - values: T, -): z.ZodMiniType => { - const canonical = inSet(values); - return kinded( - { kind: 'choice', choices: values }, - z.pipe( - rawParam.check(rejects('enum', (value) => canonical(value) !== undefined)), - z.transform((value) => (value === undefined ? undefined : canonical(value))), - ), - ); -}; - -/** - * A comma-separated list drawn from a fixed set, such as the stats a card can show. - * Matched as {@link enumParam} matches, so `?affiliation=owner` reads as `OWNER`; - * one unknown entry rejects the list, since an ignored entry reads as a stat the card forgot. - * - * @returns Schema yielding the entries, empty when absent. - */ -const enumListParam = >( - values: T, - /** What a form offers, when that is only the useful part of what is accepted. */ - offered: ReadonlyArray = values, -): z.ZodMiniType, string | undefined> => { - const canonical = inSet(values); - return kinded( - { kind: 'list', choices: offered }, - z.pipe( - rawParam.check( - rejects('enum', (value) => - parseArray(value).every((entry) => canonical(entry) !== undefined), - ), - ), - z.transform((value) => parseArray(value).flatMap((entry) => canonical(entry) ?? [])), - ), - ); -}; +const hostnameParam = patternParam(REJECTION_MESSAGES.hostname, HOSTNAME_PATTERN); /** * Where the search-based stats count: `owner/name` or `owner/*` entries. A bare name is rejected, @@ -392,7 +117,7 @@ const scopeParam: z.ZodMiniType, string | undefined> = kinded( 'list', z.pipe( rawParam.check( - rejects('scope', (value) => { + rejects(REJECTION_MESSAGES.scope, (value) => { const entries = parseArray(value); return entries.length <= MAX_SCOPES && entries.every((entry) => SCOPE_PATTERN.test(entry)); }), @@ -414,174 +139,13 @@ const scopeNeedsSearch = }>( path: ['scope'], }); -/** What every card accepts beside the theme, so a card cannot be the one that forgot one. */ -const commonParams = { - hide_border: booleanParam, - border_radius: numberParam({ px: true }), - disable_animations: booleanParam, - locale: localeParam, -}; - -/** What a card that draws a title accepts. */ -const titleParams = { - hide_title: booleanParam, - title: rawParam, -}; - -/** What a card drawing one stat per row accepts. */ -const statRowParams = { - show_icons: booleanParam, - number_format: enumParam(NUMBER_FORMATS), - number_precision: intParam({ min: 0 }), -}; - -/** - * A theme name, or a `light,dark` pair of them, each matched as {@link enumParam} matches. A form - * offers the single names; a pair is typed. - */ -const themeParam = ((): z.ZodMiniType => { - const canonical = inSet(THEME_PARAM_VALUES); - const names = (value: string): Array => - parseArray(value).map((entry) => canonical(entry)); - return kinded( - { kind: 'choice', choices: THEME_PARAM_VALUES }, - z.pipe( - rawParam.check( - rejects('enum', (value) => { - const resolved = names(value); - // a value of commas alone names nothing, which is no more a theme than a wrong name - return ( - resolved.length > 0 && - resolved.length <= 2 && - resolved.every((name) => name !== undefined) - ); - }), - ), - z.transform((value) => (value === undefined ? undefined : names(value).join(','))), - ), - ); -})(); - -/** The theme param, parsed apart from the endpoint's own so a bad theme cannot draw its own error. */ -const themeParamsSchema = z.object({ theme: themeParam }); - -/** - * The query an endpoint accepts, derived from its own schema. - * Every param is optional and every value a string, which is all a query string can carry; - * naming it lets a consumer typecheck the object it builds. - */ -type ApiQuery = Partial> & ThemeParams; - -/** - * Turns a rejection back into the error the api answers with. - * Every rejection is the query's fault, so they share one code - * and differ only in the message the check already worded. - * - * @returns The failure, ready to render. - */ -const toCardError = (error: z.core.$ZodError): CardError => { - const [issue] = error.issues; - return CardError.because('invalid_param', { - param: String(issue?.path[0] ?? ''), - detail: issue?.message ?? 'Invalid input', - }); -}; - -/** - * Runs a query through an endpoint's schema. - * - * Only the first rejection is reported: - * the error card has room for one line. - * - * @throws {CardError} When the schema rejects a param. - * - * @returns The parsed params. - */ -const parseParams = ( - schema: TSchema, - query: unknown, -): z.output => { - const result = z.safeParse(schema, query); - if (!result.success) { - throw toCardError(result.error); - } - return result.data; -}; - -/** - * The theme param, validated. - * Split from the endpoint's own schema because a rejected theme cannot then be used to render its own error card. - * - * @throws {CardError} When the theme names none, or more than two. - * - * @returns The theme param. - */ -const parseThemeParams = (query: unknown): ThemeParams => parseParams(themeParamsSchema, query); - -/** - * One rule for an empty value, whatever the param: `?hide_border` and `?hide_border=` arrive alike, - * so an empty boolean is a flag that is set, and any other empty param is absent. - * - * @returns The query with every empty value resolved. - */ -const resolveEmpty = ( - query: Readonly>, - params: Readonly>, -): Record => - Object.fromEntries( - Object.entries(query).flatMap(([param, value]) => { - if (value !== '') { - return [[param, value]]; - } - return params[param]?.kind === 'boolean' ? [[param, 'true']] : []; - }), - ); - -/** - * How a form asks for each param a schema shape reads, the theme first. - * - * @throws {Error} When a param was built without a kind, which only a new helper can do. - * - * @returns Each param's shape, keyed by the param. - */ -const describeParams = ( - shape: Readonly>, -): Readonly> => - Object.fromEntries( - Object.entries({ ...themeParamsSchema.shape, ...shape }).map(([param, schema]) => { - const described = SHAPES.get(schema); - if (!described) { - throw new Error(`Param "${param}" was built without a kind`); - } - return [param, described]; - }), - ); - -export type { ApiQuery, ParamKind, ParamShape }; - export { - booleanParam, - commonParams, DAYS_OR_RANGE, - describeParams, - enumListParam, - enumParam, fromParam, hostnameParam, - listParam, ORDERED_RANGE, - intParam, - numberParam, - parseThemeParams, - parseParams, - readBoolean, - resolveEmpty, - safeListParam, - safeParam, scopeNeedsSearch, scopeParam, - statRowParams, - titleParams, toParam, usernameParam, wakatimeUsernameParam, diff --git a/packages/core/src/api/pin.ts b/packages/core/src/api/pin.ts index 4e2180bd..22b024ae 100644 --- a/packages/core/src/api/pin.ts +++ b/packages/core/src/api/pin.ts @@ -1,10 +1,3 @@ -import * as z from 'zod/mini'; - -import { renderRepoCard } from '../cards/repo/index.ts'; -import { fetchRepo } from '../fetchers/repo/index.ts'; -import { REPO_USER_SEARCHES } from '../fetchers/stats/index.ts'; - -import { cardHandler } from './handler.ts'; import { booleanParam, commonParams, @@ -12,8 +5,15 @@ import { intParam, safeParam, statRowParams, - usernameParam, -} from './params.ts'; +} from '@stats-forge/card-kit'; +import * as z from 'zod/mini'; + +import { renderRepoCard } from '../cards/repo/index.ts'; +import { fetchRepo } from '../fetchers/repo/index.ts'; +import { REPO_USER_SEARCHES } from '../fetchers/stats/index.ts'; + +import { cardHandler } from './handler.ts'; +import { usernameParam } from './params.ts'; /** What the pin endpoint accepts, on top of the shared color params. */ const pinQuery = z.object({ diff --git a/packages/core/src/api/query.ts b/packages/core/src/api/query.ts deleted file mode 100644 index af7b315c..00000000 --- a/packages/core/src/api/query.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * A query string's params as a handler takes them. - * A key given twice is one list, joined on commas the way every list param reads, - * so `?hide=stars&hide=prs` hides both rather than only the last. - * - * @returns One string per key. - */ -export const queryOf = (params: URLSearchParams): Record => - Object.fromEntries([...new Set(params.keys())].map((key) => [key, params.getAll(key).join(',')])); diff --git a/packages/core/src/api/stats.ts b/packages/core/src/api/stats.ts index 68a8e369..df0f8921 100644 --- a/packages/core/src/api/stats.ts +++ b/packages/core/src/api/stats.ts @@ -1,3 +1,13 @@ +import { + booleanParam, + commonParams, + enumListParam, + enumParam, + intParam, + safeListParam, + statRowParams, + titleParams, +} from '@stats-forge/card-kit'; import * as z from 'zod/mini'; import { renderStatsCard } from '../cards/stats/index.ts'; @@ -6,18 +16,10 @@ import { fetchStats, REPO_USER_SEARCHES } from '../fetchers/stats/index.ts'; import { cardHandler } from './handler.ts'; import { - booleanParam, - commonParams, - enumListParam, - enumParam, fromParam, - intParam, ORDERED_RANGE, - safeListParam, scopeNeedsSearch, scopeParam, - statRowParams, - titleParams, toParam, usernameParam, } from './params.ts'; diff --git a/packages/core/src/api/top-langs.ts b/packages/core/src/api/top-langs.ts index 73971dbb..667c44d6 100644 --- a/packages/core/src/api/top-langs.ts +++ b/packages/core/src/api/top-langs.ts @@ -1,11 +1,3 @@ -import * as z from 'zod/mini'; - -import { renderTopLanguages } from '../cards/top-languages/index.ts'; -import { OWNER_AFFILIATIONS } from '../common/constants.ts'; -import { fetchTopLanguages } from '../fetchers/top-languages/index.ts'; -import { NOTEBOOK_COUNTS } from '../fetchers/top-languages/jupyter.ts'; - -import { cardHandler } from './handler.ts'; import { booleanParam, commonParams, @@ -16,8 +8,16 @@ import { numberParam, safeListParam, titleParams, - usernameParam, -} from './params.ts'; +} from '@stats-forge/card-kit'; +import * as z from 'zod/mini'; + +import { renderTopLanguages } from '../cards/top-languages/index.ts'; +import { OWNER_AFFILIATIONS } from '../common/constants.ts'; +import { fetchTopLanguages } from '../fetchers/top-languages/index.ts'; +import { NOTEBOOK_COUNTS } from '../fetchers/top-languages/jupyter.ts'; + +import { cardHandler } from './handler.ts'; +import { usernameParam } from './params.ts'; /** What the top-languages endpoint accepts, on top of the shared color params. */ const topLangsQuery = z.object({ diff --git a/packages/core/src/api/wakatime.ts b/packages/core/src/api/wakatime.ts index d06a5de0..0a23696c 100644 --- a/packages/core/src/api/wakatime.ts +++ b/packages/core/src/api/wakatime.ts @@ -1,20 +1,19 @@ -import * as z from 'zod/mini'; - -import { renderWakatimeCard } from '../cards/wakatime/index.ts'; -import { WAKATIME_RANGES } from '../common/constants.ts'; -import { fetchWakatimeStats } from '../fetchers/wakatime/index.ts'; - -import { cardHandler } from './handler.ts'; import { booleanParam, commonParams, enumParam, - hostnameParam, intParam, listParam, titleParams, - wakatimeUsernameParam, -} from './params.ts'; +} from '@stats-forge/card-kit'; +import * as z from 'zod/mini'; + +import { renderWakatimeCard } from '../cards/wakatime/index.ts'; +import { WAKATIME_RANGES } from '../common/constants.ts'; +import { fetchWakatimeStats } from '../fetchers/wakatime/index.ts'; + +import { cardHandler } from './handler.ts'; +import { hostnameParam, wakatimeUsernameParam } from './params.ts'; /** What the wakatime endpoint accepts, on top of the shared color params. */ const wakatimeQuery = z.object({ diff --git a/packages/core/src/cards/AGENTS.md b/packages/core/src/cards/AGENTS.md index 975480ed..655348c6 100644 --- a/packages/core/src/cards/AGENTS.md +++ b/packages/core/src/cards/AGENTS.md @@ -42,10 +42,10 @@ After any card edit, redraw the previews (`pnpm docs:cards`) and diff the snapsh ## Card branding -- **`common/brand.ts` owns how a card looks** ([#51]): the font stack, type scale, weights, default - widths and icons. A card writing its own `font`, pixel size or weight is drifting. -- **Every measure is a `CARD_STYLE` entry, read by dot** ([#123]); only `CARD_ICON`, `font` and - `firefoxFontSize` stand beside it. A new measure goes there, never into a card. +- **The kit's `brand.ts` owns how a card looks** ([#51]): the font stack, type scale, weights, + default widths and icons. A card writing its own `font`, pixel size or weight is drifting. +- **Every measure is a `CARD_STYLE` entry, read by dot** ([#123]); only `font` and `firefoxFontSize` + stand beside it, in the kit. A new measure goes there, never into a card. - **Every side offset reads `CARD_STYLE.padding.x`, never a literal**: when it moved from 25 to 18, each hard-coded 25 left a row, a bar or a value off the new edge. - **A default width is a `CARD_STYLE.width` step**; a layout needing more takes the next step, so @@ -70,7 +70,7 @@ After any card edit, redraw the previews (`pnpm docs:cards`) and diff the snapsh - **A `quiet` theme drops the corner glow and halves the band**, for the original palettes. - **The title icon is `.title-icon`, not `.icon`**, which the stats card hides; it takes `iconColor`, the heat's middle stop unless set. **Each card's icon is distinct, from the one - `CARD_ICON` table.** + `CARD_ICON` table**, in `common/brand.ts`. - **A card's height is its content plus `CARD_STYLE.padding.bottom`, never a spare row** ([#123]). Measure both ends across every card before trusting a layout constant. - **Two things moving with one width are derived from it the same way**, as the rank ring and the diff --git a/packages/core/src/cards/contributed-to/index.ts b/packages/core/src/cards/contributed-to/index.ts index 0a3ce18a..11373f1b 100644 --- a/packages/core/src/cards/contributed-to/index.ts +++ b/packages/core/src/cards/contributed-to/index.ts @@ -1,15 +1,25 @@ -import { CARD_ICON, CARD_STYLE, firefoxFontSize, font } from '../../common/brand.ts'; -import { Card, titleFits } from '../../common/Card.ts'; -import { HEAT_PAINT, getLightDarkColors } from '../../common/color.ts'; +import { + CARD_STYLE, + Card, + HEAT_PAINT, + atRule, + clampValue, + countFormatter, + createProgressNode, + el, + firefoxFontSize, + font, + getLightDarkColors, + localize, + measureText, + rule, + titleFits, +} from '@stats-forge/card-kit'; +import type { Child, CssChild, Locale, Localized } from '@stats-forge/card-kit'; + +import { CARD_ICON } from '../../common/brand.ts'; import { formatYears } from '../../common/date.ts'; -import type { Locale, Localized } from '../../common/localize.ts'; -import { localize } from '../../common/localize.ts'; -import { clampValue } from '../../common/ops.ts'; -import { countFormatter, createProgressNode } from '../../common/render.ts'; -import { measureText } from '../../common/text.ts'; import type { ContributedRepo, ContributedToData } from '../../fetchers/contributed-to/types.ts'; -import type { Child, CssChild } from '../../markup/index.ts'; -import { atRule, el, rule } from '../../markup/index.ts'; import type { CardOptions, CommonCardOptions } from '../options.ts'; import { contributedToCardLocales } from './locales.ts'; diff --git a/packages/core/src/cards/contributed-to/locales.ts b/packages/core/src/cards/contributed-to/locales.ts index f4bdf107..a38ecf99 100644 --- a/packages/core/src/cards/contributed-to/locales.ts +++ b/packages/core/src/cards/contributed-to/locales.ts @@ -1,4 +1,4 @@ -import { defineLocales } from '../../common/localize.ts'; +import { defineLocales } from '@stats-forge/card-kit'; /** The contributed-to card's labels in the locales they have been translated into. */ const contributedToCardLocales = defineLocales({ diff --git a/packages/core/src/cards/gist/index.ts b/packages/core/src/cards/gist/index.ts index 6ec30745..5a769080 100644 --- a/packages/core/src/cards/gist/index.ts +++ b/packages/core/src/cards/gist/index.ts @@ -1,21 +1,25 @@ -import { CARD_ICON, CARD_STYLE, font } from '../../common/brand.ts'; -import { Card } from '../../common/Card.ts'; -import { getLightDarkColors } from '../../common/color.ts'; -import { icons } from '../../common/icons.ts'; -import { getLanguageColor } from '../../common/languageColors.ts'; -import type { Locale } from '../../common/localize.ts'; -import { localize } from '../../common/localize.ts'; -import { parseEmojis } from '../../common/ops.ts'; import { + CARD_STYLE, + Card, countFormatter, createLanguageNode, + el, flexLayout, + font, + getLightDarkColors, iconWithLabel, -} from '../../common/render.ts'; -import { wrapTextMultiline, measureText } from '../../common/text.ts'; + icons, + localize, + measureText, + rule, + wrapTextMultiline, +} from '@stats-forge/card-kit'; +import type { CssChild, Locale } from '@stats-forge/card-kit'; + +import { CARD_ICON } from '../../common/brand.ts'; +import { getLanguageColor } from '../../common/languageColors.ts'; +import { parseEmojis } from '../../common/ops.ts'; import type { GistData } from '../../fetchers/gist/types.ts'; -import type { CssChild } from '../../markup/index.ts'; -import { el, rule } from '../../markup/index.ts'; import type { CardOptions, CommonCardOptions } from '../options.ts'; import { gistCardLocales } from './locales.ts'; diff --git a/packages/core/src/cards/gist/locales.ts b/packages/core/src/cards/gist/locales.ts index ec0274ca..023f1c2c 100644 --- a/packages/core/src/cards/gist/locales.ts +++ b/packages/core/src/cards/gist/locales.ts @@ -1,4 +1,4 @@ -import { defineLocales } from '../../common/localize.ts'; +import { defineLocales } from '@stats-forge/card-kit'; /** The gist card's labels in the locales they have been translated into. */ const gistCardLocales = defineLocales({ diff --git a/packages/core/src/cards/options.ts b/packages/core/src/cards/options.ts index 01ddffc9..122bae5f 100644 --- a/packages/core/src/cards/options.ts +++ b/packages/core/src/cards/options.ts @@ -1,4 +1,4 @@ -import type { ThemeParams } from '../common/color.ts'; +import type { ThemeParams } from '@stats-forge/card-kit'; /** * Options every card accepts. diff --git a/packages/core/src/cards/org-activity/index.ts b/packages/core/src/cards/org-activity/index.ts index 9cf3ec50..00e6aad4 100644 --- a/packages/core/src/cards/org-activity/index.ts +++ b/packages/core/src/cards/org-activity/index.ts @@ -1,23 +1,28 @@ -import { CARD_ICON, CARD_STYLE, font } from '../../common/brand.ts'; -import { Card, titleFits } from '../../common/Card.ts'; -import { HEAT_PAINT, getLightDarkColors } from '../../common/color.ts'; -import { formatRange } from '../../common/date.ts'; -import { CardError } from '../../common/error.ts'; -import { icons } from '../../common/icons.ts'; -import type { Locale, Localized } from '../../common/localize.ts'; -import { localize } from '../../common/localize.ts'; import { + CARD_STYLE, + Card, + HEAT_PAINT, NUMBER_FORMATS, + atRule, + countFormatter, createProgressNode, createTextNode, + el, flexLayout, - countFormatter, -} from '../../common/render.ts'; -import type { NumberFormat } from '../../common/render.ts'; -import { measureText } from '../../common/text.ts'; + font, + getLightDarkColors, + icons, + localize, + measureText, + rule, + titleFits, +} from '@stats-forge/card-kit'; +import type { Child, CssChild, Locale, Localized, NumberFormat } from '@stats-forge/card-kit'; + +import { CARD_ICON } from '../../common/brand.ts'; +import { formatRange } from '../../common/date.ts'; +import { CardError } from '../../common/error.ts'; import type { OrgActivityData } from '../../fetchers/org-activity/types.ts'; -import type { Child, CssChild } from '../../markup/index.ts'; -import { atRule, el, rule } from '../../markup/index.ts'; import type { CardOptions, CommonCardOptions } from '../options.ts'; import { orgActivityCardLocales } from './locales.ts'; diff --git a/packages/core/src/cards/org-activity/locales.ts b/packages/core/src/cards/org-activity/locales.ts index 73faee8e..e2ebdfe9 100644 --- a/packages/core/src/cards/org-activity/locales.ts +++ b/packages/core/src/cards/org-activity/locales.ts @@ -1,4 +1,4 @@ -import { defineLocales } from '../../common/localize.ts'; +import { defineLocales } from '@stats-forge/card-kit'; /** The organization activity card's labels in the locales they have been translated into. */ const orgActivityCardLocales = defineLocales({ diff --git a/packages/core/src/cards/org-calendar/index.ts b/packages/core/src/cards/org-calendar/index.ts index 9c9afb02..b835c840 100644 --- a/packages/core/src/cards/org-calendar/index.ts +++ b/packages/core/src/cards/org-calendar/index.ts @@ -1,17 +1,30 @@ -import { CARD_ICON, CARD_STYLE, font } from '../../common/brand.ts'; -import { Card, titleFits } from '../../common/Card.ts'; -import type { CardColors } from '../../common/color.ts'; -import { getLightDarkColors, inkOn } from '../../common/color.ts'; +import { + CARD_STYLE, + Card, + NUMBER_FORMATS, + countFormatter, + el, + font, + formatDecimal, + getLightDarkColors, + inkOn, + localize, + measureText, + rule, + titleFits, +} from '@stats-forge/card-kit'; +import type { + CardColors, + Child, + CssChild, + Locale, + Localized, + NumberFormat, +} from '@stats-forge/card-kit'; + +import { CARD_ICON } from '../../common/brand.ts'; import { toSearchDate } from '../../common/date.ts'; -import { formatDecimal } from '../../common/fmt.ts'; -import type { Locale, Localized } from '../../common/localize.ts'; -import { localize } from '../../common/localize.ts'; -import { NUMBER_FORMATS, countFormatter } from '../../common/render.ts'; -import type { NumberFormat } from '../../common/render.ts'; -import { measureText } from '../../common/text.ts'; import type { OrgCalendarData } from '../../fetchers/org-calendar/types.ts'; -import type { Child, CssChild } from '../../markup/index.ts'; -import { el, rule } from '../../markup/index.ts'; import type { CardOptions, CommonCardOptions } from '../options.ts'; import { orgCalendarCardLocales } from './locales.ts'; diff --git a/packages/core/src/cards/org-calendar/locales.ts b/packages/core/src/cards/org-calendar/locales.ts index dbf39451..0b022c73 100644 --- a/packages/core/src/cards/org-calendar/locales.ts +++ b/packages/core/src/cards/org-calendar/locales.ts @@ -1,4 +1,4 @@ -import { defineLocales } from '../../common/localize.ts'; +import { defineLocales } from '@stats-forge/card-kit'; /** The organization calendar card's labels in the locales they have been translated into. */ const orgCalendarCardLocales = defineLocales({ diff --git a/packages/core/src/cards/organization/index.ts b/packages/core/src/cards/organization/index.ts index f3a38e4d..1efe96d5 100644 --- a/packages/core/src/cards/organization/index.ts +++ b/packages/core/src/cards/organization/index.ts @@ -1,17 +1,25 @@ -import { CARD_ICON, CARD_STYLE, font } from '../../common/brand.ts'; -import { Card } from '../../common/Card.ts'; -import { getLightDarkColors } from '../../common/color.ts'; +import { + CARD_STYLE, + Card, + NUMBER_FORMATS, + countFormatter, + createTextNode, + el, + flexLayout, + font, + getLightDarkColors, + icons, + localize, + measureText, + rule, + wrapTextMultiline, +} from '@stats-forge/card-kit'; +import type { Child, CssChild, Locale, NumberFormat } from '@stats-forge/card-kit'; + +import { CARD_ICON } from '../../common/brand.ts'; import { CardError } from '../../common/error.ts'; -import { icons } from '../../common/icons.ts'; -import type { Locale } from '../../common/localize.ts'; -import { localize } from '../../common/localize.ts'; import { parseEmojis } from '../../common/ops.ts'; -import { countFormatter, createTextNode, flexLayout, NUMBER_FORMATS } from '../../common/render.ts'; -import type { NumberFormat } from '../../common/render.ts'; -import { measureText, wrapTextMultiline } from '../../common/text.ts'; import type { OrganizationData } from '../../fetchers/organization/types.ts'; -import type { Child, CssChild } from '../../markup/index.ts'; -import { el, rule } from '../../markup/index.ts'; import type { CardOptions, CommonCardOptions } from '../options.ts'; import { orgCardLocales } from './locales.ts'; diff --git a/packages/core/src/cards/organization/locales.ts b/packages/core/src/cards/organization/locales.ts index d0161027..e2add9bf 100644 --- a/packages/core/src/cards/organization/locales.ts +++ b/packages/core/src/cards/organization/locales.ts @@ -1,4 +1,4 @@ -import { defineLocales } from '../../common/localize.ts'; +import { defineLocales } from '@stats-forge/card-kit'; /** The organization card's labels in the locales they have been translated into. */ const orgCardLocales = defineLocales({ diff --git a/packages/core/src/cards/repo/index.ts b/packages/core/src/cards/repo/index.ts index 679455a6..bc75fa98 100644 --- a/packages/core/src/cards/repo/index.ts +++ b/packages/core/src/cards/repo/index.ts @@ -1,23 +1,27 @@ -import { CARD_ICON, CARD_STYLE, font } from '../../common/brand.ts'; -import { Card } from '../../common/Card.ts'; -import { getLightDarkColors } from '../../common/color.ts'; -import { icons } from '../../common/icons.ts'; -import type { Locale } from '../../common/localize.ts'; -import { localize } from '../../common/localize.ts'; -import { buildSearchFilter, clampValue, parseEmojis } from '../../common/ops.ts'; import { + CARD_STYLE, + Card, + NUMBER_FORMATS, + clampValue, + countFormatter, createLanguageNode, createTextNode, + el, flexLayout, - countFormatter, + font, + getLightDarkColors, iconWithLabel, - NUMBER_FORMATS, -} from '../../common/render.ts'; -import type { NumberFormat } from '../../common/render.ts'; -import { measureText, wrapTextMultiline } from '../../common/text.ts'; + icons, + localize, + measureText, + rule, + wrapTextMultiline, +} from '@stats-forge/card-kit'; +import type { Child, CssChild, Locale, MarkupElement, NumberFormat } from '@stats-forge/card-kit'; + +import { CARD_ICON } from '../../common/brand.ts'; +import { buildSearchFilter, parseEmojis } from '../../common/ops.ts'; import type { RepositoryData } from '../../fetchers/repo/types.ts'; -import type { Child, CssChild, MarkupElement } from '../../markup/index.ts'; -import { el, rule } from '../../markup/index.ts'; import type { CardOptions, CommonCardOptions } from '../options.ts'; import { repoCardLocales } from './locales.ts'; diff --git a/packages/core/src/cards/repo/locales.ts b/packages/core/src/cards/repo/locales.ts index 26620b93..3142d8b9 100644 --- a/packages/core/src/cards/repo/locales.ts +++ b/packages/core/src/cards/repo/locales.ts @@ -1,4 +1,4 @@ -import { defineLocales } from '../../common/localize.ts'; +import { defineLocales } from '@stats-forge/card-kit'; /** The repo card's labels in the locales they have been translated into. */ const repoCardLocales = defineLocales({ diff --git a/packages/core/src/cards/stats/index.ts b/packages/core/src/cards/stats/index.ts index f384733c..b3edcc9c 100644 --- a/packages/core/src/cards/stats/index.ts +++ b/packages/core/src/cards/stats/index.ts @@ -1,28 +1,28 @@ -import { CARD_ICON, CARD_STYLE } from '../../common/brand.ts'; -import { Card } from '../../common/Card.ts'; -import { getLightDarkColors } from '../../common/color.ts'; -import { CardError } from '../../common/error.ts'; -import { formatDecimal } from '../../common/fmt.ts'; -import { rankIcon } from '../../common/icons.ts'; -import { commonLocales } from '../../common/locales.ts'; -import type { Locale } from '../../common/localize.ts'; -import { localize } from '../../common/localize.ts'; -import { clampValue } from '../../common/ops.ts'; import { + CARD_STYLE, + Card, NUMBER_FORMATS, + clampValue, countFormatter, createTextNode, + el, flexLayout, + formatDecimal, + getLightDarkColors, glowingCircle, -} from '../../common/render.ts'; -import type { NumberFormat } from '../../common/render.ts'; -import { measureText } from '../../common/text.ts'; + localize, + measureText, +} from '@stats-forge/card-kit'; +import type { CssChild, Locale, NumberFormat } from '@stats-forge/card-kit'; + +import { CARD_ICON } from '../../common/brand.ts'; +import { CardError } from '../../common/error.ts'; +import { commonLocales } from '../../common/locales.ts'; import type { StatsData } from '../../fetchers/stats/types.ts'; -import type { CssChild } from '../../markup/index.ts'; -import { el } from '../../markup/index.ts'; import type { CardOptions, CommonCardOptions } from '../options.ts'; import { statCardLocales } from './locales.ts'; +import { rankIcon } from './rankIcon.ts'; import { getTotalCommitsRangeLabel, HIDE_STATS, diff --git a/packages/core/src/cards/stats/locales.ts b/packages/core/src/cards/stats/locales.ts index ba31f2d6..7f7f5d01 100644 --- a/packages/core/src/cards/stats/locales.ts +++ b/packages/core/src/cards/stats/locales.ts @@ -1,4 +1,4 @@ -import { defineLocales } from '../../common/localize.ts'; +import { defineLocales } from '@stats-forge/card-kit'; /** The stats card's labels in the locales they have been translated into. */ const statCardLocales = defineLocales({ diff --git a/packages/core/src/cards/stats/rankIcon.ts b/packages/core/src/cards/stats/rankIcon.ts new file mode 100644 index 00000000..f71984b5 --- /dev/null +++ b/packages/core/src/cards/stats/rankIcon.ts @@ -0,0 +1,73 @@ +import { el } from '@stats-forge/card-kit'; +import type { Child } from '@stats-forge/card-kit'; + +/** Attributes every rank glyph shares, so the three variants differ only where they mean to. */ +const RANK_TEXT = { + x: -5, + 'alignment-baseline': 'central', + 'dominant-baseline': 'central', + 'text-anchor': 'middle', +} as const; + +/** + * Get rank icon + * + * @returns The rank icon. + */ +const rankIcon = ( + kind: string, + rankLevel: string, + /** The percentile ring's two lines, in the card's locale; `value` is written without its `%`. */ + percentile: { header: string; value: string }, +): Child => { + switch (kind) { + case 'github': + return el( + 'svg', + { + x: -38, + y: -30, + height: 66, + width: 66, + 'aria-hidden': 'true', + viewBox: '0 0 16 16', + version: '1.1', + 'data-view-component': 'true', + 'data-testid': 'github-rank-icon', + }, + el('path', { + d: 'M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z', + }), + ); + + case 'percentile': + return [ + el( + 'text', + { + ...RANK_TEXT, + y: -12, + 'data-testid': 'percentile-top-header', + class: 'rank-percentile-header', + }, + percentile.header, + ), + el( + 'text', + { + ...RANK_TEXT, + y: 12, + 'data-testid': 'percentile-rank-value', + class: 'rank-percentile-text', + }, + `${percentile.value}%`, + ), + ]; + + case 'default': + default: + return el('text', { ...RANK_TEXT, y: 3, 'data-testid': 'level-rank-icon' }, rankLevel); + } +}; + +export { rankIcon }; diff --git a/packages/core/src/cards/stats/rows.ts b/packages/core/src/cards/stats/rows.ts index aea5f00b..4c825b70 100644 --- a/packages/core/src/cards/stats/rows.ts +++ b/packages/core/src/cards/stats/rows.ts @@ -2,16 +2,14 @@ * @file Every stat row the stats card can draw, worked out from the data and the options. */ +import { clampPrecision, formatDecimal, icons } from '@stats-forge/card-kit'; +import type { Child, Locale, Localized } from '@stats-forge/card-kit'; + import type { GitHubDateRange } from '../../common/date.ts'; import { formatRange } from '../../common/date.ts'; -import { formatDecimal } from '../../common/fmt.ts'; -import { icons } from '../../common/icons.ts'; import type { commonLocales } from '../../common/locales.ts'; -import type { Locale, Localized } from '../../common/localize.ts'; import { buildSearchFilter } from '../../common/ops.ts'; -import { clampPrecision } from '../../common/render.ts'; import type { StatsData } from '../../fetchers/stats/types.ts'; -import type { Child } from '../../markup/index.ts'; import type { statCardLocales } from './locales.ts'; diff --git a/packages/core/src/cards/stats/styles.ts b/packages/core/src/cards/stats/styles.ts index d12897b6..9d365b59 100644 --- a/packages/core/src/cards/stats/styles.ts +++ b/packages/core/src/cards/stats/styles.ts @@ -2,9 +2,8 @@ * @file The stats card's stylesheet, the rank ring's animation included. */ -import { CARD_STYLE, firefoxFontSize, font } from '../../common/brand.ts'; -import type { CssChild } from '../../markup/index.ts'; -import { atRule, cssComment, rule } from '../../markup/index.ts'; +import { CARD_STYLE, atRule, cssComment, firefoxFontSize, font, rule } from '@stats-forge/card-kit'; +import type { CssChild } from '@stats-forge/card-kit'; /** The rank ring, and the sparks that rise off it. */ const RANK_RING = { diff --git a/packages/core/src/cards/top-languages/index.ts b/packages/core/src/cards/top-languages/index.ts index 72d7b616..8b8bdfb2 100644 --- a/packages/core/src/cards/top-languages/index.ts +++ b/packages/core/src/cards/top-languages/index.ts @@ -1,12 +1,20 @@ -import { CARD_ICON, CARD_STYLE, firefoxFontSize, font } from '../../common/brand.ts'; -import { Card } from '../../common/Card.ts'; -import { getLightDarkColors } from '../../common/color.ts'; -import type { Locale } from '../../common/localize.ts'; -import { localize } from '../../common/localize.ts'; -import { clampValue, lowercaseTrim } from '../../common/ops.ts'; +import { + CARD_STYLE, + Card, + atRule, + clampValue, + el, + firefoxFontSize, + font, + getLightDarkColors, + localize, + rule, +} from '@stats-forge/card-kit'; +import type { Child, CssChild, Locale } from '@stats-forge/card-kit'; + +import { CARD_ICON } from '../../common/brand.ts'; +import { lowercaseTrim } from '../../common/ops.ts'; import type { Lang, TopLangData } from '../../fetchers/top-languages/types.ts'; -import type { Child, CssChild } from '../../markup/index.ts'; -import { atRule, el, rule } from '../../markup/index.ts'; import type { CardOptions, CommonCardOptions } from '../options.ts'; import { diff --git a/packages/core/src/cards/top-languages/layouts.ts b/packages/core/src/cards/top-languages/layouts.ts index b46032d6..72f3a957 100644 --- a/packages/core/src/cards/top-languages/layouts.ts +++ b/packages/core/src/cards/top-languages/layouts.ts @@ -1,13 +1,18 @@ -import { CARD_STYLE } from '../../common/brand.ts'; -import { isPrefixedHexColor } from '../../common/color.ts'; -import { formatBytes, formatDecimal } from '../../common/fmt.ts'; +import { + CARD_STYLE, + compactColumnWidth, + createProgressNode, + el, + flexLayout, + formatBytes, + formatDecimal, + isPrefixedHexColor, +} from '@stats-forge/card-kit'; +import type { Child, Locale, MarkupElement } from '@stats-forge/card-kit'; + import { DEFAULT_LANG_COLOR } from '../../common/languageColors.ts'; -import type { Locale } from '../../common/localize.ts'; import { chunkArray } from '../../common/ops.ts'; -import { compactColumnWidth, createProgressNode, flexLayout } from '../../common/render.ts'; import type { Lang } from '../../fetchers/top-languages/types.ts'; -import type { Child, MarkupElement } from '../../markup/index.ts'; -import { el } from '../../markup/index.ts'; import { getCircleLength, polarToCartesian } from './geometry.ts'; diff --git a/packages/core/src/cards/top-languages/locales.ts b/packages/core/src/cards/top-languages/locales.ts index 7a5beced..2706b40b 100644 --- a/packages/core/src/cards/top-languages/locales.ts +++ b/packages/core/src/cards/top-languages/locales.ts @@ -1,4 +1,4 @@ -import { defineLocales } from '../../common/localize.ts'; +import { defineLocales } from '@stats-forge/card-kit'; /** The top-languages card's labels in the locales they have been translated into. */ const langCardLocales = defineLocales({ diff --git a/packages/core/src/cards/wakatime/index.ts b/packages/core/src/cards/wakatime/index.ts index d231d7e7..642d7c05 100644 --- a/packages/core/src/cards/wakatime/index.ts +++ b/packages/core/src/cards/wakatime/index.ts @@ -1,17 +1,29 @@ -import { CARD_ICON, CARD_STYLE, firefoxFontSize, font } from '../../common/brand.ts'; -import { Card } from '../../common/Card.ts'; -import { HEAT_PAINT, getLightDarkColors, isPrefixedHexColor } from '../../common/color.ts'; -import { formatDecimal } from '../../common/fmt.ts'; +import { + CARD_STYLE, + Card, + HEAT_PAINT, + atRule, + clampValue, + compactColumnWidth, + createProgressNode, + el, + firefoxFontSize, + flexLayout, + font, + formatDecimal, + getLightDarkColors, + isPrefixedHexColor, + localize, + measureText, + rule, +} from '@stats-forge/card-kit'; +import type { Child, CssChild, Locale, MarkupElement } from '@stats-forge/card-kit'; + +import { CARD_ICON } from '../../common/brand.ts'; import { getLanguageColor } from '../../common/languageColors.ts'; import { commonLocales } from '../../common/locales.ts'; -import type { Locale } from '../../common/localize.ts'; -import { localize } from '../../common/localize.ts'; -import { clampValue, lowercaseTrim } from '../../common/ops.ts'; -import { compactColumnWidth, createProgressNode, flexLayout } from '../../common/render.ts'; -import { measureText } from '../../common/text.ts'; +import { lowercaseTrim } from '../../common/ops.ts'; import type { WakaTimeData, WakaTimeLang } from '../../fetchers/wakatime/types.ts'; -import type { Child, CssChild, MarkupElement } from '../../markup/index.ts'; -import { atRule, el, rule } from '../../markup/index.ts'; import type { CardOptions, CommonCardOptions } from '../options.ts'; import { wakatimeCardLocales } from './locales.ts'; diff --git a/packages/core/src/cards/wakatime/locales.ts b/packages/core/src/cards/wakatime/locales.ts index fccca54c..ef1f1826 100644 --- a/packages/core/src/cards/wakatime/locales.ts +++ b/packages/core/src/cards/wakatime/locales.ts @@ -1,4 +1,4 @@ -import { defineLocales } from '../../common/localize.ts'; +import { defineLocales } from '@stats-forge/card-kit'; /** The wakatime card's labels in the locales they have been translated into. */ const wakatimeCardLocales = defineLocales({ diff --git a/packages/core/src/common/Card.ts b/packages/core/src/common/Card.ts deleted file mode 100644 index 4c789eb8..00000000 --- a/packages/core/src/common/Card.ts +++ /dev/null @@ -1,498 +0,0 @@ -import type { Child, CssChild, MarkupElement } from '../markup/index.ts'; -import { atRule, changedBy, cssComment, el, renderMarkup, rule, style } from '../markup/index.ts'; - -import { CARD_STYLE, firefoxFontSize, font } from './brand.ts'; -import { HEAT_ID, HEAT_PAINT, getCardColors, isPrefixedHexColor } from './color.ts'; -import type { CardColors } from './color.ts'; -import { flexLayout } from './render.ts'; -import { measureText } from './text.ts'; - -/** - * Builds the card CSS for one color scheme from that scheme's resolved colors. - */ -type CardCSSBuilder = (colors: CardColors) => Array; - -/** How far the title sits right of the icon before it. */ -const TITLE_ICON_GAP = 25; -/** Below this a shrunk title stops being readable, so a card narrower still crops it. */ -const TITLE_MIN_FONT_SIZE = CARD_STYLE.fontSize.title / 2; - -/** @returns How wide the semibold title draws at its own size. */ -const titleWidth = (title: string): number => - measureText(title, CARD_STYLE.fontSize.title) * CARD_STYLE.semiboldInk; - -/** @returns What a title room leaves on a card of `width`, beside an icon if it has one. */ -const titleRoom = (width: number, withIcon: boolean): number => - width - CARD_STYLE.padding.x * 2 - (withIcon ? TITLE_ICON_GAP : 0); - -/** - * Shrunk rather than cut, so a long name or translation is still read whole; a smaller font, - * unlike `textLength`, cannot stretch a title the estimate overrated. - * - * @returns The px size a title too wide for `room` is drawn at, or `undefined` when it fits. - */ -const shrunkTitleSize = (title: string, room: number): number | undefined => { - const width = titleWidth(title); - if (width <= room) { - return undefined; - } - return Math.max( - TITLE_MIN_FONT_SIZE, - Math.floor((CARD_STYLE.fontSize.title * room * 10) / width) / 10, - ); -}; - -/** - * For a card with a shorter title to fall back on: a name shrunk past the lead size reads worse - * than no name. - * - * @returns Whether `title` fits a card of `width`, beside the icon, at the lead size or larger. - */ -const titleFits = (title: string, width: number): boolean => - (shrunkTitleSize(title, titleRoom(width, true)) ?? CARD_STYLE.fontSize.title) >= - CARD_STYLE.fontSize.lead; - -/** Stops every animation, whether the reader asked or the card did. */ -const NO_MOTION = { - 'animation-duration': '0s !important', - 'animation-delay': '0s !important', -} as const; - -/** - * Retrieves css animations for a card. - * Each fills `backwards` over a base style that is its last frame, so a renderer that skips - * animations still draws the whole card. - * - * @returns Animation css. - */ -const getAnimations = (): Array => [ - cssComment('Animations'), - atRule( - '@keyframes scaleInAnimation', - rule('from', { transform: 'translate(-5px, 5px) scale(0)' }), - rule('to', { transform: 'translate(-5px, 5px) scale(1)' }), - ), - atRule('@keyframes fadeInAnimation', rule('from', { opacity: 0 }), rule('to', { opacity: 1 })), - atRule('@keyframes trailAnimation', rule('from', { transform: 'scaleX(0)' })), - atRule('@media (prefers-reduced-motion: reduce)', rule('*', NO_MOTION)), -]; - -/** @returns The glow's opacity where it fades into the last stop, to three places. */ -const glowEdgeOpacity = (colors: CardColors): number => - Math.round(colors.glowOpacity * CARD_STYLE.glow.edgeShare * 1000) / 1000; - -/** @returns The band's opacity where it cools into the last stop, to three places. */ -const bandEdgeOpacity = (colors: CardColors): number => - Math.round(colors.bandOpacity * CARD_STYLE.band.edgeShare * 1000) / 1000; - -/** @returns The band's stops, classed so the dark block can recolor them. */ -const bandStops = (colors: CardColors): Array => - ([0, 0.55, 1] as const).map((offset, index) => - el('stop', { - offset, - class: `band-${String(index)}`, - 'stop-color': colors.heat[index], - 'stop-opacity': index === 2 ? bandEdgeOpacity(colors) : colors.bandOpacity, - }), - ); - -/** - * A heat stop. It carries the light color as an attribute, for a renderer that skips CSS, - * and a class the dark block recolors. - */ -const heatStop = ( - offset: number, - colors: CardColors, - index: 0 | 1 | 2, - opacity?: number, -): MarkupElement => - el('stop', { - offset, - class: `heat-${String(index)}`, - 'stop-color': colors.heat[index], - 'stop-opacity': opacity, - }); - -/** - * The heat gradients every card paints with, under the ids in {@link HEAT_ID}, plus the ring - * gradient and the bloom filter for a card that burns. - * - * @returns The definitions. - */ -const renderHeatDefs = (colors: CardColors, burns: boolean): Array => { - const stops = (): Array => [ - heatStop(0, colors, 0), - heatStop(0.55, colors, 1), - heatStop(1, colors, 2), - ]; - - return [ - el('linearGradient', { id: HEAT_ID.line, x1: 0, y1: 0, x2: 1, y2: 0 }, stops()), - burns && el('linearGradient', { id: HEAT_ID.ring, x1: 0, y1: 1, x2: 1, y2: 0 }, stops()), - el('linearGradient', { id: HEAT_ID.trail, x1: 0, y1: 0, x2: 1, y2: 0 }, [ - heatStop(0, colors, 0), - heatStop(0.3, colors, 1), - heatStop(0.7, colors, 2, 0.5), - heatStop(1, colors, 2, 0), - ]), - el('linearGradient', { id: HEAT_ID.band, x1: 0, y1: 0, x2: 1, y2: 0 }, bandStops(colors)), - el('radialGradient', { id: HEAT_ID.glow, cx: 0.95, cy: 1.05, r: 0.8 }, [ - el('stop', { - offset: 0, - class: 'glow-core', - 'stop-color': colors.heat[1], - 'stop-opacity': colors.glowOpacity, - }), - el('stop', { - offset: 0.45, - class: 'glow-edge', - 'stop-color': colors.heat[2], - 'stop-opacity': glowEdgeOpacity(colors), - }), - heatStop(1, colors, 2, 0), - ]), - // The blur alone: WebKit draws a filtered shape at 1x, so what glows is drawn again over it. - burns && - el( - 'filter', - { id: HEAT_ID.bloom, x: '-50%', y: '-50%', width: '200%', height: '200%' }, - el('feGaussianBlur', { stdDeviation: CARD_STYLE.bloom.blur }), - el( - 'feComponentTransfer', - {}, - el('feFuncA', { type: 'linear', slope: CARD_STYLE.bloom.opacity }), - ), - ), - ]; -}; - -/** @returns The rules recoloring the heat stops, for the dark block. */ -const heatStopRules = (colors: CardColors): Array => [ - colors.heat.map((color, index) => rule(`.heat-${String(index)}`, { 'stop-color': color })), - rule('.glow-core', { 'stop-color': colors.heat[1], 'stop-opacity': colors.glowOpacity }), - rule('.glow-edge', { - 'stop-color': colors.heat[2], - 'stop-opacity': glowEdgeOpacity(colors), - }), - colors.heat.map((color, index) => - rule(`.band-${String(index)}`, { - 'stop-color': color, - 'stop-opacity': index === 2 ? bandEdgeOpacity(colors) : colors.bandOpacity, - }), - ), -]; - -/** Everything a card is drawn with; all but the title and its description have a default. */ -interface CardInit { - width?: number; - /** The height with the title shown; `hideTitle` takes the title's room off it. */ - height?: number; - // `| undefined`: card callers forward possibly-undefined query options - border_radius?: number | undefined; - colors?: { light: CardColors; dark: CardColors | null }; - title: string; - titlePrefixIcon?: Child; - /** What a screen reader announces first; the title unless a card has more to say. */ - accessibleTitle?: string; - /** What a screen reader reads after it: the card's figures, in words. */ - description: string; - hideBorder?: boolean; - hideTitle?: boolean; - animations?: boolean; - /** Whether it draws with `HEAT_PAINT.ring` or `HEAT_PAINT.bloom`, which are defined only then. */ - burns?: boolean; - /** The card's own CSS, built once per color scheme; the dark block keeps only what changes. */ - css?: CardCSSBuilder; -} - -/** The room the title and its band take, which a card without a title gives back. */ -const TITLE_ROOM = CARD_STYLE.padding.bodyOffsetY - CARD_STYLE.padding.untitledBodyOffsetY; - -class Card { - readonly width: number; - readonly height: number; - readonly hideBorder: boolean; - readonly hideTitle: boolean; - readonly border_radius: number; - readonly colors: { light: CardColors; dark: CardColors | null }; - readonly title: string; - readonly css: Array; - readonly darkCss: Array; - readonly paddingX = CARD_STYLE.padding.x; - readonly paddingY = CARD_STYLE.padding.titleY; - readonly titlePrefixIcon: Child; - readonly animations: boolean; - readonly burns: boolean; - readonly a11yTitle: string; - readonly a11yDesc: string; - - constructor({ - width = 100, - height = 100, - border_radius = 14, - colors = { light: getCardColors(), dark: null }, - title, - titlePrefixIcon, - accessibleTitle = title, - description, - hideBorder = false, - hideTitle = false, - animations = true, - burns = false, - css = (): Array => [], - }: CardInit) { - this.width = width; - this.height = hideTitle ? height - TITLE_ROOM : height; - this.hideBorder = hideBorder; - this.hideTitle = hideTitle; - this.border_radius = border_radius; - this.colors = colors; - this.title = title; - this.titlePrefixIcon = titlePrefixIcon; - this.animations = animations; - this.burns = burns; - this.a11yTitle = accessibleTitle; - this.a11yDesc = description; - this.css = css(colors.light); - this.darkCss = colors.dark ? changedBy(this.css, css(colors.dark)) : []; - } - - /** - * The trail starts under the icon and runs past the title's end, but never past the card's - * inner edge. - * - * @returns The title, its icon and the heat trail under them. - */ - renderTitle(titleSize: number): MarkupElement { - const withIcon = Boolean(this.titlePrefixIcon); - const drawn = (titleWidth(this.title) * titleSize) / CARD_STYLE.fontSize.title; - const trailWidth = Math.min( - (withIcon ? TITLE_ICON_GAP : 0) + - Math.min(drawn, titleRoom(this.width, withIcon)) + - CARD_STYLE.accent.overrun, - this.width - this.paddingX * 2, - ); - - return el( - 'g', - { - 'data-testid': 'card-title', - transform: `translate(${this.paddingX}, ${this.paddingY})`, - }, - flexLayout({ - items: [ - this.titlePrefixIcon && - el( - 'svg', - { - class: 'title-icon', - x: 0, - y: -13, - viewBox: '0 0 16 16', - version: '1.1', - width: 16, - height: 16, - }, - this.titlePrefixIcon, - ), - el( - 'text', - { - x: 0, - y: 0, - class: 'header', - 'data-testid': 'header', - }, - this.title, - ), - ], - gap: TITLE_ICON_GAP, - }), - el('rect', { - 'data-testid': 'title-accent', - class: 'title-accent', - x: 0, - y: CARD_STYLE.accent.y, - width: Math.round(trailWidth * 10) / 10, - height: CARD_STYLE.accent.height, - rx: CARD_STYLE.accent.height / 2, - fill: HEAT_PAINT.trail, - }), - ); - } - - /** Where the body starts, and so where the title band ends. */ - private get bodyOffset(): number { - if (this.hideTitle) { - return CARD_STYLE.padding.untitledBodyOffsetY; - } - return CARD_STYLE.padding.bodyOffsetY; - } - - /** - * The card's corner radius, capped at half the rect's sides. SVG caps `rx` and `ry` on their own - * axes, which turns a round corner elliptical, so the card caps the one `rx` it draws itself. - */ - private get corner(): number { - return Math.min(this.border_radius, (this.width - 1) / 2, (this.height * 0.99) / 2); - } - - /** - * The band behind the title. Its top corners follow the card's own and its bottom ones stay - * square, so it meets the body as an edge rather than a floating box. - * - * @returns The title band. - */ - private renderTitleBand(): MarkupElement { - const height = this.bodyOffset - CARD_STYLE.band.gap; - const innerWidth = this.width - 1; - const r = this.corner; - const bandHeight = height - 0.5; - - if (r <= bandHeight) { - return el('path', { - 'data-testid': 'title-band', - d: `M0.5 ${0.5 + r}a${r} ${r} 0 0 1 ${r} ${-r}h${innerWidth - 2 * r}a${r} ${r} 0 0 1 ${r} ${r}v${height - r - 0.5}h${-innerWidth}z`, - fill: `url(#${HEAT_ID.band})`, - }); - } - - // A corner deeper than the band: the band's sides follow the corners' arcs to its bottom. - const inset = Math.round((r - Math.sqrt(r * r - (r - bandHeight) ** 2)) * 100) / 100; - return el('path', { - 'data-testid': 'title-band', - d: `M${0.5 + inset} ${height}A${r} ${r} 0 0 1 ${0.5 + r} 0.5h${innerWidth - 2 * r}A${r} ${r} 0 0 1 ${0.5 + innerWidth - inset} ${height}z`, - fill: `url(#${HEAT_ID.band})`, - }); - } - - /** @returns The card's heat definitions. */ - private renderDefs(): MarkupElement { - return el('defs', {}, renderHeatDefs(this.colors.light, this.burns)); - } - - /** - * Builds the @media (prefers-color-scheme: dark) CSS block for the card. - * Returns nothing when no dark colors are set. - */ - private renderDarkMediaBlock(): CssChild { - if (!this.colors.dark) { - return undefined; - } - - return atRule( - '@media (prefers-color-scheme: dark)', - heatStopRules(this.colors.dark), - rule('.header', { fill: this.colors.dark.titleColor }), - rule('.title-icon', { fill: this.colors.dark.iconColor }), - rule('.card-bg', { - fill: this.colors.dark.bgColor, - stroke: this.colors.dark.borderColor, - }), - this.darkCss, - ); - } - - /** @returns The rendered card. */ - render(body: Child): string { - if (!Number.isFinite(this.border_radius)) { - throw new TypeError(`Invalid border radius: "${this.border_radius}"`); - } - const badStop = this.colors.light.heat.find((stop) => !isPrefixedHexColor(stop)); - if (badStop !== undefined) { - throw new Error(`Invalid heat color: "${badStop}"`); - } - if (!isPrefixedHexColor(this.colors.light.borderColor)) { - throw new Error(`Invalid border color: "${this.colors.light.borderColor}"`); - } - if (!isPrefixedHexColor(this.colors.light.bgColor)) { - throw new Error(`Invalid background color: ${this.colors.light.bgColor}`); - } - - const shrunk = shrunkTitleSize( - this.title, - titleRoom(this.width, Boolean(this.titlePrefixIcon)), - ); - const firefoxSize = - shrunk === undefined - ? CARD_STYLE.titleFirefoxSize - : Math.floor((shrunk * CARD_STYLE.titleFirefoxSize * 10) / CARD_STYLE.fontSize.title) / 10; - - const cardShape = { - x: 0.5, - y: 0.5, - rx: this.corner, - height: '99%', - width: this.width - 1, - }; - - return renderMarkup( - el( - 'svg', - { - width: this.width, - height: this.height, - viewBox: `0 0 ${this.width} ${this.height}`, - fill: 'none', - xmlns: 'http://www.w3.org/2000/svg', - role: 'img', - 'aria-labelledby': 'title-id desc-id', - }, - el('title', { id: 'title-id' }, this.a11yTitle), - el('desc', { id: 'desc-id' }, this.a11yDesc), - style( - rule('.header', { - font: font('semibold', 'title'), - fill: this.colors.light.titleColor, - animation: 'fadeInAnimation 0.8s ease-in-out backwards', - }), - cssComment( - 'The icon reads against the title rather than with it, so it keeps its own color.', - ), - rule('.title-icon', { - fill: this.colors.light.iconColor, - animation: 'fadeInAnimation 0.8s ease-in-out backwards', - }), - rule('.title-accent', { - 'transform-box': 'fill-box', - 'transform-origin': 'left center', - animation: 'trailAnimation 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) 0.2s backwards', - }), - shrunk !== undefined && rule('.header', { 'font-size': `${String(shrunk)}px` }), - // Not a style attribute, which would outrank Firefox's smaller size. - firefoxFontSize(['.header'], firefoxSize), - this.css, - this.renderDarkMediaBlock(), - getAnimations(), - !this.animations && rule('*', NO_MOTION), - ), - this.renderDefs(), - el('rect', { - 'data-testid': 'card-bg', - class: 'card-bg', - ...cardShape, - stroke: this.colors.light.borderColor, - fill: this.colors.light.bgColor, - 'stroke-opacity': this.hideBorder ? 0 : 1, - }), - el('rect', { - 'data-testid': 'card-glow', - ...cardShape, - fill: `url(#${HEAT_ID.glow})`, - }), - !this.hideTitle && this.renderTitleBand(), - !this.hideTitle && this.renderTitle(shrunk ?? CARD_STYLE.fontSize.title), - el( - 'g', - { - 'data-testid': 'main-card-body', - transform: `translate(0, ${this.bodyOffset})`, - }, - body, - ), - ), - ); - } -} - -export { Card, titleFits }; diff --git a/packages/core/src/common/brand.ts b/packages/core/src/common/brand.ts index d4733150..bf040339 100644 --- a/packages/core/src/common/brand.ts +++ b/packages/core/src/common/brand.ts @@ -1,203 +1,5 @@ -/** - * The visual vocabulary every card draws with: one font stack, one type scale, - * one set of widths and the accent rule that marks a card as this tool's. - * A card composes from here; it never writes a `font` shorthand or a size of its own. - */ -import type { Child, CssChild } from '../markup/index.ts'; -import { atRule, cssComment, rule } from '../markup/index.ts'; - -import { icons } from './icons.ts'; - -const FONT_STACK = `'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif`; - -/** - * Every measure a card draws to, under one name so a card imports the vocabulary - * rather than a line of it per figure. - */ -const CARD_STYLE = { - /** - * The widths a card may default to. A card picks the step nearest what its layout - * needs, so cards stacked in a README share an edge instead of forming a ragged one. - */ - width: { - compact: 300, - standard: 400, - wide: 500, - broad: 600, - }, - - /** The type scale, in px. */ - fontSize: { - display: 22, - title: 18, - lead: 16, - body: 14, - meta: 13, - small: 12, - micro: 11, - }, - - /** `regular` labels, `semibold` the values they label, `bold` the rank glyph alone. */ - fontWeight: { - regular: 400, - semibold: 600, - bold: 700, - }, - - /** - * The one size off the scale. Firefox is given 15.5px rather than the scale's next step down, - * because that half pixel is inherited tuning for long titles and nothing here can test the - * wrapping it protects; it lives with the scale so `brand.ts` still owns every size a card draws. - */ - titleFirefoxSize: 15.5, - - /** - * The air a card keeps at its edges. - * - * `bottom` is what the last row's ink is measured against, so it holds whatever a card's row - * spacing is; deriving it from that spacing instead put 33px under a top-languages card - * against the 17px above its title. - */ - padding: { - /** The side padding, `Card`'s `paddingX`: wider than `bottom`, as lines of text need. */ - x: 18, - /** The same air as above the title's ink, so a card sits even between its edges. */ - bottom: 12, - /** The title's baseline: its ink, 13px tall, starts `bottom` below the top edge. */ - titleY: 25, - /** Where `Card` puts the body while the title is shown. */ - bodyOffsetY: 50, - /** Where it puts the body when the title is hidden: the same air above as `bottom` below. */ - untitledBodyOffsetY: 12, - }, - - /** A two-column language list, each row a dot then its label. */ - compactList: { - /** Where a row's label starts, clear of its dot. */ - labelX: 15, - /** Air between the first column's widest label and the second column. */ - columnAir: 5, - }, - - /** How far a stat row's ink reaches below its own top: the icon box, which outruns the text. */ - statRowInk: 15, - - /** `measureText` reads regular widths; a semibold line draws about a tenth wider. */ - semiboldInk: 1.1, - - /** A big total leading a card's body, with a line of what it means beside it. */ - headline: { - /** Its room: the total's line, then the air before what follows. */ - height: 36, - /** The total's baseline, so its ink starts at the body's top. */ - baseline: 17, - /** Air between the total and the line beside it. */ - gap: 10, - }, - - /** Every progress bar a card draws, whole or split by language. */ - bar: { - height: 8, - /** Its ends' rounding: half the height, so each end is a half circle. */ - radius: 4, - }, - - /** The organization calendar: its total, a row of weeks, and the dates and legend under it. */ - calendar: { - /** A week's cell. */ - cellHeight: 28, - /** The baseline of a week's count, from the cell's top: its digits centred in the cell. */ - countBaseline: 18, - /** The least air either side of a week's count; a count that would need less is shortened. */ - countInset: 3, - /** Air between two weeks' cells. */ - cellGap: 2, - /** The baseline of the dates and the legend, below the cells. */ - footBaseline: 17, - /** A legend swatch's side. */ - swatch: 10, - /** Air between the legend's words and its swatches. */ - legendGap: 5, - }, - - /** A row pairing two counts over a bar: the stat line, then the bar comparing them. */ - flowRow: { - /** The bar's top, clear of the stat line's ink. */ - barY: 23, - /** From one row's top to the next row's. */ - height: 45, - }, - - /** The band behind the title, filled with the heat: with the trail, what a card is known by. */ - band: { - /** Clear air the band leaves between its foot and the first row of the body. */ - gap: 9, - /** The last stop's share of the band's opacity, so it cools toward the right. */ - edgeShare: 0.7, - }, - - /** The glow in the card's bottom-right corner, drawn in the heat's middle and last stops. */ - glow: { - /** The last stop's share of the glow's opacity, where the middle one fades into it. */ - edgeShare: 0.35, - }, - - /** The blur behind what burns: the rank ring, a bar's tip. */ - bloom: { - /** The blur's spread, in px. */ - blur: 2, - /** How strongly it shows, on 0–1, so the crisp shape over it reads first. */ - opacity: 0.5, - /** A bar's glowing end: a touch wider than the 8px bar it caps. */ - tipRadius: 4.5, - }, - - /** A repository topic, a tinted pill in one row under the description. */ - topic: { - height: 18, - /** Air either side of the name, inside the pill. */ - paddingX: 8, - gap: 6, - /** Strong enough that a pill reads on its own. */ - opacity: 0.12, - /** What the row adds to the card's height. */ - rowHeight: 26, - }, - - /** - * The heat trail under the title: the mark a card is recognized by. It starts under the icon - * and cools to nothing past the title's end. - */ - accent: { - height: 2, - /** Below the title's descenders. */ - y: 8, - /** How far the trail runs past the title, where it has faded. */ - overrun: 24, - }, -} as const; - -type FontSize = keyof typeof CARD_STYLE.fontSize; -type FontWeight = keyof typeof CARD_STYLE.fontWeight; - -/** @returns The `font` shorthand for one step of the scale. */ -const font = (weight: FontWeight, size: FontSize): string => - `${String(CARD_STYLE.fontWeight[weight])} ${String(CARD_STYLE.fontSize[size])}px ${FONT_STACK}`; - -/** - * Firefox lays Segoe UI out wider than the other engines, so text that fits - * elsewhere overflows there; each affected selector steps down a size. - * - * @returns The `@supports` block that detects Firefox and resizes those selectors. - */ -const firefoxFontSize = (selectors: Array, size: FontSize | number): CssChild => { - const px = typeof size === 'number' ? size : CARD_STYLE.fontSize[size]; - return atRule( - '@supports(-moz-appearance: auto)', - cssComment('Selector detects Firefox'), - selectors.map((selector) => rule(selector, { 'font-size': `${String(px)}px` })), - ); -}; +import { icons } from '@stats-forge/card-kit'; +import type { Child } from '@stats-forge/card-kit'; /** * The title icon each card wears. They are distinct on purpose: with the heat trail @@ -215,4 +17,4 @@ const CARD_ICON = { wakatime: icons.clock, } satisfies Record; -export { CARD_STYLE, CARD_ICON, firefoxFontSize, font }; +export { CARD_ICON }; diff --git a/packages/core/src/common/color.ts b/packages/core/src/common/color.ts deleted file mode 100644 index df4c17e5..00000000 --- a/packages/core/src/common/color.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { DEFAULT_THEME, isThemeName, themes } from '../themes/index.ts'; - -/** Matches a 3-, 4-, 6-, or 8-digit hex color with no leading `#`. */ -const HEX_COLOR = /^(?[A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{4}|[A-Fa-f0-9]{3})$/; - -/** - * Checks if a value is a `#`-prefixed hex color (`"#f00"`, `"#ffffff"`), the form colors take - * right before they are written into the SVG. - * - * @returns True if the value is a `#`-prefixed hex color. - */ -const isPrefixedHexColor = (value: unknown): boolean => - typeof value === 'string' && value.startsWith('#') && HEX_COLOR.test(value.slice(1)); - -/** @returns A `#`-prefixed hex color's digits, a shorthand expanded to six or eight. */ -const expandHex = (hex: string): string => { - const digits = hex.slice(1); - if (digits.length <= 4) { - return [...digits].map((digit) => digit + digit).join(''); - } - return digits; -}; - -/** - * @returns Its `[r, g, b, a]` channels, each on 0–1. - */ -const hexChannels = (hex: string): [number, number, number, number] => { - const expanded = expandHex(hex); - const channel = (index: number): number => - Number.parseInt(expanded.slice(index * 2, index * 2 + 2), 16) / 255; - - return [channel(0), channel(1), channel(2), expanded.length === 8 ? channel(3) : 1]; -}; - -type BackgroundTone = 'light' | 'dark' | 'translucent'; - -/** - * @returns Whether the background reads light, dark, or lets the page behind it show through. - */ -const backgroundTone = (bgColor: string): BackgroundTone => { - if (!isPrefixedHexColor(bgColor)) { - return 'light'; - } - - const [r, g, b, a] = hexChannels(bgColor); - if (a < 0.5) { - return 'translucent'; - } - - return 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5 ? 'dark' : 'light'; -}; - -/** A progress bar's empty track, as bright as `#ddd` is dark on a light card. */ -const PROG_BAR_BG_BY_TONE: Readonly> = { - light: '#ddd', - dark: '#ffffff33', - translucent: '#8b949e59', -}; - -/** - * Resolved card colors for a single color scheme, as written into the SVG. - */ -interface CardColors { - /** The heat gradient's stops, brightest first. */ - heat: [string, string, string]; - /** The heat's middle stop, where a card wants its accent as one color. */ - titleColor: string; - /** Card icon color. */ - iconColor: string; - /** Card text color. */ - textColor: string; - /** Card background color. */ - bgColor: string; - /** Card border color. */ - borderColor: string; - /** Stats card rank ring paint: {@link HEAT_PAINT}'s ring gradient. */ - ringColor: string; - /** Progress bar background color. */ - progBarBgColor: string; - /** How strongly the corner glow shows, on 0–1. */ - glowOpacity: number; - /** How strongly the title band shows, on 0–1. */ - bandOpacity: number; -} - -/** The ids `Card` defines its heat gradients under, so a card can paint with one. */ -const HEAT_ID = { - line: 'heat', - ring: 'heat-ring', - trail: 'heat-trail', - glow: 'heat-glow', - band: 'heat-band', - bloom: 'heat-bloom', -} as const; - -/** The heat gradients as paints, for a `fill` or a `stroke`. */ -const HEAT_PAINT = { - line: `url(#${HEAT_ID.line})`, - ring: `url(#${HEAT_ID.ring})`, - trail: `url(#${HEAT_ID.trail})`, - /** A `filter`, not a paint. */ - bloom: `url(#${HEAT_ID.bloom})`, -} as const; - -/** A dark card glows hotter: the same opacity on a light one would tint the whole card. */ -const GLOW_OPACITY_BY_TONE: Readonly> = { - light: 0.05, - dark: 0.13, - translucent: 0.08, -}; - -/** The band, like the glow, needs more on a dark card to show at all. */ -const BAND_OPACITY_BY_TONE: Readonly> = { - light: 0.05, - dark: 0.08, - translucent: 0.06, -}; - -/** How much of its band a quiet theme keeps: enough to frame the title, too little to glow. */ -const QUIET_BAND_SHARE = 0.5; - -/** `4d` of `ff`: the border is the heat's middle stop at this alpha. */ -const HEAT_BORDER_ALPHA = '4d'; - -/** The one color param a card takes: a theme name, or a `light,dark` pair of them. */ -interface ThemeParams { - theme?: string | undefined; -} - -/** @returns The colors one theme draws with; an unknown name draws `ember_light`. */ -const getCardColors = (name?: string): CardColors => { - const theme = isThemeName(name) ? themes[name] : themes.ember_light; - - const heat: CardColors['heat'] = [`#${theme.heat[0]}`, `#${theme.heat[1]}`, `#${theme.heat[2]}`]; - const [, titleColor] = heat; - const bgColor = `#${theme.bg_color}`; - const tone = backgroundTone(bgColor); - const borderColor = - 'border_color' in theme ? `#${theme.border_color}` : `${titleColor}${HEAT_BORDER_ALPHA}`; - - return { - heat, - titleColor, - iconColor: titleColor, - textColor: `#${theme.text_color}`, - bgColor, - borderColor, - ringColor: HEAT_PAINT.ring, - progBarBgColor: PROG_BAR_BG_BY_TONE[tone], - glowOpacity: 'quiet' in theme ? 0 : GLOW_OPACITY_BY_TONE[tone], - bandOpacity: BAND_OPACITY_BY_TONE[tone] * ('quiet' in theme ? QUIET_BAND_SHARE : 1), - }; -}; - -/** - * Resolves `theme` for both color schemes: one name serves both modes, a `light,dark` pair names - * each mode's own. - * - * @returns Both palettes; `darkColors` is `null` when both modes draw one theme, so the caller - * emits no dark-mode block. - */ -const getLightDarkColors = ({ - theme = DEFAULT_THEME, -}: ThemeParams): { lightColors: CardColors; darkColors: CardColors | null } => { - const [light = '', dark = light] = theme.split(','); - if (light === dark) { - return { lightColors: getCardColors(light), darkColors: null }; - } - return { lightColors: getCardColors(light), darkColors: getCardColors(dark) }; -}; - -/** @returns WCAG's relative luminance of a color's red, green and blue, each on 0–1. */ -const relativeLuminance = (channels: ReadonlyArray): number => { - const [r = 0, g = 0, b = 0] = channels.map((channel) => { - if (channel <= 0.04045) { - return channel / 12.92; - } - return ((channel + 0.055) / 1.055) ** 2.4; - }); - return 0.2126 * r + 0.7152 * g + 0.0722 * b; -}; - -/** @returns WCAG's contrast ratio between two luminances, from 1 to 21. */ -const contrastRatio = (a: number, b: number): number => - (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); - -/** The pages a translucent card may stand on: GitHub's light and dark grounds. */ -const PAGE_GROUNDS = ['#ffffff', '#0d1117'] as const; - -/** WCAG AA's contrast for text, which the theme's own ink is kept while it clears. */ -const TEXT_CONTRAST = 4.5; - -/** - * The ink that reads best on a fill laid at `opacity` over the card's background: the theme's text - * color or its background while one clears {@link TEXT_CONTRAST}, else black or white. A - * translucent card's ink must read on either page it may stand on. - * - * @returns The ink. - */ -const inkOn = (colors: CardColors, fill: string, opacity: number): string => { - const { textColor, bgColor } = colors; - const translucent = backgroundTone(bgColor) === 'translucent'; - const grounds = translucent ? PAGE_GROUNDS : [bgColor]; - const [r, g, b, a] = hexChannels(fill); - const alpha = a * opacity; - const laid = grounds.map((ground) => { - const below = hexChannels(ground); - return relativeLuminance( - [r, g, b].map((channel, index) => channel * alpha + (below[index] ?? 0) * (1 - alpha)), - ); - }); - /** @returns The ink's contrast on the fill over whichever ground it reads worst on. */ - const worst = (ink: string): number => - Math.min(...laid.map((cell) => contrastRatio(cell, relativeLuminance(hexChannels(ink))))); - /** @returns The first of `inks` that reads best, so a tie keeps the theme's own. */ - const best = ([first, ...rest]: readonly [string, ...Array]): string => { - let kept = first; - for (const ink of rest) { - if (worst(ink) > worst(kept)) { - kept = ink; - } - } - return kept; - }; - - // a translucent background is no ink at all - const own = best(translucent ? [textColor] : [textColor, bgColor]); - if (worst(own) >= TEXT_CONTRAST) { - return own; - } - return best([own, '#ffffff', '#000000']); -}; - -/** What a card stands on: its one background's tone, or `viewer` when it carries both schemes. */ -type CardGround = BackgroundTone | 'viewer'; - -/** @returns The ground a card drawn in this theme stands on. */ -const cardGround = (params: ThemeParams): CardGround => { - const { lightColors, darkColors } = getLightDarkColors(params); - if (darkColors) { - return 'viewer'; - } - return backgroundTone(lightColors.bgColor); -}; - -export type { CardColors, CardGround, ThemeParams }; - -export { - cardGround, - getCardColors, - getLightDarkColors, - HEAT_ID, - HEAT_PAINT, - inkOn, - isPrefixedHexColor, -}; diff --git a/packages/core/src/common/config.ts b/packages/core/src/common/config.ts index c56b15df..e9587008 100644 --- a/packages/core/src/common/config.ts +++ b/packages/core/src/common/config.ts @@ -1,9 +1,8 @@ +import { consoleLogger, defaultFetch, parseArray, withTimeout } from '@stats-forge/card-kit'; +import type { FetchLike, Logger } from '@stats-forge/card-kit'; + import type { FailureReason } from './error.ts'; -import { GITHUB_API_URL, defaultFetch, withTimeout } from './http.ts'; -import type { FetchLike } from './http.ts'; -import { consoleLogger } from './log.ts'; -import type { Logger } from './log.ts'; -import { parseArray } from './ops.ts'; +import { GITHUB_API_URL } from './http.ts'; type Env = Record; @@ -147,4 +146,4 @@ export class CardConfig { } export type { AllowlistKind, PersonalAccessToken, CardConfigInit }; -export type { Logger } from './log.ts'; +export type { Logger } from '@stats-forge/card-kit'; diff --git a/packages/core/src/common/error.ts b/packages/core/src/common/error.ts index c349b90a..9c21eec1 100644 --- a/packages/core/src/common/error.ts +++ b/packages/core/src/common/error.ts @@ -1,63 +1,17 @@ /** - * @file One error type for everything a card render can fail on. - * - * A failure carries a `code` a host can branch on, the two lines the error card - * draws, and whether repeating the request could help. - * The fetchers throw this rather than a bare `Error`, so a host is told what failed. - * Anything else that escapes is wrapped as `upstream` by `from`, which is retryable — - * so a permanent failure has to throw a `CardError` to be reported as one. - * A failure is thrown by its reason, and `FAILURES` below is the only place it is worded. + * @file The failures GitHub's and WakaTime's cards are thrown for, worded once and registered with + * the kit's `CardError` on import: a module that throws one imports `CardError` from here. */ -import type { GraphQLResponse } from './http.ts'; -import type { Logger } from './log.ts'; -import { wrapTextMultiline } from './text.ts'; - -/** Why a request failed. */ -type ErrorCode = - /** The token may not read a stat the query asked for. */ - | 'forbidden' - /** The query is wrong: a param is malformed, unsafe, or not renderable. */ - | 'invalid_param' - /** The query is missing a param the endpoint cannot render without. */ - | 'missing_param' - /** The query is well-formed, but this deployment is pinned to identities it does not name. */ - | 'not_allowed' - /** The user, repository or gist does not exist. */ - | 'not_found' - /** No GitHub token is set, or GitHub rejected every one — retryable, the fix being a token on the next start. */ - | 'no_tokens' - /** Every token is rate limited. */ - | 'rate_limited' - /** GitHub or WakaTime answered with something unusable, or could not be reached at all. */ - | 'upstream'; - -/** What a failure is, once it reaches the api layer. */ -interface CardErrorInit { - /** Why it failed. */ - code: ErrorCode; - /** Second line of the error card. */ - secondaryMessage?: string | undefined; - /** The first param at fault, when the failure names one. */ - param?: string | undefined; - /** What this failure was raised from. Kept for logs; the card never draws it. */ - cause?: unknown; - /** Whether filing an issue could help; `true` when omitted, and never for `not_found`. */ - reportable?: boolean | undefined; - /** - * Whether repeating the request could produce a different answer; `false` when omitted. - * A `forbidden` is, as the retryer starts at a random token and the next may be permitted. - */ - retryable?: boolean | undefined; -} - -/** One failure, worded: the first line of its card, and everything else it carries. */ -interface Failure extends CardErrorInit { - message: string; -} +import { + CardError, + registerFailures, + TRY_AGAIN_LATER, + wrapTextMultiline, +} from '@stats-forge/card-kit'; +import type { Failure, Logger } from '@stats-forge/card-kit'; -/** The second line of upstream's own outage, which no issue could fix. */ -const TRY_AGAIN_LATER = 'Please try again later'; +import type { GraphQLResponse } from './http.ts'; /** @returns The names as prose: `a`, `a and b`, `a, b and c`. */ const listed = (names: ReadonlyArray): string => { @@ -110,41 +64,11 @@ const FAILURES = { secondaryMessage: TRY_AGAIN_LATER, reportable: false, }), - /** The param a check rejected, and the check's own wording of why. */ - invalid_param: ({ param, detail }: { param: string; detail: string }): Failure => ({ - code: 'invalid_param', - message: 'Something went wrong', - secondaryMessage: detail, - param, - }), invalid_username: (): Failure => ({ code: 'invalid_param', message: 'Invalid username provided.', param: 'username', }), - // the caller's to fix, so the card links its docs instead of asking for an issue - /** Every param missing, first the one at fault, and where to read how to pass them. */ - missing_param: ({ - params, - hint, - }: { - params: Array; - hint?: string | undefined; - }): Failure => ({ - code: 'missing_param', - message: `Missing params ${params.map((param) => `"${param}"`).join(', ')} make sure you pass the parameters in URL`, - secondaryMessage: hint, - param: params[0], - reportable: false, - }), - // names neither the value nor the list: one is the caller's, the other the operator's - /** The identity param the deployment refused. */ - not_allowed: ({ param }: { param: string }): Failure => ({ - code: 'not_allowed', - message: 'Not allowed', - secondaryMessage: 'This deployment does not serve that account', - param, - }), org_activity_issues_forbidden: (): Failure => ({ code: 'forbidden', retryable: true, @@ -253,102 +177,39 @@ const FAILURES = { }), }; -/** Everything this package throws. */ -class CardError extends Error { - readonly code: ErrorCode; - readonly secondaryMessage: string | undefined; - readonly param: string | undefined; - /** Whether filing an issue could help, which decides the card's report line. */ - readonly reportable: boolean; - /** - * Whether repeating the request could produce a different answer. - * A host reads this to decide between caching the failure and retrying it. - */ - readonly retryable: boolean; - #reason: FailureReason | undefined; +type GitHubFailures = typeof FAILURES; - // only `because`, `from` and `fromGraphQL` build one, so every failure has a reason or is wrapped - private constructor(message: string, init: CardErrorInit) { - // `cause` is installed by its presence, so an absent one must not be passed as undefined - super(message, init.cause === undefined ? undefined : { cause: init.cause }); - this.name = 'CardError'; - this.code = init.code; - this.secondaryMessage = init.secondaryMessage; - this.param = init.param; - // a name nothing answers to is the visitor's to fix, never a bug - this.reportable = init.code !== 'not_found' && (init.reportable ?? true); - this.retryable = init.retryable ?? false; - } - - /** Which failure this is; absent for one wrapped by `from`. */ - get reason(): FailureReason | undefined { - return this.#reason; - } +declare module '@stats-forge/card-kit' { + // oxlint-disable-next-line typescript/no-empty-object-type -- adds this package's reasons + interface Failures extends GitHubFailures {} +} - /** - * The failure `reason` names, worded from `data` when the reason needs any. - * - * @returns The error. - */ - static because( - reason: TReason, - ...data: Parameters<(typeof FAILURES)[TReason]> - ): CardError { - // one entry's own signature, which `data` was checked against at the call site - const word = FAILURES[reason] as (...args: typeof data) => Failure; - const { message, ...init } = word(...data); - const error = new CardError(message, init); - error.#reason = reason; - return error; - } +registerFailures(FAILURES); - /** - * Anything thrown that is not already a `CardError`: - * an upstream failure, since the query itself got this far. - * - * @returns The error, unchanged when it already was one. - */ - static from(err: unknown): CardError { - if (err instanceof CardError) { - return err; - } - const message = err instanceof Error ? err.message : 'An unknown error occurred'; - return new CardError(message, { - code: 'upstream', - secondaryMessage: TRY_AGAIN_LATER, - reportable: false, - retryable: true, - }); +/** + * The failure a GraphQL query answered with. + * + * @returns `not_found` when the user does not resolve; otherwise `upstream`, worded from the first entry or from `subject`. + */ +const fromGraphQL = ( + errors: NonNullable['data']['errors']>, + statusText: string, + /** What the query asked for, as "the ___ using the GraphQL API" reads. */ + subject: string, + /** Receives the whole list, of which the card shows only the first entry. */ + logger: Logger, +): CardError => { + logger.error(errors); + const [firstError] = errors; + if (firstError?.type === 'NOT_FOUND') { + return CardError.because('user_not_found', { message: firstError.message }); } - - /** - * The failure a GraphQL query answered with. - * - * @returns `not_found` when the user does not resolve; otherwise `upstream`, worded from the first entry or from `subject`. - */ - static fromGraphQL( - errors: NonNullable['data']['errors']>, - statusText: string, - /** What the query asked for, as "the ___ using the GraphQL API" reads. */ - subject: string, - /** Receives the whole list, of which the card shows only the first entry. */ - logger: Logger, - ): CardError { - logger.error(errors); - const [firstError] = errors; - if (firstError?.type === 'NOT_FOUND') { - return CardError.because('user_not_found', { message: firstError.message }); - } - if (firstError?.message) { - return CardError.because('github_error', { message: firstError.message, statusText }); - } - return CardError.because('graphql_failed', { subject }); + if (firstError?.message) { + return CardError.because('github_error', { message: firstError.message, statusText }); } -} - -/** The name of a failure, as `CardError.because` takes it. */ -type FailureReason = Parameters[0]; - -export type { ErrorCode, FailureReason }; + return CardError.because('graphql_failed', { subject }); +}; -export { CardError }; +export { fromGraphQL }; +export { CardError } from '@stats-forge/card-kit'; +export type { ErrorCode, FailureReason } from '@stats-forge/card-kit'; diff --git a/packages/core/src/common/fmt.ts b/packages/core/src/common/fmt.ts deleted file mode 100644 index 5c62d214..00000000 --- a/packages/core/src/common/fmt.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Locale } from './localize.ts'; -import { intlTag } from './localize.ts'; - -/** Built once per tag and digits: building a formatter costs far more than formatting with one. */ -const decimalFormats = new Map(); - -/** - * A figure with the locale's decimal separator, kept in Latin digits and ungrouped so it - * measures as the English one does. - * - * @returns The figure, to between `minimum` and `maximum` decimals. - */ -const formatDecimal = ( - value: number, - locale: Locale | undefined, - minimum: number, - maximum = minimum, -): string => { - const tag = intlTag(locale); - const key = `${tag} ${String(minimum)} ${String(maximum)}`; - let format = decimalFormats.get(key); - if (!format) { - format = new Intl.NumberFormat(tag, { - minimumFractionDigits: minimum, - maximumFractionDigits: maximum, - numberingSystem: 'latn', - useGrouping: false, - }); - decimalFormats.set(key, format); - } - return format.format(value); -}; - -/** - * Retrieves num with suffix k(thousands) precise to given decimal places. - * - * @returns The formatted number. - */ -const kFormatter = ( - num: number, - locale: Locale | undefined, - /** Decimals, as `clampPrecision` leaves them; the abbreviation's own default when absent. */ - precision?: number, -): string | number => { - if (precision !== undefined) { - return `${formatDecimal(num / 1000, locale, precision)}k`; - } - - if (Math.abs(num) < 1000) { - return num; - } - - return `${formatDecimal(num / 1000, locale, 0, 1)}k`; -}; - -/** - * Convert bytes to a human-readable string representation. - * - * @throws {Error} If bytes is negative or too large. - * - * @returns The human-readable representation of bytes. - */ -const formatBytes = (bytes: number, locale: Locale | undefined): string => { - if (bytes < 0) { - throw new Error('Bytes must be a non-negative number'); - } - - if (bytes === 0) { - return '0 B'; - } - - const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; - const base = 1024; - const i = Math.floor(Math.log(bytes) / Math.log(base)); - - const unit = sizes[i]; - if (unit === undefined) { - throw new Error('Bytes is too large to convert to a human-readable string'); - } - - return `${formatDecimal(bytes / base ** i, locale, 1)} ${unit}`; -}; - -export { formatBytes, formatDecimal, kFormatter }; diff --git a/packages/core/src/common/http.ts b/packages/core/src/common/http.ts index 4a37a796..46895cb3 100644 --- a/packages/core/src/common/http.ts +++ b/packages/core/src/common/http.ts @@ -1,3 +1,6 @@ +import { httpRequest } from '@stats-forge/card-kit'; +import type { FetchLike, HttpResponse } from '@stats-forge/card-kit'; + import type { GraphQLDocument } from '../graphql/graphqlDocument.ts'; /** GitHub.com's REST API, which a GitHub Enterprise Server host replaces with its own `/api/v3`. */ @@ -11,41 +14,6 @@ const GITHUB_API_URL = 'https://api.github.com'; */ const graphqlUrl = (apiUrl: string): string => `${apiUrl.replace(/\/api\/v3$/, '/api')}/graphql`; -/** - * The slice of `fetch` this library calls. - * A host substitutes it through `CardConfig` to mock, cache or proxy every request core makes. - */ -type FetchLike = (input: string, init?: RequestInit) => Promise; - -/** - * Resolves the global `fetch` at call time rather than capturing it on import, - * so a host that patches the global afterwards is still picked up. - */ -const defaultFetch: FetchLike = async (input, init) => fetch(input, init); - -/** - * @returns `fetchImpl`, giving up on each request after `timeoutMs`; `fetchImpl` itself without one. - */ -const withTimeout = (fetchImpl: FetchLike, timeoutMs: number | undefined): FetchLike => { - if (timeoutMs === undefined) { - return fetchImpl; - } - return async (input, init) => - fetchImpl(input, { ...init, signal: AbortSignal.timeout(timeoutMs) }); -}; - -/** - * A response, parsed. - * A non-2xx is a value here rather than a throw: - * the retryer reads the body of a rejected request to tell a spent token from a rate limit. - */ -interface HttpResponse { - status: number; - /** Reason phrase, which HTTP/2 omits — only ever used to annotate an error. */ - statusText: string; - data: TData; -} - /** What the retryer hands a fetcher on each attempt. */ interface FetcherContext { /** Transport to send the request with. */ @@ -56,32 +24,6 @@ interface FetcherContext { retries: number; } -/** - * @returns The response; a JSON body parsed, anything else left as text. - */ -const httpRequest = async ( - fetchImpl: FetchLike, - url: string, - init?: RequestInit, -): Promise> => { - const response = await fetchImpl(url, init); - const body = await response.text(); - - let data: unknown; - try { - data = JSON.parse(body); - } catch { - // an empty or non-JSON body reads as text, so callers still get a defined `data` - data = body; - } - - return { - status: response.status, - statusText: response.statusText, - data: data as TData, - }; -}; - /** Response of a GraphQL call: the envelope the GitHub API wraps results in. */ type GraphQLResponse = HttpResponse<{ data: TResult; @@ -141,12 +83,5 @@ const createGraphQLFetcher = return { ...response, data: asEnvelope(response.data) as GraphQLResponse['data'] }; }; -export { - createGraphQLFetcher, - defaultFetch, - GITHUB_API_URL, - httpRequest, - UNREADABLE_REPLY, - withTimeout, -}; -export type { FetcherContext, FetchLike, GraphQLResponse, HttpResponse }; +export { createGraphQLFetcher, GITHUB_API_URL, UNREADABLE_REPLY }; +export type { FetcherContext, GraphQLResponse }; diff --git a/packages/core/src/common/icons.ts b/packages/core/src/common/icons.ts deleted file mode 100644 index f47fb0bc..00000000 --- a/packages/core/src/common/icons.ts +++ /dev/null @@ -1,183 +0,0 @@ -/* -The icons in this file are based on https://github.com/primer/octicons which is released under the MIT license: - -MIT License - -Copyright (c) 2026 GitHub Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ -import type { Child } from '../markup/index.ts'; -import { el } from '../markup/index.ts'; - -/** eye-16. GitHub marks both a review and a watcher with it, so two keys share the one path. */ -const EYE = el('path', { - 'fill-rule': 'evenodd', - d: 'M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z', -}); - -const icons = { - star: el('path', { - 'fill-rule': 'evenodd', - d: 'M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z', - }), - commits: el('path', { - 'fill-rule': 'evenodd', - d: 'M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z', - }), - prs: el('path', { - 'fill-rule': 'evenodd', - d: 'M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z', - }), - prs_merged: el('path', { - 'fill-rule': 'evenodd', - d: 'M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z', - }), - prs_merged_percentage: el('path', { - 'fill-rule': 'evenodd', - d: 'M13.442 2.558a.625.625 0 0 1 0 .884l-10 10a.625.625 0 1 1-.884-.884l10-10a.625.625 0 0 1 .884 0zM4.5 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm7 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z', - }), - issues: el('path', { - 'fill-rule': 'evenodd', - d: 'M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z', - }), - repo: el('path', { - 'fill-rule': 'evenodd', - d: 'M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z', - }), - fork: el('path', { - 'fill-rule': 'evenodd', - d: 'M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z', - }), - reviews: EYE, - watchers: EYE, - discussions_started: el('path', { - 'fill-rule': 'evenodd', - d: 'M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z', - }), - discussions_answered: el('path', { - 'fill-rule': 'evenodd', - d: 'M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z', - }), - comments: el('path', { - d: 'M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z', - }), - contributions: el('path', { - d: 'M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7.25-3.25v2.5h2.5a.75.75 0 0 1 0 1.5h-2.5v2.5a.75.75 0 0 1-1.5 0v-2.5h-2.5a.75.75 0 0 1 0-1.5h2.5v-2.5a.75.75 0 0 1 1.5 0Z', - }), - gist: el('path', { - 'fill-rule': 'evenodd', - d: 'M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z', - }), - pulse: el('path', { - d: 'M6 2c.306 0 .582.187.696.471L10 10.731l1.304-3.26A.751.751 0 0 1 12 7h3.25a.75.75 0 0 1 0 1.5h-2.742l-1.812 4.529a.751.751 0 0 1-1.392 0L6 4.769l-1.304 3.26A.751.751 0 0 1 4 8.5H.75a.75.75 0 0 1 0-1.5h2.742l1.812-4.529A.751.751 0 0 1 6 2Z', - }), - code: el('path', { - d: 'M4.72 3.22a.75.75 0 0 1 1.06 1.06L2.06 8l3.72 3.72a.75.75 0 1 1-1.06 1.06L.47 8.53a.75.75 0 0 1 0-1.06Zm6.56 0a.75.75 0 1 0-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 1 0 1.06 1.06l4.25-4.25a.75.75 0 0 0 0-1.06Z', - }), - clock: el('path', { - d: 'M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.75.75 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z', - }), - organization: el('path', { - d: 'M1.75 16A1.75 1.75 0 0 1 0 14.25V1.75C0 .784.784 0 1.75 0h8.5C11.216 0 12 .784 12 1.75v12.5c0 .085-.006.168-.018.25h2.268a.25.25 0 0 0 .25-.25V8.285a.25.25 0 0 0-.111-.208l-1.055-.703a.749.749 0 1 1 .832-1.248l1.055.703c.487.325.779.871.779 1.456v5.965A1.75 1.75 0 0 1 14.25 16h-3.5a.766.766 0 0 1-.197-.026c-.099.017-.2.026-.303.026h-3a.75.75 0 0 1-.75-.75V14h-1v1.25a.75.75 0 0 1-.75.75Zm-.25-1.75c0 .138.112.25.25.25H4v-1.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 .75.75v1.25h2.25a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25ZM3.75 6h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM3 3.75A.75.75 0 0 1 3.75 3h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 3 3.75Zm4 3A.75.75 0 0 1 7.75 6h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 7 6.75ZM7.75 3h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5ZM3 9.75A.75.75 0 0 1 3.75 9h.5a.75.75 0 0 1 0 1.5h-.5A.75.75 0 0 1 3 9.75ZM7.75 9h.5a.75.75 0 0 1 0 1.5h-.5a.75.75 0 0 1 0-1.5Z', - }), - people: el('path', { - d: 'M2 5.5a3.5 3.5 0 1 1 5.898 2.549 5.508 5.508 0 0 1 3.034 4.084.75.75 0 1 1-1.482.235 4 4 0 0 0-7.9 0 .75.75 0 0 1-1.482-.236A5.507 5.507 0 0 1 3.102 8.05 3.493 3.493 0 0 1 2 5.5ZM11 4a3.001 3.001 0 0 1 2.22 5.018 5.01 5.01 0 0 1 2.56 3.012.749.749 0 0 1-.885.954.752.752 0 0 1-.549-.514 3.507 3.507 0 0 0-2.522-2.372.75.75 0 0 1-.574-.73v-.352a.75.75 0 0 1 .416-.672A1.5 1.5 0 0 0 11 5.5.75.75 0 0 1 11 4Zm-5.5-.5a2 2 0 1 0-.001 3.999A2 2 0 0 0 5.5 3.5Z', - }), - tag: el('path', { - d: 'M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z', - }), - flame: el('path', { - d: 'M9.533.753V.752c.217 2.385 1.463 3.626 2.653 4.81C13.37 6.74 14.498 7.863 14.498 10c0 3.5-3 6-6.5 6S1.5 13.512 1.5 10c0-1.298.536-2.56 1.425-3.286.376-.308.862 0 1.035.454C4.46 8.487 5.581 8.419 6 8c.282-.282.341-.811-.003-1.5C4.34 3.187 7.035.75 8.77.146c.39-.137.726.194.763.607ZM7.998 14.5c2.832 0 5-1.98 5-4.5 0-1.463-.68-2.19-1.879-3.383l-.036-.037c-1.013-1.008-2.3-2.29-2.834-4.434-.322.256-.63.579-.864.953-.432.696-.621 1.58-.046 2.73.473.947.67 2.284-.278 3.232-.61.61-1.545.84-2.403.633a2.79 2.79 0 0 1-1.436-.874A3.198 3.198 0 0 0 3 10c0 2.53 2.164 4.5 4.998 4.5Z', - }), - calendar: el('path', { - d: 'M4.75 0a.75.75 0 0 1 .75.75V2h5V.75a.75.75 0 0 1 1.5 0V2h1.25c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 13.25 16H2.75A1.75 1.75 0 0 1 1 14.25V3.75C1 2.784 1.784 2 2.75 2H4V.75A.75.75 0 0 1 4.75 0ZM2.5 7.5v6.75c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25V7.5Zm10.75-4H2.75a.25.25 0 0 0-.25.25V6h11V3.75a.25.25 0 0 0-.25-.25Z', - }), -}; - -/** Attributes every rank glyph shares, so the three variants differ only where they mean to. */ -const RANK_TEXT = { - x: -5, - 'alignment-baseline': 'central', - 'dominant-baseline': 'central', - 'text-anchor': 'middle', -} as const; - -/** - * Get rank icon - * - * @returns The rank icon. - */ -const rankIcon = ( - kind: string, - rankLevel: string, - /** The percentile ring's two lines, in the card's locale; `value` is written without its `%`. */ - percentile: { header: string; value: string }, -): Child => { - switch (kind) { - case 'github': - return el( - 'svg', - { - x: -38, - y: -30, - height: 66, - width: 66, - 'aria-hidden': 'true', - viewBox: '0 0 16 16', - version: '1.1', - 'data-view-component': 'true', - 'data-testid': 'github-rank-icon', - }, - el('path', { - d: 'M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z', - }), - ); - - case 'percentile': - return [ - el( - 'text', - { - ...RANK_TEXT, - y: -12, - 'data-testid': 'percentile-top-header', - class: 'rank-percentile-header', - }, - percentile.header, - ), - el( - 'text', - { - ...RANK_TEXT, - y: 12, - 'data-testid': 'percentile-rank-value', - class: 'rank-percentile-text', - }, - `${percentile.value}%`, - ), - ]; - - case 'default': - default: - return el('text', { ...RANK_TEXT, y: 3, 'data-testid': 'level-rank-icon' }, rankLevel); - } -}; - -export { icons, rankIcon }; diff --git a/packages/core/src/common/locales.ts b/packages/core/src/common/locales.ts index 87856c68..f1e48b17 100644 --- a/packages/core/src/common/locales.ts +++ b/packages/core/src/common/locales.ts @@ -1,4 +1,4 @@ -import { defineLocales } from './localize.ts'; +import { defineLocales } from '@stats-forge/card-kit'; /** * The wordings more than one card draws, in the locales they have been translated into. diff --git a/packages/core/src/common/localize.ts b/packages/core/src/common/localize.ts deleted file mode 100644 index 6d25047e..00000000 --- a/packages/core/src/common/localize.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Every locale a card can be asked for. - * - * A key is written in `en` first and translated afterwards, so a card's table need not - * carry every locale — a wording falls back to `en`. This list is what the api accepts. - * - * @see https://www.andiamo.co.uk/resources/iso-language-codes/ for language codes. - */ -const AVAILABLE_LOCALES = [ - 'en', - 'ar', - 'az', - 'bg', - 'bn', - 'ca', - 'zh-cn', - 'zh-tw', - 'cs', - 'de', - 'sw', - 'ur', - 'es', - 'fa', - 'fi', - 'fr', - 'hi', - 'sa', - 'hu', - 'it', - 'ja', - 'ko', - 'nl', - 'pt-pt', - 'pt-br', - 'ne', - 'el', - 'ro', - 'ru', - 'uk-ua', - 'id', - 'ml', - 'my', - 'ta', - 'sk', - 'tr', - 'pl', - 'uz', - 'vi', - 'sv', - 'he', - 'fil', - 'th', - 'sr', - 'sr-latn', - 'no', - 'be', -] as const; - -/** A locale a card can draw, as {@link AVAILABLE_LOCALES} spells it. */ -type Locale = (typeof AVAILABLE_LOCALES)[number]; - -const FALLBACK_LOCALE: Locale = 'en'; - -/** Another code a README may use for a listed locale: an older one, or a shorter one. */ -const LOCALE_ALIASES = new Map([ - ['kr', 'ko'], - ['cn', 'zh-cn'], - ['zh', 'zh-cn'], - ['np', 'ne'], - ['se', 'sv'], - ['uk', 'uk-ua'], -]); - -/** - * The tag `Intl` reads a locale by, each listed code being one it knows. - * - * @returns The tag, English's when no locale was asked for. - */ -const intlTag = (locale: Locale | undefined): string => locale ?? FALLBACK_LOCALE; - -/** - * The listed locale a requested code draws, matched in any case. - * - * @returns The code as {@link AVAILABLE_LOCALES} spells it, or `undefined` for one it lacks. - */ -const resolveLocale = (locale: string): Locale | undefined => { - const lower = locale.toLowerCase(); - return AVAILABLE_LOCALES.find((listed) => listed === lower) ?? LOCALE_ALIASES.get(lower); -}; - -/** - * A count-dependent wording: one form per plural category the locale distinguishes. - * - * `other` is the form every locale has, so it is what an unwritten category falls back to. - */ -type PluralForms = { other: string } & Partial>; - -/** One locale's wording for a key, either outright or a form per plural category. */ -type Phrase = string | PluralForms; - -/** One locale's wordings, by key. */ -type Wordings = Record; - -/** - * A card's translations: a locale, then that locale's wording per key. - * - * `en` is required and declares every key, because it is what every other locale falls back to. - */ -type LocaleTable = { en: Wordings } & Partial>; - -/** The `{name}` placeholders in a phrase, as a union of their names. */ -type PlaceholderNames = Written extends `${string}{${infer Name}}${infer Rest}` - ? Name | PlaceholderNames - : never; - -/** What a phrase has to be given: its placeholders, plus `count` when it has plural forms. */ -type PhraseValues = - // a wording only known at runtime declares nothing, so it asks for nothing - string extends Written - ? never - : Written extends string - ? PlaceholderNames - : Written extends Record - ? 'count' | PlaceholderNames - : never; - -/** Plural categories `Intl.PluralRules` never picks, so a form written under one is never drawn. */ -type UnknownCategories = - Written extends Record ? Exclude : never; - -/** - * A translation, or `never` when it needs a value the English wording does not supply or - * writes a plural form under a category no locale has. - */ -type Translation = [ - Exclude, PhraseValues> | UnknownCategories, -] extends [never] - ? Written - : never; - -/** - * A table as it may be written: listed locales only, which an alias or a typo is not, each - * naming only keys `en` declares and only the values its English wording supplies. - */ -type Checked = { - [Code in keyof Table]: Code extends Locale - ? { - [Key in keyof Table[Code]]: Key extends keyof Table['en'] - ? Translation - : never; - } - : never; -}; - -/** - * Declares a card's translations, keeping every string's literal type so `localize` knows - * which `{placeholder}` values each key needs, and so a translation is checked against them. - * - * @returns The table it was given. - */ -const defineLocales = (table: Table & Checked
): Table => - table; - -/** `t`'s trailing argument: absent for a wording that interpolates nothing. */ -type ValuesArg = [PhraseValues] extends [never] - ? [] - : [ - values: { - [Name in PhraseValues]: Name extends 'count' ? number : string | number; - }, - ]; - -/** The values as `t` reads them back, once the generic tuple above has done its checking. */ -type ReadValues = Record | undefined; - -/** `{name}` — a placeholder in a phrase. */ -const PLACEHOLDER = /\{(?\w+)\}/g; - -/** - * Substitutes a phrase's `{name}` placeholders. Only the phrase is scanned, so a value - * that itself looks like a placeholder is left alone. - * - * @returns The wording as the card draws it. - */ -const interpolate = (phrase: string, key: string, values: ReadValues): string => - phrase.replaceAll(PLACEHOLDER, (_placeholder, name: string) => { - const value = values?.[name]; - if (value === undefined) { - throw new Error(`'${key}' needs a value for '{${name}}'`); - } - return String(value); - }); - -const pluralRules = new Map(); - -/** - * The plural rules of the locale that supplied the phrase. - * - * @returns The rules, built once per locale. - */ -const rulesFor = (locale: Locale): Intl.PluralRules => { - const cached = pluralRules.get(locale); - if (cached) { - return cached; - } - - const rules = new Intl.PluralRules(intlTag(locale)); - pluralRules.set(locale, rules); - return rules; -}; - -/** - * Picks the form a count calls for, by the rules of the locale the phrase came from. - * - * @returns The chosen form, or `other` for a category that locale has not written. - */ -const selectPlural = ( - forms: PluralForms, - locale: Locale, - key: string, - values: ReadValues, -): string => { - const count = values?.['count']; - if (typeof count !== 'number') { - throw new TypeError(`'${key}' has plural forms and needs a numeric 'count'`); - } - return forms[rulesFor(locale).select(count)] ?? forms.other; -}; - -/** - * Draws a wording in the plural form its count calls for, by the rules of the locale it came - * from, with its `{name}` placeholders substituted. - * - * @returns The wording as the card draws it. - */ -const draw = (phrase: Phrase, from: Locale, key: string, values: ReadValues): string => { - const wording = typeof phrase === 'string' ? phrase : selectPlural(phrase, from, key, values); - - return interpolate(wording, key, values); -}; - -/** - * A card's translations bound to one locale: each key a function of the values its own - * wording declares. - */ -type Localized
= { - [Key in keyof Table['en']]: (...args: ValuesArg) => string; -}; - -/** - * Binds a card's translations to one locale, so a card names a wording where it draws it - * — `t.title({ login })` — rather than naming a key. That is what lets an editor follow a - * wording to where it is written, and why a key carries no card name in front of it. - * A key the locale lacks draws in English. - * - * @returns One function per key the table declares. - */ -const localize =
(table: Table, locale?: Locale): Localized
=> { - const asked = locale ?? FALLBACK_LOCALE; - const wordings: Record string> = {}; - - for (const [key, english] of Object.entries(table.en)) { - const written = table[asked]?.[key]; - // a fallback is English, so English rules pick its plural form - const from = written === undefined ? FALLBACK_LOCALE : asked; - const phrase = written ?? english; - wordings[key] = (values): string => draw(phrase, from, key, values); - } - - // A mapped type over a table known only as a type parameter needs the assertion. - return wordings as Localized
; -}; - -export { AVAILABLE_LOCALES, defineLocales, intlTag, LOCALE_ALIASES, localize, resolveLocale }; -export type { Locale, LocaleTable, Localized }; diff --git a/packages/core/src/common/log.ts b/packages/core/src/common/log.ts deleted file mode 100644 index 53742502..00000000 --- a/packages/core/src/common/log.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @file Where this package's diagnostics go: a host hands a `Logger` to `CardConfig`. - */ - -/** Receives core's diagnostics, each as `console.log` or `console.error` would. */ -interface Logger { - log: (...args: Array) => void; - error: (...args: Array) => void; -} - -/** Dispatches to `console` at call time, so a host or test that replaces `console.log` is obeyed. */ -const consoleLogger: Logger = { - log: (...args) => { - console.log(...args); - }, - error: (...args) => { - console.error(...args); - }, -}; - -export { consoleLogger }; -export type { Logger }; diff --git a/packages/core/src/common/ops.ts b/packages/core/src/common/ops.ts index 50a31765..77567dab 100644 --- a/packages/core/src/common/ops.ts +++ b/packages/core/src/common/ops.ts @@ -1,16 +1,5 @@ import { getEmoji } from './emojiMap.ts'; -/** - * A comma-separated list, as a URL is typed by hand: `a, b,` is `a` and `b`. - * - * @returns The trimmed entries, without the empty ones. - */ -const parseArray = (str: string | undefined): Array => - (str ?? '') - .split(',') - .map((entry) => entry.trim()) - .filter((entry) => entry !== ''); - /** * Whether a repository was excluded by the query or by the deployment. * @@ -27,19 +16,6 @@ const excludes = (entries: Array): ((nameWithOwner: string) => boolean) }; }; -/** - * Clamp the given number between the given range. - * - * @returns The clamped number; `min` for `NaN`. - */ -const clampValue = (number: number, min: number, max: number): number => { - if (Number.isNaN(number)) { - return min; - } - - return Math.max(min, Math.min(number, max)); -}; - /** * Lowercase and trim string. * @@ -89,12 +65,4 @@ const buildSearchFilter = (scope: ReadonlyArray): string => .map((entry) => (entry.endsWith('/*') ? `owner:${entry.slice(0, -2)} ` : `repo:${entry} `)) .join(''); -export { - parseArray, - excludes, - clampValue, - lowercaseTrim, - chunkArray, - parseEmojis, - buildSearchFilter, -}; +export { buildSearchFilter, chunkArray, excludes, lowercaseTrim, parseEmojis }; diff --git a/packages/core/src/common/render.ts b/packages/core/src/common/render.ts deleted file mode 100644 index b1c6bdb7..00000000 --- a/packages/core/src/common/render.ts +++ /dev/null @@ -1,464 +0,0 @@ -import type { Attributes, Child, MarkupElement } from '../markup/index.ts'; -import { atRule, el, renderMarkup, rule, style } from '../markup/index.ts'; - -import { CARD_STYLE } from './brand.ts'; -import type { ThemeParams } from './color.ts'; -import { HEAT_PAINT, getLightDarkColors, isPrefixedHexColor } from './color.ts'; -import { kFormatter } from './fmt.ts'; -import type { Locale } from './localize.ts'; -import { clampValue } from './ops.ts'; -import { measureText } from './text.ts'; - -/** - * Auto layout utility, allows us to layout things vertically or horizontally with - * proper gaping. - * - * @returns The items, each in the group that positions it. - */ -const flexLayout = ({ - items, - gap, - direction, - sizes = [], -}: { - items: Array; - gap: number; - direction?: 'column' | 'row'; - sizes?: Array; -}): Array => { - if (sizes.some((size) => !Number.isFinite(size))) { - throw new Error('flexLayout: `sizes` must contain only numbers'); - } - - const laidOut = items.filter(Boolean); - - // A lone item is already where it belongs, so it needs no group to move it. - if (laidOut.length === 1) { - return laidOut; - } - - let lastSize = 0; - return laidOut.map((item, i) => { - const offset = lastSize; - lastSize += (sizes[i] ?? 0) + gap; - - // The first item never moves, so it gets no transform to carry. - let transform: string | undefined; - if (offset !== 0 && direction === 'column') { - transform = `translate(0, ${offset})`; - } else if (offset !== 0) { - transform = `translate(${offset}, 0)`; - } - - return el('g', { transform }, item); - }); -}; - -/** - * Creates a node to display the primary programming language of the repository/gist. - * - * @returns Language display SVG object. - */ -const createLanguageNode = (langName: string, langColor: string): MarkupElement => { - if (!isPrefixedHexColor(langColor)) { - throw new Error(`Invalid language color: "${langColor}"`); - } - - return el( - 'g', - { 'data-testid': 'primary-lang' }, - el('circle', { 'data-testid': 'lang-color', cx: 0, cy: -5, r: 6, fill: langColor }), - el('text', { 'data-testid': 'lang-name', class: 'gray', x: 15 }, langName), - ); -}; - -/** - * A circle that glows, drawn twice: WebKit draws a filtered shape at 1x, so the circle stays out - * of its own bloom. - * - * @returns The bloom, tested as `${testId}-glow`, then the crisp circle over it. - */ -const glowingCircle = (testId: string, attributes: Attributes<'circle'>): Array => [ - el('circle', { ...attributes, 'data-testid': `${testId}-glow`, filter: HEAT_PAINT.bloom }), - el('circle', { ...attributes, 'data-testid': testId }), -]; - -/** - * Create a node to indicate progress in percentage along a horizontal line. - * - * @returns Progress node. - */ -const createProgressNode = ({ - x, - y, - width, - color, - progress, - delay, - tip = false, -}: { - x: number; - y: number; - width: number; - /** When omitted, the color must be set via a `.lang-progress` CSS rule. */ - color?: string; - progress: number; - delay: number; - /** A glowing end in the bar's own color, styled by `.lang-tip`; needs `color`. */ - tip?: boolean; -}): MarkupElement => { - if (color !== undefined && !isPrefixedHexColor(color)) { - throw new Error(`Invalid progress color: "${color}"`); - } - if (!Number.isFinite(width)) { - throw new TypeError(`Invalid width: "${width}"`); - } - if (!Number.isFinite(x)) { - throw new TypeError(`Invalid x: "${x}"`); - } - if (!Number.isFinite(y)) { - throw new TypeError(`Invalid y: "${y}"`); - } - if (!Number.isFinite(delay)) { - throw new TypeError(`Invalid delay: "${delay}"`); - } - - const progressPercentage = clampValue(progress, 2, 100); - const barLength = (width * progressPercentage) / 100; - - return el( - 'svg', - { width, x, y }, - el('rect', { - 'data-testid': 'progress-background', - class: 'progress-background', - rx: CARD_STYLE.bar.radius, - ry: CARD_STYLE.bar.radius, - x: 0, - y: 0, - width, - height: CARD_STYLE.bar.height, - }), - el( - 'svg', - { 'data-testid': 'lang-progress', width: `${progressPercentage}%` }, - el('rect', { - width: '100%', - height: CARD_STYLE.bar.height, - fill: color, - rx: CARD_STYLE.bar.radius, - ry: CARD_STYLE.bar.radius, - x: 0, - y: 0, - class: 'lang-progress', - style: `animation-delay: ${delay}ms;`, - }), - ), - // A bar shorter than the tip would read as the tip's size, not its own. - tip && - color !== undefined && - barLength >= CARD_STYLE.bloom.tipRadius * 2 && - glowingCircle('lang-tip', { - class: 'lang-tip', - cx: Math.round((barLength - CARD_STYLE.bloom.tipRadius) * 10) / 10, - cy: CARD_STYLE.bloom.tipRadius - 0.5, - r: CARD_STYLE.bloom.tipRadius, - fill: color, - style: `animation-delay: ${delay + 400}ms;`, - }), - ); -}; - -/** - * Creates an icon with label to display repository/gist stats like forks, stars, etc. - * Draws nothing when the label is not positive. - * - * @returns Icon with label SVG object, or nothing when the label is not positive. - */ -const iconWithLabel = ( - icon: Child, - label: number | string, - testid: string, - iconSize: number, -): Child => { - if (typeof label === 'number' && label <= 0) { - return undefined; - } - - if (!Number.isFinite(iconSize)) { - throw new TypeError(`Invalid iconSize: "${iconSize}"`); - } - - return flexLayout({ - items: [ - el( - 'svg', - { - class: 'icon', - y: -12, - viewBox: '0 0 16 16', - version: '1.1', - width: iconSize, - height: iconSize, - }, - icon, - ), - el('text', { 'data-testid': testid, class: 'gray' }, String(label)), - ], - gap: 20, - }); -}; - -/** How a count is written: `short` abbreviates it (`1.5k`), `long` writes it out. */ -const NUMBER_FORMATS = ['short', 'long'] as const; -type NumberFormat = (typeof NUMBER_FORMATS)[number]; - -/** - * The decimals `number_precision` asks for, clamped to 0–2. - * - * @returns The decimals, or `undefined` when none, or no finite number, was given. - */ -const clampPrecision = (number_precision: number | undefined): number | undefined => { - if (number_precision === undefined || !Number.isFinite(number_precision)) { - return undefined; - } - return clampValue(number_precision, 0, 2); -}; - -/** The options that decide how a card writes a count. */ -interface CountOptions { - number_format?: NumberFormat | undefined; - /** Decimals kept when abbreviating, clamped to 0–2; the abbreviation's own default when absent. */ - number_precision?: number | undefined; - locale?: Locale | undefined; -} - -/** - * How a card writes its counts, decided once from its options. - * - * @returns The count as drawn: abbreviated unless `number_format` is `long`. - */ -const countFormatter = ({ - number_format = 'short', - number_precision, - locale, -}: CountOptions): ((value: number) => string) => { - if (number_format === 'long') { - return String; - } - - const precision = clampPrecision(number_precision); - return (value) => String(kFormatter(value, locale, precision)); -}; - -/** - * Create a labelled stat text item - * (a label and its value, optionally with an icon and link). - * Shared by the stats and repo cards. - * - * The caller must ensure that the passed `link` is properly sanitized! - * - * @returns The stats card text item SVG object. - */ -const createTextNode = ({ - icon, - label, - value, - id, - unitSymbol, - index, - showIcons, - valueAnchorX, - bold, - labelBold = bold, - formatValue, - link, - labelXOffset = 25, -}: { - icon: Child; - label: string; - value: number | string; - id: string; - // `| undefined`: card callers forward possibly-undefined per-stat fields - unitSymbol?: string | undefined; - index: number; - showIcons: boolean; - valueAnchorX?: number | undefined; - bold: boolean; - labelBold?: boolean; - /** Writes a numeric value; a string value is drawn as given. */ - formatValue: (value: number) => string; - link?: string | undefined; - labelXOffset?: number; -}): MarkupElement => { - if (!Number.isFinite(labelXOffset)) { - throw new TypeError(`Invalid labelXOffset: "${labelXOffset}"`); - } - if (!Number.isFinite(index)) { - throw new TypeError(`Invalid index: "${index}"`); - } - if (valueAnchorX !== undefined && !Number.isFinite(valueAnchorX)) { - throw new Error(`Invalid valueAnchorX: "${valueAnchorX}"`); - } - - const drawnValue = typeof value === 'number' ? formatValue(value) : value; - - const staggerDelay = 120 + index * 40; - const boldClass = bold ? 'bold' : 'not_bold'; - const labelBoldClass = labelBold ? 'bold' : 'not_bold'; - const valueX = (showIcons ? 140 : 120) + (bold ? 5 : 0); - const unit = unitSymbol ? ` ${unitSymbol}` : ''; - - const content = [ - showIcons && - el( - 'svg', - { - 'data-testid': 'icon', - class: 'icon', - viewBox: '0 0 16 16', - version: '1.1', - width: 16, - height: 16, - }, - icon, - ), - el( - 'text', - { class: `stat ${labelBoldClass}`, x: showIcons ? labelXOffset : undefined, y: 12.5 }, - `${label}:`, - ), - el( - 'text', - { - class: `stat ${boldClass}`, - x: valueAnchorX ?? valueX, - 'text-anchor': valueAnchorX === undefined ? undefined : 'end', - y: 12.5, - 'data-testid': id, - }, - `${drawnValue}${unit}`, - ), - ]; - - return el( - 'g', - { - class: 'stagger', - style: `animation-delay: ${staggerDelay}ms`, - transform: `translate(${CARD_STYLE.padding.x}, 0)`, - }, - link ? el('a', { href: link }, content) : content, - ); -}; - -const ERROR_CARD_LENGTH = 576.5; - -/** - * Renders error message on the card. - * - * @returns The SVG markup. - */ -const renderError = ({ - message, - secondaryMessage = '', - renderOptions = {}, -}: { - message: string; - secondaryMessage?: string | undefined; - // `| undefined`: api handlers forward absent query params - renderOptions?: ThemeParams & { reportable?: boolean | undefined }; -}): string => { - const { reportable = true, theme } = renderOptions; - - const { lightColors, darkColors } = getLightDarkColors({ theme }); - const { titleColor, textColor, bgColor, borderColor } = lightColors; - - /* - * Its own line, at the small size, because it no longer fits beside the title: the inherited URL - * ran to 561px inside a 576.5px card and this one runs past it. Measured in a canvas at the - * title's own font, so re-measure before putting it back on one line. - */ - const report = reportable ? 'File an issue at https://tinyurl.com/github-stats-forge-bug' : ''; - - return renderMarkup( - el( - 'svg', - { - width: ERROR_CARD_LENGTH, - height: 120, - viewBox: `0 0 ${ERROR_CARD_LENGTH} 120`, - fill: bgColor, - xmlns: 'http://www.w3.org/2000/svg', - role: 'img', - 'aria-labelledby': 'title-id desc-id', - }, - el('title', { id: 'title-id' }, message), - el('desc', { id: 'desc-id' }, secondaryMessage), - style( - rule('.text', { font: "600 16px 'Segoe UI', Ubuntu, Sans-Serif", fill: titleColor }), - rule('.small', { font: "600 12px 'Segoe UI', Ubuntu, Sans-Serif", fill: textColor }), - rule('.gray', { fill: '#858585' }), - darkColors && - atRule( - '@media (prefers-color-scheme: dark)', - rule('.text', { fill: darkColors.titleColor }), - rule('.small', { fill: darkColors.textColor }), - // again, or the dark `.small` above would win over it - rule('.gray', { fill: '#858585' }), - rule('.error-bg', { - fill: darkColors.bgColor, - stroke: darkColors.borderColor, - }), - ), - ), - el('rect', { - x: 0.5, - y: 0.5, - width: ERROR_CARD_LENGTH - 1, - height: '99%', - rx: 8, - class: 'error-bg', - fill: bgColor, - stroke: borderColor, - }), - el('text', { x: CARD_STYLE.padding.x, y: 45, class: 'text' }, 'Something went wrong!'), - el( - 'text', - { 'data-testid': 'message', x: CARD_STYLE.padding.x, y: 55, class: 'text small' }, - el('tspan', { x: CARD_STYLE.padding.x, dy: 18 }, message), - el('tspan', { x: CARD_STYLE.padding.x, dy: 18, class: 'gray' }, secondaryMessage), - ), - report !== '' && - el( - 'text', - { 'data-testid': 'report', x: CARD_STYLE.padding.x, y: 109, class: 'text small gray' }, - report, - ), - ), - ); -}; - -/** @returns How wide a compact list column draws: its widest label, past the row's dot. */ -const compactColumnWidth = (labels: Array): number => - Math.max( - 0, - ...labels.map( - (label) => CARD_STYLE.compactList.labelX + measureText(label, CARD_STYLE.fontSize.micro), - ), - ); - -export { - clampPrecision, - compactColumnWidth, - countFormatter, - NUMBER_FORMATS, - renderError, - createLanguageNode, - createProgressNode, - createTextNode, - glowingCircle, - iconWithLabel, - flexLayout, -}; -export type { NumberFormat }; diff --git a/packages/core/src/common/retryer.ts b/packages/core/src/common/retryer.ts index c6eac9e7..c6b3b5f1 100644 --- a/packages/core/src/common/retryer.ts +++ b/packages/core/src/common/retryer.ts @@ -1,6 +1,8 @@ +import type { HttpResponse } from '@stats-forge/card-kit'; + import type { CardConfig } from './config.ts'; import { CardError } from './error.ts'; -import type { FetcherContext, HttpResponse } from './http.ts'; +import type { FetcherContext } from './http.ts'; /** * Error-detection fields the retryer inspects to detect rate-limiting and credential failures. diff --git a/packages/core/src/common/text.ts b/packages/core/src/common/text.ts deleted file mode 100644 index 8f1c26ac..00000000 --- a/packages/core/src/common/text.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** Advance of each ASCII code point, as a fraction of the font size. */ -// prettier-ignore -const CHAR_WIDTHS = [ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0.2733333110809326, 0.28499999046325686, - 0.39166667461395266, 0.5900000095367431, 0.5383333206176758, - 0.8183333396911621, 0.8, 0.22999999523162842, 0.30166666507720946, - 0.30166666507720946, 0.41666665077209475, 0.6833333492279052, - 0.21666667461395264, 0.4, 0.21666667461395264, 0.3900000095367432, - 0.5383333206176758, 0.5383333206176758, 0.5383333206176758, - 0.5383333206176758, 0.5383333206176758, 0.5383333206176758, - 0.5383333206176758, 0.5383333206176758, 0.5383333206176758, - 0.5383333206176758, 0.21666667461395264, 0.21666667461395264, - 0.6833333492279052, 0.6833333492279052, 0.6833333492279052, - 0.4483333110809326, 0.9550000190734863, 0.6449999809265137, - 0.5733333110809327, 0.6183333396911621, 0.7016666889190674, - 0.5066666603088379, 0.48833332061767576, 0.6866666793823242, - 0.7099999904632568, 0.26666667461395266, 0.35666666030883787, - 0.5800000190734863, 0.4699999809265137, 0.8983333587646485, - 0.7483333110809326, 0.753333330154419, 0.5599999904632569, - 0.753333330154419, 0.5983333110809326, 0.5316666603088379, - 0.5233333110809326, 0.6866666793823242, 0.621666669845581, - 0.9333333015441895, 0.5900000095367431, 0.553333330154419, - 0.5699999809265137, 0.30166666507720946, 0.37833333015441895, - 0.30166666507720946, 0.6833333492279052, 0.41500000953674315, - 0.26833333969116213, 0.5083333492279053, 0.5883333206176757, - 0.4616666793823242, 0.5883333206176757, 0.5233333110809326, - 0.3133333444595337, 0.5883333206176757, 0.5666666507720948, - 0.24166667461395264, 0.24166667461395264, 0.49666666984558105, - 0.24166667461395264, 0.8616666793823242, 0.5666666507720948, - 0.5866666793823242, 0.5883333206176757, 0.5883333206176757, - 0.3483333349227905, 0.425, 0.33833334445953367, 0.5666666507720948, - 0.4783333301544189, 0.7233333110809326, 0.45833334922790525, - 0.4833333492279053, 0.45166668891906736, 0.30166666507720946, - 0.24000000953674316, 0.30166666507720946, 0.6833333492279052, -]; - -/** Advance of a code point the table does not cover. */ -const AVERAGE_CHAR_WIDTH = 0.5131403493881227; -/** - * Full-width glyphs: U+3000–U+9FFF (CJK symbols and punctuation, Hiragana, Katakana, - * CJK Unified Ideographs incl. Extension A) plus U+FF00–U+FFEF (halfwidth and fullwidth forms). - */ -const FULL_WIDTH_PATTERN = /[\u3000-\u9FFF\uFF00-\uFFEF]/; - -/** - * Retrieve text length based on Segoe UI font. - * - * @see https://stackoverflow.com/a/48172630/10629172 - * - * @returns Text length. - */ -const measureText = (str: string, fontSize = 10): number => - [...str] - .map((char) => { - if (FULL_WIDTH_PATTERN.test(char)) { - return 1; - } - return CHAR_WIDTHS[char.codePointAt(0) ?? 0] ?? AVERAGE_CHAR_WIDTH; - }) - .reduce((total, width) => total + width, 0) * fontSize; - -/** - * Split text into the lines it would wrap to when laid out greedily at the - * given font size inside a box of width `maxWidth`. Uses `measureText` so the - * estimate reflects actual font metrics rather than a fixed character count. - * - * @returns Estimated wrapped lines. - */ -const splitWrappedText = (text: string, fontSize: number, maxWidth: number): Array => { - if (!text) { - return []; - } - - // Tokenize the text into atoms representing line-break opportunities: - // - ASCII whitespace runs (collapsed/dropped at line edges per CSS rules); - // - non-ASCII whitespaces - // - a single CJK codepoint (browsers break between any two CJK chars, - // punctuation or not, so each one is its own atom and ~1 em wide); - // - a run of non-whitespace non-CJK characters (a "word" that can only - // break at its boundaries, like Latin script). - // Korean Hangul (U+AC00–U+D7AF) is intentionally NOT in the CJK range - // because Korean wraps at word boundaries by default in HTML. - // ASCII whitespace is collapsed to a single space per CSS `white-space: normal;` - const normalizedText = text.replaceAll(/[\t\n\r ]+/g, ' '); - const tokens = normalizedText.match( - /\s|[\u3000-\u9FFF\uFF00-\uFFEF]|[^\s\u3000-\u9FFF\uFF00-\uFFEF]+/g, - ); - if (!tokens) { - return []; - } - - const takeFittingSegment = ( - token: string, - availableWidth: number, - ): { segment: string; width: number } => { - const characters = [...token]; - let segment = ''; - let width = 0; - - for (const character of characters) { - const characterWidth = measureText(character, fontSize); - if (segment && width + characterWidth > availableWidth) { - break; - } - segment += character; - width += characterWidth; - } - - return { - segment, - width, - }; - }; - - const lines = ['']; - let currentWidth = 0; - - for (const token of tokens) { - if (token === ' ') { - // Whitespace at the start of a line is dropped by browsers. - if (currentWidth === 0) { - continue; - } - lines[lines.length - 1] = (lines.at(-1) ?? '') + token; - currentWidth += measureText(token, fontSize); - continue; - } - - let remaining = token; - - while (remaining) { - const w = measureText(remaining, fontSize); - if (currentWidth + w <= maxWidth) { - lines[lines.length - 1] = (lines.at(-1) ?? '') + remaining; - currentWidth += w; - break; - } - - if (currentWidth > 0) { - lines.push(''); - currentWidth = 0; - continue; - } - - // An atom wider than the box wraps mid-glyph (overflow-wrap: anywhere). - const { segment, width } = takeFittingSegment(remaining, maxWidth); - lines[lines.length - 1] = (lines.at(-1) ?? '') + segment; - currentWidth = width; - remaining = remaining.slice(segment.length); - } - } - - return lines.map((line) => line.replace(/[\t\n\r ]+$/, '')); -}; - -/** - * Split text over multiple lines based on the card width. - * - * @returns Array of lines. - */ -const wrapTextMultiline = ( - text: string, - width: number, - fontSize: number, - maxLines = 3, -): Array => { - const wrapped = splitWrappedText(text, fontSize, width); - const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines - - // Add "..." to the last line if the text exceeds maxLines - if (wrapped.length > maxLines) { - const lastIndex = maxLines - 1; - const lastLine = lines[lastIndex]; - if (lastLine !== undefined) { - lines[lastIndex] = `${lastLine}...`; - } - } - - // Remove empty lines if text fits in less than maxLines lines - const multiLineText = lines.filter(Boolean); - return multiLineText; -}; - -export { measureText, splitWrappedText, wrapTextMultiline }; diff --git a/packages/core/src/fetchers/contributed-to/index.ts b/packages/core/src/fetchers/contributed-to/index.ts index 89b49034..5d4bd484 100644 --- a/packages/core/src/fetchers/contributed-to/index.ts +++ b/packages/core/src/fetchers/contributed-to/index.ts @@ -1,9 +1,11 @@ +import { clampValue } from '@stats-forge/card-kit'; + import type { CardConfig } from '../../common/config.ts'; import type { GitHubDateRange } from '../../common/date.ts'; import { toContributionRanges, toRange } from '../../common/date.ts'; -import { CardError } from '../../common/error.ts'; +import { CardError, fromGraphQL } from '../../common/error.ts'; import { createGraphQLFetcher, UNREADABLE_REPLY } from '../../common/http.ts'; -import { clampValue, excludes } from '../../common/ops.ts'; +import { excludes } from '../../common/ops.ts'; import { retryer } from '../../common/retryer.ts'; import { UserContributionYearsDocument } from '../../graphql/generated/contributed-to.ts'; import { @@ -127,12 +129,7 @@ const fetchReposContributedTo = async ( ); continue; } - throw CardError.fromGraphQL( - res.data.errors, - res.statusText, - REPOS_CONTRIBUTED_TO, - config.logger, - ); + throw fromGraphQL(res.data.errors, res.statusText, REPOS_CONTRIBUTED_TO, config.logger); } const { user } = res.data.data; if (!user) { @@ -258,12 +255,7 @@ const fetchContributedTo = async ( const res = await retryer(yearsFetcher, { login: username }, config); if (res.data.errors) { - throw CardError.fromGraphQL( - res.data.errors, - res.statusText, - 'contribution years', - config.logger, - ); + throw fromGraphQL(res.data.errors, res.statusText, 'contribution years', config.logger); } const { user } = res.data.data; diff --git a/packages/core/src/fetchers/index.ts b/packages/core/src/fetchers/index.ts index 15fd1720..8fe376a4 100644 --- a/packages/core/src/fetchers/index.ts +++ b/packages/core/src/fetchers/index.ts @@ -23,7 +23,7 @@ export type { WakaTimeData, WakaTimeLang } from './wakatime/types.ts'; // consumer back to the package root for it. export { CardConfig } from '../common/config.ts'; export type { CardConfigInit, Logger, PersonalAccessToken } from '../common/config.ts'; -export type { FetchLike } from '../common/http.ts'; export { retryer } from '../common/retryer.ts'; export { CardError } from '../common/error.ts'; export type { ErrorCode, FailureReason } from '../common/error.ts'; +export type { FetchLike } from '@stats-forge/card-kit'; diff --git a/packages/core/src/fetchers/org-activity/index.ts b/packages/core/src/fetchers/org-activity/index.ts index 9eb3c5eb..6e0538ce 100644 --- a/packages/core/src/fetchers/org-activity/index.ts +++ b/packages/core/src/fetchers/org-activity/index.ts @@ -1,11 +1,13 @@ +import { clampValue, httpRequest } from '@stats-forge/card-kit'; +import type { HttpResponse } from '@stats-forge/card-kit'; + import type { CardConfig } from '../../common/config.ts'; import { GITHUB_USERNAME_PATTERN } from '../../common/constants.ts'; import type { GitHubDateRange } from '../../common/date.ts'; import { toSearchDate } from '../../common/date.ts'; -import { CardError } from '../../common/error.ts'; -import { createGraphQLFetcher, httpRequest } from '../../common/http.ts'; -import type { FetcherContext, HttpResponse } from '../../common/http.ts'; -import { clampValue } from '../../common/ops.ts'; +import { CardError, fromGraphQL } from '../../common/error.ts'; +import { createGraphQLFetcher } from '../../common/http.ts'; +import type { FetcherContext } from '../../common/http.ts'; import { retryer } from '../../common/retryer.ts'; import { GetOrganizationActivityDocument } from '../../graphql/generated/org-activity.ts'; import type { GetOrganizationActivityQuery } from '../../graphql/generated/org-activity.ts'; @@ -159,17 +161,12 @@ const fetchOrgActivity = async ( ); if (res.data.errors) { - // A login that is a user, or nothing at all, answers NOT_FOUND — which `CardError.fromGraphQL` + // A login that is a user, or nothing at all, answers NOT_FOUND — which `fromGraphQL` // words for a user query, so this endpoint's own wording is thrown instead. if (res.data.errors[0]?.type === 'NOT_FOUND') { throw CardError.because('org_not_found'); } - throw CardError.fromGraphQL( - res.data.errors, - res.statusText, - 'organization activity', - config.logger, - ); + throw fromGraphQL(res.data.errors, res.statusText, 'organization activity', config.logger); } const { data } = res.data; diff --git a/packages/core/src/fetchers/org-calendar/index.ts b/packages/core/src/fetchers/org-calendar/index.ts index de5987c8..b01781d2 100644 --- a/packages/core/src/fetchers/org-calendar/index.ts +++ b/packages/core/src/fetchers/org-calendar/index.ts @@ -1,10 +1,11 @@ +import { clampValue } from '@stats-forge/card-kit'; + import type { CardConfig } from '../../common/config.ts'; import { GITHUB_USERNAME_PATTERN, ORG_CALENDAR_MAX_WEEKS } from '../../common/constants.ts'; import type { ORG_CALENDAR_COUNTS } from '../../common/constants.ts'; import { toSearchDate } from '../../common/date.ts'; -import { CardError } from '../../common/error.ts'; +import { CardError, fromGraphQL } from '../../common/error.ts'; import { createGraphQLFetcher } from '../../common/http.ts'; -import { clampValue } from '../../common/ops.ts'; import { retryer } from '../../common/retryer.ts'; import { buildOrgCalendarDocument } from '../../graphql/buildOrgCalendarDocument.ts'; import type { OrgCalendarQueryVariables } from '../../graphql/buildOrgCalendarDocument.ts'; @@ -90,17 +91,12 @@ const fetchOrgCalendar = async ( ); if (res.data.errors) { - // A login that is a user, or nothing at all, answers NOT_FOUND — which `CardError.fromGraphQL` + // A login that is a user, or nothing at all, answers NOT_FOUND — which `fromGraphQL` // words for a user query, so this endpoint's own wording is thrown instead. if (res.data.errors[0]?.type === 'NOT_FOUND') { throw CardError.because('org_not_found'); } - throw CardError.fromGraphQL( - res.data.errors, - res.statusText, - 'organization calendar', - config.logger, - ); + throw fromGraphQL(res.data.errors, res.statusText, 'organization calendar', config.logger); } const { data } = res.data; diff --git a/packages/core/src/fetchers/organization/index.ts b/packages/core/src/fetchers/organization/index.ts index 7e11d84c..23991ff7 100644 --- a/packages/core/src/fetchers/organization/index.ts +++ b/packages/core/src/fetchers/organization/index.ts @@ -1,6 +1,6 @@ import type { CardConfig } from '../../common/config.ts'; import { GITHUB_USERNAME_PATTERN } from '../../common/constants.ts'; -import { CardError } from '../../common/error.ts'; +import { CardError, fromGraphQL } from '../../common/error.ts'; import { createGraphQLFetcher } from '../../common/http.ts'; import type { GraphQLResponse } from '../../common/http.ts'; import { retryer } from '../../common/retryer.ts'; @@ -116,18 +116,13 @@ const fetchOrganization = async ( config, ); if (res.data.errors) { - // A login that is a user, or nothing at all, answers NOT_FOUND — which `CardError.fromGraphQL` + // A login that is a user, or nothing at all, answers NOT_FOUND — which `fromGraphQL` // words for a user query, so this endpoint's own wording is thrown instead. if (res.data.errors[0]?.type === 'NOT_FOUND') { throw CardError.because('org_not_found'); } if (!onlyMembersForbidden(res.data.errors)) { - throw CardError.fromGraphQL( - res.data.errors, - res.statusText, - 'organization data', - config.logger, - ); + throw fromGraphQL(res.data.errors, res.statusText, 'organization data', config.logger); } if (require_members) { throw CardError.because('org_members_forbidden'); diff --git a/packages/core/src/fetchers/repo/index.ts b/packages/core/src/fetchers/repo/index.ts index 87067525..ea66e6d5 100644 --- a/packages/core/src/fetchers/repo/index.ts +++ b/packages/core/src/fetchers/repo/index.ts @@ -1,5 +1,5 @@ import type { CardConfig } from '../../common/config.ts'; -import { CardError } from '../../common/error.ts'; +import { CardError, fromGraphQL } from '../../common/error.ts'; import { createGraphQLFetcher } from '../../common/http.ts'; import { retryer } from '../../common/retryer.ts'; import { GetRepoDocument } from '../../graphql/generated/repo.ts'; @@ -80,7 +80,7 @@ const fetchRepo = async ( // the half naming no such login always fails NOT_FOUND const failures = res.data.errors?.filter(({ type }) => type !== 'NOT_FOUND') ?? []; if (failures.length > 0) { - throw CardError.fromGraphQL(failures, res.statusText, 'repository', config.logger); + throw fromGraphQL(failures, res.statusText, 'repository', config.logger); } const { data } = res.data; // a login is a user or an organization, never both diff --git a/packages/core/src/fetchers/stats/index.ts b/packages/core/src/fetchers/stats/index.ts index a1fd7c75..69acacbd 100644 --- a/packages/core/src/fetchers/stats/index.ts +++ b/packages/core/src/fetchers/stats/index.ts @@ -1,3 +1,5 @@ +import { httpRequest } from '@stats-forge/card-kit'; + import { calculateRank } from '../../calculateRank.ts'; import type { CardConfig } from '../../common/config.ts'; import { affiliationsOrOwner, GITHUB_USERNAME_PATTERN } from '../../common/constants.ts'; @@ -9,8 +11,8 @@ import { toRange, toYearRanges, } from '../../common/date.ts'; -import { CardError } from '../../common/error.ts'; -import { createGraphQLFetcher, httpRequest } from '../../common/http.ts'; +import { CardError, fromGraphQL } from '../../common/error.ts'; +import { createGraphQLFetcher } from '../../common/http.ts'; import type { FetcherContext, GraphQLResponse } from '../../common/http.ts'; import { buildSearchFilter, excludes } from '../../common/ops.ts'; import { retryer } from '../../common/retryer.ts'; @@ -308,7 +310,7 @@ const sumOverRanges = async ( const contribRes = await retryer(contributionsFetcher, { login: username }, config); if (contribRes.data.errors) { - throw CardError.fromGraphQL( + throw fromGraphQL( contribRes.data.errors, contribRes.statusText, 'contributions data', @@ -421,7 +423,7 @@ const fetchStats = async ( ); if (res.data.errors) { - throw CardError.fromGraphQL(res.data.errors, res.statusText, 'stats data', config.logger); + throw fromGraphQL(res.data.errors, res.statusText, 'stats data', config.logger); } const { user } = res.data.data; diff --git a/packages/core/src/fetchers/top-languages/index.ts b/packages/core/src/fetchers/top-languages/index.ts index 95f9b6fd..2b1f0c70 100644 --- a/packages/core/src/fetchers/top-languages/index.ts +++ b/packages/core/src/fetchers/top-languages/index.ts @@ -1,6 +1,6 @@ import type { CardConfig } from '../../common/config.ts'; import { affiliationsOrOwner } from '../../common/constants.ts'; -import { CardError } from '../../common/error.ts'; +import { CardError, fromGraphQL } from '../../common/error.ts'; import { createGraphQLFetcher } from '../../common/http.ts'; import { excludes } from '../../common/ops.ts'; import { retryer } from '../../common/retryer.ts'; @@ -57,7 +57,7 @@ const fetchTopLanguages = async ( ); if (res.data.errors) { - throw CardError.fromGraphQL(res.data.errors, res.statusText, 'language data', config.logger); + throw fromGraphQL(res.data.errors, res.statusText, 'language data', config.logger); } const isExcluded = excludes([...exclude_repo, ...config.excludeRepositories]); diff --git a/packages/core/src/fetchers/top-languages/jupyter.ts b/packages/core/src/fetchers/top-languages/jupyter.ts index d1949296..041afb4c 100644 --- a/packages/core/src/fetchers/top-languages/jupyter.ts +++ b/packages/core/src/fetchers/top-languages/jupyter.ts @@ -3,14 +3,15 @@ * in, since GitHub counts a whole `.ipynb`, saved outputs included, as Jupyter Notebook. */ +import { httpRequest } from '@stats-forge/card-kit'; +import type { Logger } from '@stats-forge/card-kit'; + import type { CardConfig } from '../../common/config.ts'; import { GITHUB_USERNAME_PATTERN } from '../../common/constants.ts'; import { CardError } from '../../common/error.ts'; -import { httpRequest } from '../../common/http.ts'; import type { FetcherContext } from '../../common/http.ts'; import { linguistLanguage } from '../../common/languageAliases.ts'; import { getLanguageColor } from '../../common/languageColors.ts'; -import type { Logger } from '../../common/log.ts'; import { retryer } from '../../common/retryer.ts'; import type { FetcherResponse } from '../../common/retryer.ts'; import type { TopLanguageFragment } from '../../graphql/generated/top-languages.ts'; diff --git a/packages/core/src/fetchers/wakatime/index.ts b/packages/core/src/fetchers/wakatime/index.ts index 23c2f43a..3ced4774 100644 --- a/packages/core/src/fetchers/wakatime/index.ts +++ b/packages/core/src/fetchers/wakatime/index.ts @@ -1,3 +1,5 @@ +import { httpRequest } from '@stats-forge/card-kit'; + import type { CardConfig } from '../../common/config.ts'; import { HOSTNAME_PATTERN, @@ -5,7 +7,6 @@ import { WAKATIME_USERNAME_PATTERN, } from '../../common/constants.ts'; import { CardError } from '../../common/error.ts'; -import { httpRequest } from '../../common/http.ts'; import type { WakaTimeData } from './types.ts'; diff --git a/packages/core/src/markup/css.ts b/packages/core/src/markup/css.ts deleted file mode 100644 index 17186e4e..00000000 --- a/packages/core/src/markup/css.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { Sink } from './serialize.ts'; -import { INDENT, indentAt } from './serialize.ts'; - -/** Every CSS property a card declares, closed so a typo is a compile error, not a dropped rule. */ -type CssProperty = - | '-webkit-box-orient' - | '-webkit-line-clamp' - | 'animation' - | 'animation-delay' - | 'animation-duration' - | 'color' - | 'display' - | 'fill' - | 'fill-opacity' - | 'font' - | 'font-size' - | 'font-variant-numeric' - | 'font-weight' - | 'line-clamp' - | 'line-height' - | 'margin' - | 'opacity' - | 'overflow' - | 'overflow-wrap' - | 'padding-bottom' - | 'stop-color' - | 'stop-opacity' - | 'stroke' - | 'stroke-dasharray' - | 'stroke-dashoffset' - | 'stroke-linecap' - | 'stroke-width' - | 'text-overflow' - | 'transform' - | 'transform-box' - | 'transform-origin' - | 'width' - | 'word-break'; - -/** A declaration whose value is `undefined` is left out, so a caller can inline a condition. */ -type Declarations = Partial>; - -type CssNode = - | { readonly kind: 'rule'; readonly selector: string; readonly declarations: Declarations } - | { readonly kind: 'at'; readonly prelude: string; readonly children: Array } - | { readonly kind: 'comment'; readonly text: string }; - -/** Anything accepted where CSS is expected; falsy entries drop out, arrays flatten. */ -type CssChild = CssNode | false | null | undefined | Array; - -/** - * Selectors stay free-form: they compose (`.badge rect`), and a keyframe stop is one too (`from`). - */ -const rule = (selector: string, declarations: Declarations): CssNode => ({ - kind: 'rule', - selector, - declarations, -}); - -const atRule = (prelude: string, ...children: Array): CssNode => ({ - kind: 'at', - prelude, - children, -}); - -const cssComment = (text: string): CssNode => ({ kind: 'comment', text }); - -/** Drops the rules that carry nothing and flattens the arrays. */ -const flatten = (children: Array, into: Array = []): Array => { - for (const child of children) { - if (!child) { - continue; - } - if (Array.isArray(child)) { - flatten(child, into); - } else { - into.push(child); - } - } - return into; -}; - -/** - * Serializes a stylesheet, dropping every rule that would have come out empty. - * - * A block is written optimistically and rewound if its body turns out empty, - * so the tree is walked once however deeply the at-rules nest. - */ -const writeCss = (children: Array, depth: number, sink: Sink): void => { - const indent = indentAt(depth); - - for (const node of flatten(children)) { - if (node.kind === 'comment') { - sink.out += `${indent}/* ${node.text} */\n`; - continue; - } - - const mark = sink.out.length; - - if (node.kind === 'at') { - sink.out += `${indent}${node.prelude} {\n`; - const bodyStart = sink.out.length; - writeCss(node.children, depth + 1, sink); - sink.out = - sink.out.length === bodyStart ? sink.out.slice(0, mark) : `${sink.out}${indent}}\n`; - continue; - } - - sink.out += `${indent}${node.selector} {\n`; - const bodyStart = sink.out.length; - for (const [property, value] of Object.entries(node.declarations)) { - if (value !== undefined) { - sink.out += `${indent}${INDENT}${property}: ${String(value)};\n`; - } - } - sink.out = sink.out.length === bodyStart ? sink.out.slice(0, mark) : `${sink.out}${indent}}\n`; - } -}; - -/** - * Two builds of one stylesheet, as an override of the first by the second: the declarations the - * second changes, in the rules and at-rules they sit in. A node that does not line up is kept whole. - * - * @returns What `next` sets differently from `base`. - */ -const changedBy = (base: Array, next: Array): Array => { - const before = flatten(base); - return flatten(next).flatMap((node, index): Array => { - const was = before[index]; - if (node.kind === 'rule' && was?.kind === 'rule' && was.selector === node.selector) { - const previous: Readonly> = was.declarations; - const changed = Object.entries(node.declarations).filter( - ([property, value]) => value !== previous[property], - ); - return [rule(node.selector, Object.fromEntries(changed))]; - } - if (node.kind === 'at' && was?.kind === 'at' && was.prelude === node.prelude) { - // a later @keyframes replaces the earlier one whole, so it cannot be trimmed - if (node.prelude.startsWith('@keyframes')) { - if (JSON.stringify(flatten(was.children)) === JSON.stringify(flatten(node.children))) { - return []; - } - return [node]; - } - return [atRule(node.prelude, changedBy(was.children, node.children))]; - } - if (node.kind === 'comment') { - return []; - } - return [node]; - }); -}; - -export { atRule, changedBy, cssComment, rule, writeCss }; -export type { CssChild }; diff --git a/packages/core/src/markup/element.ts b/packages/core/src/markup/element.ts deleted file mode 100644 index 642f002f..00000000 --- a/packages/core/src/markup/element.ts +++ /dev/null @@ -1,253 +0,0 @@ -import type { CssChild } from './css.ts'; -import { writeCss } from './css.ts'; -import { encodeHTML } from './html.ts'; -import type { Sink } from './serialize.ts'; -import { indentAt } from './serialize.ts'; - -/** Valid on any element; `data-*` is global. */ -type CoreAttribute = 'class' | 'data-size' | 'data-testid' | 'data-view-component' | 'id' | 'style'; - -/** SVG 2 allows `role` and `aria-*` only on what renders: containers, shapes and text. */ -type AriaAttribute = 'aria-hidden' | 'aria-labelledby' | 'role'; - -/** Fill and stroke paint shapes and text, and inherit, so a container may set them too. */ -type PaintAttribute = - | 'fill' - | 'fill-opacity' - | 'fill-rule' - | 'stroke' - | 'stroke-dasharray' - | 'stroke-dashoffset' - | 'stroke-linecap' - | 'stroke-opacity' - | 'stroke-width'; - -/** Inherited text layout, so a container may set it for the text inside. */ -type TextLayoutAttribute = 'dominant-baseline' | 'text-anchor'; - -/** What applies to graphics elements and containers alone: not to a `tspan`, nor a gradient. */ -type GraphicsAttribute = 'filter' | 'mask' | 'transform'; - -type ContainerAttribute = - | CoreAttribute - | AriaAttribute - | PaintAttribute - | TextLayoutAttribute - | GraphicsAttribute; - -type ShapeAttribute = CoreAttribute | AriaAttribute | PaintAttribute | GraphicsAttribute; - -/** `alignment-baseline` is not inherited, so only the text elements take it. */ -type TextAttribute = - | CoreAttribute - | AriaAttribute - | PaintAttribute - | TextLayoutAttribute - | 'alignment-baseline' - | 'dy' - | 'x' - | 'y'; - -type Region = 'height' | 'width' | 'x' | 'y'; - -/** - * Every element a card draws, and the attributes each may carry, closed on both counts: - * `el('circel', …)` and a `stroke-width` on a `stop` are compile errors, not dropped output. - * `style` is absent on purpose: it comes from `style()`, which takes rules rather than children. - */ -interface ElementAttributes { - a: ContainerAttribute | 'href'; - circle: ShapeAttribute | 'cx' | 'cy' | 'r'; - defs: CoreAttribute; - desc: CoreAttribute; - feComponentTransfer: CoreAttribute | 'in' | 'result'; - feFuncA: CoreAttribute | 'slope' | 'type'; - feGaussianBlur: CoreAttribute | Region | 'in' | 'result' | 'stdDeviation'; - filter: CoreAttribute | Region; - g: ContainerAttribute; - linearGradient: CoreAttribute | 'gradientTransform' | 'gradientUnits' | 'x1' | 'x2' | 'y1' | 'y2'; - mask: CoreAttribute | Region; - path: ShapeAttribute | 'd'; - radialGradient: CoreAttribute | 'cx' | 'cy' | 'gradientTransform' | 'gradientUnits' | 'r'; - rect: ShapeAttribute | Region | 'rx' | 'ry'; - stop: CoreAttribute | 'offset' | 'stop-color' | 'stop-opacity'; - svg: ContainerAttribute | Region | 'version' | 'viewBox' | 'xmlns'; - text: TextAttribute | GraphicsAttribute; - title: CoreAttribute; - tspan: TextAttribute; -} - -type Tag = keyof ElementAttributes; - -/** An attribute whose value is `undefined` is not written, so a caller can inline a condition. */ -type Attributes = Partial>; - -/** Elements whose character data is part of the rendering, so they stay on one line. */ -const INLINE_ELEMENTS: ReadonlySet = new Set(['text', 'tspan', 'title', 'desc']); - -/** Defaults to any element, which is what a parent holds and the serializer walks. */ -interface MarkupElement { - readonly tag: T; - readonly attributes: Attributes; - readonly children: Array; -} - -interface StyleElement { - readonly tag: 'style'; - readonly rules: Array; -} - -/** Anything accepted as content; falsy entries drop out, arrays flatten, strings are escaped. */ -type Child = - | MarkupElement - | StyleElement - | string - | number - | false - | null - | undefined - | Array; - -/** What is left of a child list once the empty entries and the nesting are gone. */ -type FlatChild = MarkupElement | StyleElement | string | number; - -/** - * Builds one element. Attribute values and text children are encoded on the way out. - */ -const el = ( - tag: T, - attributes: Attributes = {}, - ...children: Array -): MarkupElement => ({ - tag, - attributes, - children, -}); - -/** - * Builds the `\n`; - return; - } - - const children = flatten(node.children); - if (children.length === 0) { - sink.out += `${indent}${openTag(node, true)}\n`; - return; - } - - if (INLINE_ELEMENTS.has(node.tag)) { - let inner = ''; - for (const child of children) { - inner += writeInline(child); - } - sink.out += - inner === '' - ? `${indent}${openTag(node, true)}\n` - : `${indent}${openTag(node, false)}${inner}\n`; - return; - } - - sink.out += `${indent}${openTag(node, false)}\n`; - const bodyStart = sink.out.length; - for (const child of children) { - write(child, depth + 1, sink); - } - sink.out = - sink.out.length === bodyStart - ? `${sink.out.slice(0, mark)}${indent}${openTag(node, true)}\n` - : `${sink.out}${indent}\n`; -}; - -/** - * Serializes a finished card: one element per line, two spaces per nesting level. - * - * @returns The rendered SVG. - */ -const renderMarkup = (root: MarkupElement): string => { - const sink: Sink = { out: '' }; - write(root, 0, sink); - return sink.out; -}; - -export { el, renderMarkup, style }; -export type { Attributes, Child, MarkupElement }; diff --git a/packages/core/src/markup/html.ts b/packages/core/src/markup/html.ts deleted file mode 100644 index 753e2ebe..00000000 --- a/packages/core/src/markup/html.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * What has to be encoded: the markup characters, everything above plain ASCII, and a backspace. - * One source for both regexes, so the check and the replacement cannot disagree. - */ -const ENCODED = String.raw`[<>&"'\u00A0-\u9999\u0008]`; - -/** Non-global, so `.test` stays stateless: a global regex would carry `lastIndex`. */ -const NEEDS_ENCODING = new RegExp(ENCODED); -const TO_ENCODE = new RegExp(ENCODED, 'g'); - -const BACKSPACE = '\u0008'; - -/** - * Encodes a string for use as markup, one numeric character reference each. - * - * The serializer is now the only place text becomes markup, so nothing arrives pre-escaped: - * a literal `&` is encoded like any other `&` rather than passed through as a reference. - * - * @see https://stackoverflow.com/a/48073476/10629172 - * - * @returns The text, safe to place in an attribute or as character data. - */ -const encodeHTML = (str: string): string => { - // Most strings a card writes are plain ASCII, so they leave without a rewrite. - if (!NEEDS_ENCODING.test(str)) { - return str; - } - - return str.replaceAll(TO_ENCODE, (char) => - char === BACKSPACE ? '' : `&#${char.codePointAt(0) ?? 0};`, - ); -}; - -export { encodeHTML }; diff --git a/packages/core/src/markup/index.ts b/packages/core/src/markup/index.ts deleted file mode 100644 index eb214086..00000000 --- a/packages/core/src/markup/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * The markup layer: a card is built as a tree of elements and rules, then serialized once. - * It depends on nothing else in the package, so it sits below `cards/`, not inside `common/`. - */ - -export { atRule, changedBy, cssComment, rule } from './css.ts'; -export type { CssChild } from './css.ts'; -export { el, renderMarkup, style } from './element.ts'; -export type { Attributes, Child, MarkupElement } from './element.ts'; diff --git a/packages/core/src/markup/serialize.ts b/packages/core/src/markup/serialize.ts deleted file mode 100644 index 28e2836a..00000000 --- a/packages/core/src/markup/serialize.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * What the markup and stylesheet serializers share: where output goes, and how deep a line sits. - * Both write into one growing string rather than a list of chunks, so nothing needs joining. - */ - -const INDENT = ' '; - -/** Collects serializer output. A plain holder so appends stay a property write. */ -interface Sink { - out: string; -} - -/** Indentation by depth, grown as the tree is walked rather than rebuilt per line. */ -const indents = ['', INDENT]; - -/** - * @returns Two spaces per level. - */ -const indentAt = (depth: number): string => { - for (let i = indents.length; i <= depth; i += 1) { - indents.push(`${indents[i - 1] ?? ''}${INDENT}`); - } - return indents[depth] ?? ''; -}; - -export { INDENT, indentAt }; -export type { Sink }; diff --git a/packages/core/src/themes/index.ts b/packages/core/src/themes/index.ts index a5d40fec..d81d7a10 100644 --- a/packages/core/src/themes/index.ts +++ b/packages/core/src/themes/index.ts @@ -1,171 +1,6 @@ /** - * @file The forge's palettes. Each is one look, its suffix naming the background it was drawn - * for; a card wears one, or a `light,dark` pair to follow the viewer. + * @file The kit's palettes, published here so a page picking a theme need not load the cards. */ -/** One color scheme of a palette, every color a bare hex. */ -interface Theme { - /** - * The heat gradient, from its brightest stop to its deepest: the title, the rule under it, - * the rank ring and the corner glow are drawn in it. - */ - heat: readonly [string, string, string]; - text_color: string; - bg_color: string; - border_color?: string; - /** No corner glow and a fainter band: the calmer look of the original palettes. */ - quiet?: true; -} - -/** Collection of available themes. `transparent` alone reads on either README. */ -const themes = { - ember_light: { - heat: ['dc7600', 'e2470f', 'c8174f'], - text_color: '2a1b14', - bg_color: 'fffaf6', - }, - ember_dark: { - heat: ['ffd166', 'ff7a1a', 'ff2e63'], - text_color: 'f7ede6', - bg_color: '16100d', - }, - cobalt_light: { - heat: ['0891b2', '2b5cff', '6d3cf0'], - text_color: '121833', - bg_color: 'f7f9ff', - }, - cobalt_dark: { - heat: ['7df3ff', '3d8bff', '8a5cff'], - text_color: 'eef2ff', - bg_color: '0b1020', - }, - verdigris_light: { - heat: ['63a00d', '059669', '0e7490'], - text_color: '10211c', - bg_color: 'f5fbf8', - }, - verdigris_dark: { - heat: ['d9f99d', '34d399', '0ea5a4'], - text_color: 'e8f5f0', - bg_color: '0b1513', - }, - magma_light: { - heat: ['dd730b', 'e11d74', '9333ea'], - text_color: '2a1026', - bg_color: 'fff8fb', - }, - magma_dark: { - heat: ['ffb86b', 'ff4d8d', 'b14dff'], - text_color: 'f8ecf6', - bg_color: '130a16', - }, - brass_light: { - heat: ['a87b12', '8f5f17', '6b4212'], - text_color: '22201b', - bg_color: 'fbfaf7', - }, - brass_dark: { - heat: ['f5e6b8', 'd4a44c', 'a8742a'], - text_color: 'f2efe8', - bg_color: '121110', - }, - github_light: { - heat: ['218bff', '0969da', '0550ae'], - text_color: '1f2328', - bg_color: 'ffffff', - border_color: 'd1d9e0', - }, - github_dark: { - heat: ['79c0ff', '4493f8', '1f6feb'], - text_color: 'f0f6fc', - bg_color: '0d1117', - border_color: '3d444d', - }, - github_dark_dimmed: { - heat: ['96d0ff', '539bf5', '316dca'], - text_color: 'cdd9e5', - bg_color: '22272e', - border_color: '444c56', - }, - // The original palettes, one color for the whole heat, quiet for a README that wants less fire. - classic: { - heat: ['2f80ed', '2f80ed', '2f80ed'], - text_color: '434d58', - bg_color: 'fffefe', - border_color: 'e4e2e2', - quiet: true, - }, - graywhite: { - heat: ['24292e', '24292e', '24292e'], - text_color: '24292e', - bg_color: 'ffffff', - border_color: 'e4e2e2', - quiet: true, - }, - gruvbox_light: { - heat: ['b57614', 'b57614', 'b57614'], - text_color: '427b58', - bg_color: 'fbf1c7', - border_color: 'ebdbb2', - quiet: true, - }, - gruvbox: { - heat: ['fabd2f', 'fabd2f', 'fabd2f'], - text_color: '8ec07c', - bg_color: '282828', - border_color: '3c3836', - quiet: true, - }, - solarized_light: { - heat: ['268bd2', '268bd2', '268bd2'], - text_color: '859900', - bg_color: 'fdf6e3', - border_color: 'eee8d5', - quiet: true, - }, - solarized_dark: { - heat: ['268bd2', '268bd2', '268bd2'], - text_color: '859900', - bg_color: '002b36', - border_color: '073642', - quiet: true, - }, - nord: { - heat: ['81a1c1', '81a1c1', '81a1c1'], - text_color: 'd8dee9', - bg_color: '2e3440', - border_color: '3b4252', - quiet: true, - }, - // Text halfway between GitHub's light and dark greys, so it reads on whichever page shows through. - transparent: { - heat: ['cc8308', 'f56806', 'e11d48'], - text_color: '727a85', - bg_color: 'ffffff00', - border_color: '8b949e59', - }, -} as const satisfies Record; - -/** - * Name of one of the {@link themes}. - */ -type ThemeName = keyof typeof themes; - -/** - * Checks whether a value is one of the {@link themes} - */ -const isThemeName = (value: unknown): value is ThemeName => { - if (typeof value !== 'string') { - return false; - } - return Object.hasOwn(themes, value); -}; - -/** What a card wears when no theme is named: a pair, so it follows the viewer. */ -const DEFAULT_THEME = 'ember_light,ember_dark'; - -/** Every name the `theme` param accepts. */ -const THEME_PARAM_VALUES: ReadonlyArray = Object.keys(themes); - -export type { Theme, ThemeName }; -export { DEFAULT_THEME, THEME_PARAM_VALUES, isThemeName, themes }; +export { DEFAULT_THEME, THEME_PARAM_VALUES, isThemeName, themes } from '@stats-forge/card-kit'; +export type { Theme, ThemeName } from '@stats-forge/card-kit'; diff --git a/packages/core/tests/_cards.ts b/packages/core/tests/_cards.ts index 84a98b2c..3c15987f 100644 --- a/packages/core/tests/_cards.ts +++ b/packages/core/tests/_cards.ts @@ -2,6 +2,8 @@ * @file Every card, drawn from data that exercises its labels, for the specs that check all of them. */ +import type { Locale } from '@stats-forge/card-kit'; + import { renderContributedToCard } from '../src/cards/contributed-to/index.ts'; import { renderGistCard } from '../src/cards/gist/index.ts'; import { renderOrgActivityCard } from '../src/cards/org-activity/index.ts'; @@ -11,7 +13,6 @@ import { renderRepoCard } from '../src/cards/repo/index.ts'; import { renderStatsCard } from '../src/cards/stats/index.ts'; import { renderTopLanguages } from '../src/cards/top-languages/index.ts'; import { renderWakatimeCard } from '../src/cards/wakatime/index.ts'; -import type { Locale } from '../src/common/localize.ts'; import type { ContributedToData } from '../src/fetchers/contributed-to/types.ts'; import type { GistData } from '../src/fetchers/gist/types.ts'; import type { OrgActivityData } from '../src/fetchers/org-activity/types.ts'; diff --git a/packages/core/tests/_config.ts b/packages/core/tests/_config.ts index 99418590..58c2848b 100644 --- a/packages/core/tests/_config.ts +++ b/packages/core/tests/_config.ts @@ -1,7 +1,7 @@ +import type { Logger } from '@stats-forge/card-kit'; import { afterEach, vi } from 'vitest'; import { CardConfig } from '../src/common/config.ts'; -import type { Logger } from '../src/common/log.ts'; /** Takes core's diagnostics, so a spec can assert on them and the run prints none. */ export const testLogger = { diff --git a/packages/core/tests/_fetch-mock.ts b/packages/core/tests/_fetch-mock.ts index d7498b21..39d986d0 100644 --- a/packages/core/tests/_fetch-mock.ts +++ b/packages/core/tests/_fetch-mock.ts @@ -1,4 +1,6 @@ -import type { FetchLike, GraphQLResponse } from '../src/common/http.ts'; +import type { FetchLike } from '@stats-forge/card-kit'; + +import type { GraphQLResponse } from '../src/common/http.ts'; /** A request the mock was asked to send, as the assertions read it back. */ interface MockRequest { diff --git a/packages/core/tests/accessibility.spec.ts b/packages/core/tests/accessibility.spec.ts index 2e2e52b1..c43923a5 100644 --- a/packages/core/tests/accessibility.spec.ts +++ b/packages/core/tests/accessibility.spec.ts @@ -1,8 +1,8 @@ +import { renderError } from '@stats-forge/card-kit'; import { describe, expect, it } from 'vitest'; import { renderRepoCard } from '../src/cards/repo/index.ts'; import { renderStatsCard } from '../src/cards/stats/index.ts'; -import { renderError } from '../src/common/render.ts'; import { draw, exactly } from './_card.ts'; import { cards, repoData, statsData } from './_cards.ts'; diff --git a/packages/core/tests/allowlist.spec.ts b/packages/core/tests/allowlist.spec.ts index c9332ce9..ca34592a 100644 --- a/packages/core/tests/allowlist.spec.ts +++ b/packages/core/tests/allowlist.spec.ts @@ -3,9 +3,9 @@ * any request reaches GitHub, and every card has to declare which of its params is the identity. */ +import type { ApiResult } from '@stats-forge/card-kit'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { ApiResult } from '../src/api/api-result.ts'; import { contributedTo } from '../src/api/contributed-to.ts'; import { gist } from '../src/api/gist.ts'; import * as api from '../src/api/index.ts'; diff --git a/packages/core/tests/apiErrors.spec.ts b/packages/core/tests/apiErrors.spec.ts index 941d8786..84aea9d2 100644 --- a/packages/core/tests/apiErrors.spec.ts +++ b/packages/core/tests/apiErrors.spec.ts @@ -1,7 +1,7 @@ +import { errorResult } from '@stats-forge/card-kit'; import { screen } from '@testing-library/dom'; import { afterEach, describe, expect, it } from 'vitest'; -import { errorResult } from '../src/api/api-result.ts'; import { gist as gistApi } from '../src/api/gist.ts'; import { orgActivity as orgActivityApi } from '../src/api/org-activity.ts'; import { org as orgApi } from '../src/api/organization.ts'; diff --git a/packages/core/tests/apiFuzz.spec.ts b/packages/core/tests/apiFuzz.spec.ts index 19c2248b..d960475a 100644 --- a/packages/core/tests/apiFuzz.spec.ts +++ b/packages/core/tests/apiFuzz.spec.ts @@ -5,10 +5,10 @@ * a render-time throw surfaces as `upstream`, which no query string may cause. */ +import type { ApiResult, ParamShape } from '@stats-forge/card-kit'; import * as fc from 'fast-check'; import { describe, expect, it, vi } from 'vitest'; -import type { ApiResult } from '../src/api/api-result.ts'; import { contributedTo, gist, @@ -20,7 +20,6 @@ import { topLangs, wakatime, } from '../src/api/index.ts'; -import type { ParamShape } from '../src/api/params.ts'; import type { CardConfig } from '../src/common/config.ts'; import { ORG_CALENDAR_MAX_WEEKS } from '../src/common/constants.ts'; diff --git a/packages/core/tests/apiInputValidation.spec.ts b/packages/core/tests/apiInputValidation.spec.ts index 442f8561..08a09d98 100644 --- a/packages/core/tests/apiInputValidation.spec.ts +++ b/packages/core/tests/apiInputValidation.spec.ts @@ -1,6 +1,6 @@ +import type { ApiResult } from '@stats-forge/card-kit'; import { describe, expect, it } from 'vitest'; -import type { ApiResult } from '../src/api/api-result.ts'; import { contributedTo } from '../src/api/contributed-to.ts'; import { gist } from '../src/api/gist.ts'; import { orgActivity } from '../src/api/org-activity.ts'; diff --git a/packages/core/tests/apiParams.spec.ts b/packages/core/tests/apiParams.spec.ts index e3198a25..d14260f4 100644 --- a/packages/core/tests/apiParams.spec.ts +++ b/packages/core/tests/apiParams.spec.ts @@ -1,21 +1,21 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import * as z from 'zod/mini'; - -import type { ApiResult } from '../src/api/api-result.ts'; -import { gist as gistApi } from '../src/api/gist.ts'; -import { orgCalendar as orgCalendarApi } from '../src/api/org-calendar.ts'; import { + LOCALE_ALIASES, booleanParam, commonParams, parseParams, readBoolean, resolveEmpty, -} from '../src/api/params.ts'; +} from '@stats-forge/card-kit'; +import type { ApiResult } from '@stats-forge/card-kit'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import * as z from 'zod/mini'; + +import { gist as gistApi } from '../src/api/gist.ts'; +import { orgCalendar as orgCalendarApi } from '../src/api/org-calendar.ts'; import { pin as pinApi } from '../src/api/pin.ts'; import { stats as statsApi } from '../src/api/stats.ts'; import { topLangs as topLangsApi } from '../src/api/top-langs.ts'; import { wakatime as wakatimeApi } from '../src/api/wakatime.ts'; -import { LOCALE_ALIASES } from '../src/common/localize.ts'; import { REPO_USER_SEARCHES } from '../src/fetchers/stats/index.ts'; import { testConfig } from './_config.ts'; diff --git a/packages/core/tests/card.spec.ts b/packages/core/tests/card.spec.ts deleted file mode 100644 index 33c2bdbc..00000000 --- a/packages/core/tests/card.spec.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { screen } from '@testing-library/dom'; -import { afterEach, describe, expect, it } from 'vitest'; - -import { CARD_STYLE } from '../src/common/brand.ts'; -import { Card, titleFits } from '../src/common/Card.ts'; -import { HEAT_PAINT, getCardColors } from '../src/common/color.ts'; -import { icons } from '../src/common/icons.ts'; -import { measureText } from '../src/common/text.ts'; -import { themes } from '../src/themes/index.ts'; - -import { cssValue, draw, exactly } from './_card.ts'; - -/** @returns A card with no title or description of its own, plus whatever `init` sets. */ -const blank = (init: Partial[0]> = {}): Card => - new Card({ title: '', description: '', ...init }); - -/** @returns Every `.header` font-size the drawn style sets, in px: the shrink, then Firefox's. */ -const headerSizes = (): Array => - [ - ...(document.querySelector('style')?.textContent ?? '').matchAll( - /\.header \{\s*font-size: (?[\d.]+)px;/gu, - ), - ].map((match) => Number(match.groups?.['px'])); - -describe(Card, () => { - afterEach(() => { - document.body.innerHTML = ''; - }); - - it('should hide border', () => { - const card = blank({ hideBorder: true }); - - draw(card.render(``)); - expect(screen.queryByTestId('card-bg')).toHaveAttribute('stroke-opacity', '0'); - }); - - it('should not hide border', () => { - const card = blank({ hideBorder: false }); - - draw(card.render(``)); - expect(screen.queryByTestId('card-bg')).toHaveAttribute('stroke-opacity', '1'); - }); - - it('should have a custom title', () => { - const card = blank({ - title: 'custom title', - }); - - draw(card.render(``)); - expect(screen.queryByTestId('card-title')).toHaveTextContent(exactly('custom title')); - }); - - it('should hide title', () => { - const card = blank({ hideTitle: true }); - - draw(card.render(``)); - expect(screen.queryByTestId('card-title')).toBeNull(); - }); - - it('should not hide title', () => { - const card = blank({ hideTitle: false }); - - draw(card.render(``)); - expect(screen.queryByTestId('card-title')).toBeInTheDocument(); - }); - - it.each([ - { icon: true, room: 300 - 2 * CARD_STYLE.padding.x - 25 }, - { icon: false, room: 300 - 2 * CARD_STYLE.padding.x }, - ])( - 'should shrink a title too long for the card to fill its room (icon $icon)', - ({ icon, room }) => { - const title = 'Οι περισσότερο χρησιμοποιούμενες γλώσσες'; - const card = blank({ - width: 300, - title, - titlePrefixIcon: icon ? icons.repo : undefined, - }); - - draw(card.render(``)); - expect(screen.getByTestId('header')).toHaveTextContent(title); - expect(screen.getByTestId('header')).not.toHaveAttribute('style'); - const [size = 0, firefox] = headerSizes(); - // The semibold header draws about a tenth wider than `measureText`'s regular widths. - expect(measureText(title, size) * 1.1).toBeLessThanOrEqual(room); - expect(measureText(title, size + 0.1) * 1.1).toBeGreaterThan(room); - // Firefox lays the title out wider, so it keeps the ratio its own 15.5px has to 18px. - expect(firefox).toBe(Math.floor((size * 15.5 * 10) / 18) / 10); - }, - ); - - it.each([1, 100])('should shrink no smaller than 9px, cropping instead (width %i)', (width) => { - const card = blank({ width, title: 'Most Used Languages' }); - - draw(card.render(``)); - expect(headerSizes()).toStrictEqual([9, 7.7]); - }); - - it('should leave a title that fits at its own size', () => { - const card = blank({ width: 300, title: 'Most Used Languages' }); - - draw(card.render(``)); - expect(headerSizes()).toStrictEqual([15.5]); - }); - - it('should count a title as fitting while it shrinks no further than 16px', () => { - const title = 'Most Used Languages'; - // padding on both sides, the icon's column, then the semibold width at 16px - const at16 = 2 * CARD_STYLE.padding.x + 25 + (measureText(title, 18) * 1.1 * 16) / 18; - - expect(titleFits(title, Math.ceil(at16) + 1)).toBe(true); - expect(titleFits(title, Math.floor(at16) - 1)).toBe(false); - }); - - it('title should have prefix icon', () => { - const card = blank({ titlePrefixIcon: icons.repo }); - - draw(card.render(``)); - expect(document.querySelector('.title-icon')).toBeInTheDocument(); - }); - - it('should trail the title with the heat, and drop the trail along with the title', () => { - draw(blank({ width: 200 }).render(``)); - expect(screen.getByTestId('title-accent')).toHaveAttribute('fill', HEAT_PAINT.trail); - - draw(blank({ width: 200, hideTitle: true }).render(``)); - expect(screen.queryByTestId('title-accent')).not.toBeInTheDocument(); - }); - - it('should run the trail from under the icon to past the title, inside the card', () => { - const title = 'Short'; - draw(blank({ width: 400, title, titlePrefixIcon: icons.repo }).render(``)); - const expected = 25 + measureText(title, 18) * 1.1 + 24; - expect(screen.getByTestId('title-accent')).toHaveAttribute( - 'width', - String(Math.round(expected * 10) / 10), - ); - - draw(blank({ width: 200, title: 'A title far too long for a card this narrow' }).render(``)); - expect(screen.getByTestId('title-accent')).toHaveAttribute( - 'width', - String(200 - 2 * CARD_STYLE.padding.x), - ); - }); - - it('should paint the title in the heat, in one color, and keep the icon out of it', () => { - const card = blank({ - titlePrefixIcon: icons.repo, - colors: { - light: { ...getCardColors('ember_light'), titleColor: '#f00', iconColor: '#0f0' }, - dark: null, - }, - }); - - draw(card.render(``)); - // A gradient fill blurs text in WebKit's ``, so the title takes the heat's middle stop. - expect(cssValue('.header', 'fill')).toBe('#f00'); - expect(cssValue('.title-icon', 'fill')).toBe('#0f0'); - }); - - it('should recolor the title in the dark block', () => { - const light = getCardColors('cobalt_light'); - const dark = getCardColors('cobalt_dark'); - - draw(blank({ colors: { light, dark } }).render(``)); - expect(document.querySelector('style')).toHaveTextContent( - /prefers-color-scheme: dark[^@]*\.header \{\s*fill: #3d8bff;/u, - ); - }); - - it('should glow from the corner in the heat, more strongly on a dark card', () => { - const light = getCardColors('ember_light'); - const dark = getCardColors('ember_dark'); - - draw(blank({ colors: { light, dark } }).render(``)); - expect(screen.getByTestId('card-glow')).toHaveAttribute('fill', 'url(#heat-glow)'); - expect(document.querySelector('#heat-glow .glow-core')).toHaveAttribute( - 'stop-opacity', - String(light.glowOpacity), - ); - expect(document.querySelector('style')).toHaveTextContent( - /prefers-color-scheme: dark[^@]*\.glow-core \{\s*stop-color: #ff7a1a;\s*stop-opacity: 0\.13;/u, - ); - }); - - it('should recolor every heat stop in the dark block, so one gradient serves both', () => { - const light = getCardColors('cobalt_light'); - const dark = getCardColors('cobalt_dark'); - - draw(blank({ colors: { light, dark } }).render(``)); - expect(document.querySelector('#heat .heat-0')).toHaveAttribute('stop-color', '#0891b2'); - expect(document.querySelector('style')).toHaveTextContent( - /prefers-color-scheme: dark[^@]*\.heat-0 \{\s*stop-color: #7df3ff;/u, - ); - }); - - it('should draw the title band in the heat, its top corners following the card', () => { - const light = getCardColors('ember_light'); - draw(blank({ width: 200, border_radius: 8, colors: { light, dark: null } }).render(``)); - - expect(screen.getByTestId('title-band')).toHaveAttribute('fill', 'url(#heat-band)'); - expect(screen.getByTestId('title-band')).toHaveAttribute( - 'd', - 'M0.5 8.5a8 8 0 0 1 8 -8h183a8 8 0 0 1 8 8v32.5h-199z', - ); - expect(document.querySelector('#heat-band .band-0')).toHaveAttribute( - 'stop-opacity', - String(light.bandOpacity), - ); - }); - - it('should keep the band inside corners deeper than the band itself', () => { - const light = getCardColors('ember_light'); - draw( - blank({ width: 200, height: 200, border_radius: 60, colors: { light, dark: null } }).render( - ``, - ), - ); - - // the sides meet the band's bottom where the 60px arcs reach it, 3.26px in from each edge - expect(screen.getByTestId('title-band')).toHaveAttribute( - 'd', - 'M3.76 41A60 60 0 0 1 60.5 0.5h79A60 60 0 0 1 196.24 41z', - ); - }); - - it('should keep a corner too round for the card circular, the band following it', () => { - const light = getCardColors('ember_light'); - draw( - blank({ width: 100, height: 200, border_radius: 500, colors: { light, dark: null } }).render( - ``, - ), - ); - - expect(screen.getByTestId('title-band')).toHaveAttribute( - 'd', - expect.stringMatching(/^M[\d.]+ 41A49\.5 49\.5 0 0 1 50 0\.5h0A49\.5 49\.5 /u), - ); - expect(screen.getByTestId('card-bg')).toHaveAttribute('rx', '49.5'); - }); - - it('should cap a corner too round for a short card at half its height', () => { - draw(blank({ width: 400, height: 120, border_radius: 500 }).render(``)); - - expect(screen.getByTestId('card-bg')).toHaveAttribute('rx', '59.4'); - expect(screen.getByTestId('title-band')).toHaveAttribute( - 'd', - expect.stringContaining('A59.4 59.4 '), - ); - }); - - it('should show the band more strongly on a dark card', () => { - draw( - blank({ - colors: { light: getCardColors('ember_light'), dark: getCardColors('ember_dark') }, - }).render(``), - ); - expect(document.querySelector('style')).toHaveTextContent( - /prefers-color-scheme: dark[^@]*\.band-1 \{\s*stop-color: #ff7a1a;\s*stop-opacity: 0\.08;/u, - ); - }); - - it('should drop the band with the title', () => { - draw(blank({ hideTitle: true }).render(``)); - expect(screen.queryByTestId('title-band')).not.toBeInTheDocument(); - }); - - it('should follow the card corners with the glow', () => { - draw(blank({ width: 200, border_radius: 8 }).render(``)); - expect(screen.getByTestId('card-glow')).toHaveAttribute('rx', '8'); - }); - - it('should define the ring and the bloom only for a card that burns', () => { - draw(blank().render(``)); - expect(document.querySelector('#heat-ring')).toBeNull(); - expect(document.querySelector('#heat-bloom')).toBeNull(); - - draw(blank({ burns: true }).render(``)); - expect(document.querySelector('#heat-ring')).toBeInTheDocument(); - expect(document.querySelector('#heat-bloom')).toBeInTheDocument(); - expect(document.querySelector('#heat-bloom feGaussianBlur')).toBeInTheDocument(); - expect(document.querySelector('#heat-bloom feFuncA')).toHaveAttribute( - 'slope', - String(CARD_STYLE.bloom.opacity), - ); - expect(document.querySelector('#heat-bloom [in="SourceGraphic"]')).toBeNull(); - }); - - it('title should not have prefix icon', () => { - const card = blank(); - - draw(card.render(``)); - expect(document.querySelector('.title-icon')).not.toBeInTheDocument(); - }); - - it('should have proper height, width', () => { - const card = blank({ height: 200, width: 200 }); - const svg = draw(card.render(``)); - expect(svg).toHaveAttribute('height', '200'); - expect(svg).toHaveAttribute('width', '200'); - }); - - it('should have less height after title is hidden', () => { - const card = blank({ height: 200, hideTitle: true }); - - expect(draw(card.render(``))).toHaveAttribute('height', '162'); - }); - - it('main-card-body should have proper when title is visible', () => { - const card = blank({ height: 200 }); - draw(card.render(``)); - expect(screen.queryByTestId('main-card-body')).toHaveAttribute('transform', 'translate(0, 50)'); - }); - - it('main-card-body should have proper position after title is hidden', () => { - const card = blank({ height: 200, hideTitle: true }); - - draw(card.render(``)); - expect(screen.queryByTestId('main-card-body')).toHaveAttribute('transform', 'translate(0, 12)'); - }); - - it('should render with correct colors', () => { - const card = blank({ - height: 200, - colors: { - light: { ...getCardColors('ember_light'), heat: ['#f00', '#f00', '#f00'], bgColor: '#fff' }, - dark: null, - }, - }); - draw(card.render(``)); - - expect(document.querySelector('#heat .heat-1')).toHaveAttribute('stop-color', '#f00'); - expect(screen.queryByTestId('card-bg')).toHaveAttribute('fill', '#fff'); - }); - - it("should fill the background with the dark palette's own inside the dark block", () => { - draw( - blank({ - colors: { light: getCardColors('ember_light'), dark: getCardColors('ember_dark') }, - }).render(``), - ); - expect(document.querySelector('style')?.textContent).toMatch( - new RegExp( - `prefers-color-scheme: dark[^@]*\\.card-bg \\{\\s*fill: #${themes.ember_dark.bg_color};`, - 'u', - ), - ); - }); - - it.each([ - { what: 'a border radius that is not a number', init: { border_radius: Number.NaN } }, - { what: 'a title color', colors: { heat: ['red', 'red', 'red'] as [string, string, string] } }, - { what: 'a border color', colors: { borderColor: 'red' } }, - { what: 'a background color', colors: { bgColor: 'red' } }, - ])('refuses to draw $what it cannot write', ({ init, colors }) => { - const light = getCardColors('ember_light'); - const card = blank({ ...init, colors: { light: { ...light, ...colors }, dark: null } }); - - expect(() => card.render(``)).toThrow(/^Invalid /u); - }); - - it('names the heat stop it cannot write, not the one beside it', () => { - const light = getCardColors('ember_light'); - const heat: [string, string, string] = ['nope', light.heat[1], light.heat[2]]; - const card = blank({ colors: { light: { ...light, heat }, dark: null } }); - - expect(() => card.render(``)).toThrow('Invalid heat color: "nope"'); - }); -}); diff --git a/packages/core/tests/color.spec.ts b/packages/core/tests/color.spec.ts deleted file mode 100644 index 01f16ebf..00000000 --- a/packages/core/tests/color.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { parseThemeParams } from '../src/api/params.ts'; -import { HEAT_PAINT, cardGround, getCardColors, getLightDarkColors } from '../src/common/color.ts'; -import { CardError } from '../src/common/error.ts'; -import { themes } from '../src/themes/index.ts'; - -describe(getCardColors, () => { - it("should draw with the theme's own colors", () => { - const { heat } = themes.cobalt_light; - expect(getCardColors('cobalt_light')).toStrictEqual({ - heat: [`#${heat[0]}`, `#${heat[1]}`, `#${heat[2]}`], - titleColor: `#${heat[1]}`, - iconColor: `#${heat[1]}`, - textColor: `#${themes.cobalt_light.text_color}`, - bgColor: `#${themes.cobalt_light.bg_color}`, - borderColor: `#${heat[1]}4d`, - ringColor: HEAT_PAINT.ring, - progBarBgColor: '#ddd', - glowOpacity: 0.05, - bandOpacity: 0.05, - }); - }); - - it('should fall back to ember_light for an unknown name, or none', () => { - expect(getCardColors('invalidTheme')).toStrictEqual(getCardColors('ember_light')); - expect(getCardColors('toString')).toStrictEqual(getCardColors('ember_light')); - expect(getCardColors('ember')).toStrictEqual(getCardColors('ember_light')); - expect(getCardColors()).toStrictEqual(getCardColors('ember_light')); - }); - - it('should keep a border the theme names', () => { - expect(getCardColors('transparent').borderColor).toBe(`#${themes.transparent.border_color}`); - }); - - it('should glow hotter, and draw a lighter track, on a dark background', () => { - expect(getCardColors('ember_dark').glowOpacity).toBe(0.13); - expect(getCardColors('ember_dark').progBarBgColor).toBe('#ffffff33'); - expect(getCardColors('transparent').glowOpacity).toBe(0.08); - expect(getCardColors('transparent').progBarBgColor).toBe('#8b949e59'); - }); -}); - -describe('quiet themes', () => { - it('should drop the glow and halve the band, against the same tone in the forge', () => { - const quiet = getCardColors('classic'); - const forge = getCardColors('ember_light'); - expect(quiet.glowOpacity).toBe(0); - expect(quiet.bandOpacity).toBeCloseTo(forge.bandOpacity / 2); - expect(getCardColors('nord').glowOpacity).toBe(0); - }); -}); - -describe(getLightDarkColors, () => { - it('should follow the viewer in ember when no theme is named', () => { - const { lightColors, darkColors } = getLightDarkColors({}); - expect(lightColors).toStrictEqual(getCardColors('ember_light')); - expect(darkColors).toStrictEqual(getCardColors('ember_dark')); - }); - - it('should carry one palette for one name', () => { - const { lightColors, darkColors } = getLightDarkColors({ theme: 'brass_dark' }); - expect(lightColors).toStrictEqual(getCardColors('brass_dark')); - expect(darkColors).toBeNull(); - }); - - it('should draw the first of a pair in light mode and the second in dark', () => { - const { lightColors, darkColors } = getLightDarkColors({ theme: 'cobalt_dark,github_light' }); - expect(lightColors).toStrictEqual(getCardColors('cobalt_dark')); - expect(darkColors).toStrictEqual(getCardColors('github_light')); - }); - - it('should carry one palette for a pair naming one theme twice', () => { - expect(getLightDarkColors({ theme: 'verdigris_dark,verdigris_dark' }).darkColors).toBeNull(); - }); -}); - -describe(cardGround, () => { - it('should name the one background a card stands on, or the viewer when it carries both', () => { - expect(cardGround({})).toBe('viewer'); - expect(cardGround({ theme: 'brass_light,magma_dark' })).toBe('viewer'); - expect(cardGround({ theme: 'cobalt_dark' })).toBe('dark'); - expect(cardGround({ theme: 'cobalt_light' })).toBe('light'); - expect(cardGround({ theme: 'transparent' })).toBe('translucent'); - }); -}); - -/** - * @returns The theme the schema yields, or the param it rejected. - */ -const parsedTheme = (theme: string): string | undefined => { - try { - return parseThemeParams({ theme }).theme; - } catch (error) { - return error instanceof CardError ? `rejected ${String(error.param)}` : undefined; - } -}; - -describe(parseThemeParams, () => { - it('should accept one name or a pair, in the table spelling', () => { - expect(parsedTheme('Ember_Light')).toBe('ember_light'); - expect(parsedTheme('BRASS_LIGHT, magma_dark')).toBe('brass_light,magma_dark'); - }); - - it('should reject an unknown name, or more than two', () => { - expect(parsedTheme('ember')).toBe('rejected theme'); - expect(parsedTheme('ember_light,nope')).toBe('rejected theme'); - expect(parsedTheme('ember_light,cobalt_dark,brass_dark')).toBe('rejected theme'); - }); - - it('should pick only the theme off a query', () => { - expect( - parseThemeParams({ theme: 'ember_dark', title_color: 'fff', username: 'x' }), - ).toStrictEqual({ theme: 'ember_dark' }); - }); -}); diff --git a/packages/core/tests/config.spec.ts b/packages/core/tests/config.spec.ts index 4db5b20e..1d1933ff 100644 --- a/packages/core/tests/config.spec.ts +++ b/packages/core/tests/config.spec.ts @@ -3,11 +3,10 @@ * being shared with whatever else runs beside it, and the deadline it puts on each request. */ +import type { FetchLike, Logger } from '@stats-forge/card-kit'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { CardConfig } from '../src/common/config.ts'; -import type { FetchLike } from '../src/common/http.ts'; -import type { Logger } from '../src/common/log.ts'; import { testLogger } from './_config.ts'; diff --git a/packages/core/tests/flexLayout.spec.ts b/packages/core/tests/flexLayout.spec.ts deleted file mode 100644 index 0a2560ba..00000000 --- a/packages/core/tests/flexLayout.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { flexLayout } from '../src/common/render.ts'; -import { el, renderMarkup } from '../src/markup/index.ts'; -import type { Child } from '../src/markup/index.ts'; - -/** Renders a layout in the group a card would place it in, so the snapshot shows the offsets. */ -const layout = (items: Array): string => renderMarkup(el('g', {}, items)); - -const texts = (count: number): Array => - Array.from({ length: count }, (_, i) => el('text', {}, i + 1)); - -describe(flexLayout, () => { - it('should lay items out in a row', () => { - expect(layout(flexLayout({ items: texts(2), gap: 60 }))).toMatchInlineSnapshot(` - " - - 1 - - - 2 - - - " - `); - }); - - it('should lay items out in a column', () => { - expect(layout(flexLayout({ items: texts(2), gap: 60, direction: 'column' }))) - .toMatchInlineSnapshot(` - " - - 1 - - - 2 - - - " - `); - }); - - it('should offset each item by the one before it', () => { - expect(layout(flexLayout({ items: texts(4), gap: 20, sizes: [200, 100, 55, 25] }))) - .toMatchInlineSnapshot(` - " - - 1 - - - 2 - - - 3 - - - 4 - - - " - `); - }); - - it('should leave a lone item ungrouped, and skip the items that carry nothing', () => { - expect(layout(flexLayout({ items: [undefined, el('text', {}, 1), false], gap: 60 }))) - .toMatchInlineSnapshot(` - " - 1 - - " - `); - }); - - it('should reject sizes that are not numbers', () => { - expect(() => flexLayout({ items: [el('text', {}, 1)], gap: 0, sizes: [Number.NaN] })).toThrow( - 'flexLayout: `sizes` must contain only numbers', - ); - }); -}); diff --git a/packages/core/tests/fmt.spec.ts b/packages/core/tests/fmt.spec.ts deleted file mode 100644 index 0f0a88fb..00000000 --- a/packages/core/tests/fmt.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { formatBytes, kFormatter } from '../src/common/fmt.ts'; - -describe(kFormatter, () => { - it.each([ - // value, then as written by default and at 0, 1 and 2 decimals - [1, 1, '0k', '0.0k', '0.00k'], - [-1, -1, '-0k', '-0.0k', '-0.00k'], - [500, 500, '1k', '0.5k', '0.50k'], - [1000, '1k', '1k', '1.0k', '1.00k'], - [1200, '1.2k', '1k', '1.2k', '1.20k'], - [10_000, '10k', '10k', '10.0k', '10.00k'], - [12_345, '12.3k', '12k', '12.3k', '12.35k'], - [99_900, '99.9k', '100k', '99.9k', '99.90k'], - [9_900_000, '9900k', '9900k', '9900.0k', '9900.00k'], - ])('writes %i as %s, then %s, %s and %s', (value, byDefault, zero, one, two) => { - expect(kFormatter(value, 'en')).toBe(byDefault); - expect(kFormatter(value, 'en', 0)).toBe(zero); - expect(kFormatter(value, 'en', 1)).toBe(one); - expect(kFormatter(value, 'en', 2)).toBe(two); - }); - - it('rounds to a whole thousand at 0 decimals', () => { - expect(kFormatter(99_000, 'en', 0)).toBe('99k'); - }); - - it.each([ - ['en', '1.2k'], - [undefined, '1.2k'], - ['de', '1,2k'], - ['fr', '1,2k'], - // Arabic writes its own digits, which would measure unlike the rest of the card - ['ar', '1.2k'], - ['fa', '1.2k'], - ] as const)('writes the decimal as %s does', (locale, written) => { - expect(kFormatter(1200, locale)).toBe(written); - }); -}); - -describe(formatBytes, () => { - it.each([ - [0, '0 B'], - [100, '100.0 B'], - [1024, '1.0 KB'], - [1024 ** 2, '1.0 MB'], - [1024 ** 3, '1.0 GB'], - [1024 ** 4, '1.0 TB'], - [1024 ** 5, '1.0 PB'], - [1024 ** 6, '1.0 EB'], - [1234 * 1024, '1.2 MB'], - [123.4 * 1024, '123.4 KB'], - ])('writes %i bytes as %s', (bytes, written) => { - expect(formatBytes(bytes, 'en')).toBe(written); - }); - - it('writes the decimal as the locale does', () => { - expect(formatBytes(1234 * 1024, 'it')).toBe('1,2 MB'); - }); -}); diff --git a/packages/core/tests/html.spec.ts b/packages/core/tests/html.spec.ts deleted file mode 100644 index b3f060c3..00000000 --- a/packages/core/tests/html.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { encodeHTML } from '../src/markup/html.ts'; - -describe(encodeHTML, () => { - it('should encode HTML entities', () => { - expect(encodeHTML(`"'\`\\hello world<,.#4^&^@%!🛜©))`)).toBe( - '<html>"'`\\hello world<,.#4^&^@%!🛜©))', - ); - }); - - it('should handle empty strings', () => { - expect(encodeHTML('')).toBe(''); - }); - - it('should leave plain ASCII untouched', () => { - const path = 'M8 .25a.75.75 0 01.673.418l1.882 3.815z'; - expect(encodeHTML(path)).toBe(path); - }); - - it('should encode text that already looks like a character reference', () => { - // Nothing pre-escapes any more, so `&` is literal text and encodes like any `&`. - expect(encodeHTML('a & b')).toBe('a &#38; b'); - }); - - it('should drop backspace', () => { - expect(encodeHTML('a\u0008b')).toBe('ab'); - }); -}); diff --git a/packages/core/tests/locales.spec.ts b/packages/core/tests/locales.spec.ts index d846e735..5fcfb9be 100644 --- a/packages/core/tests/locales.spec.ts +++ b/packages/core/tests/locales.spec.ts @@ -1,6 +1,8 @@ import { globSync } from 'node:fs'; import { join } from 'node:path'; +import { AVAILABLE_LOCALES } from '@stats-forge/card-kit'; +import type { LocaleTable } from '@stats-forge/card-kit'; import { describe, expect, it } from 'vitest'; import { contributedToCardLocales } from '../src/cards/contributed-to/locales.ts'; @@ -13,8 +15,6 @@ import { statCardLocales } from '../src/cards/stats/locales.ts'; import { langCardLocales } from '../src/cards/top-languages/locales.ts'; import { wakatimeCardLocales } from '../src/cards/wakatime/locales.ts'; import { commonLocales } from '../src/common/locales.ts'; -import { AVAILABLE_LOCALES } from '../src/common/localize.ts'; -import type { LocaleTable } from '../src/common/localize.ts'; import { draw } from './_card.ts'; import { cards } from './_cards.ts'; diff --git a/packages/core/tests/localize.spec.ts b/packages/core/tests/localize.spec.ts deleted file mode 100644 index e1dc1e59..00000000 --- a/packages/core/tests/localize.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { statCardLocales } from '../src/cards/stats/locales.ts'; -import { defineLocales, localize } from '../src/common/localize.ts'; - -describe(localize, () => { - it('should return translated string', () => { - const t = localize(statCardLocales, 'en'); - expect(t.totalStars()).toBe('Total Stars Earned'); - }); - - it('should fall back to english when the locale has no entry for the key', () => { - const t = localize(statCardLocales, 'fr'); - // a key written in english and not yet translated - expect(t.contributedToAllTime()).toBe('Contributed to (all time)'); - }); - - it('should fall back to english when no locale is asked for', () => { - const t = localize(statCardLocales); - expect(t.totalStars()).toBe('Total Stars Earned'); - }); - - // Only the compiler refuses these: at runtime a table is handed back untouched. - describe('the tables it refuses to declare', () => { - it('should refuse a locale no card can be asked for', () => { - // `kr` is resolved to `ko` before a card draws, so a wording written under it is dead - // @ts-expect-error a wording under an unlisted code should be reported by ts - const table = defineLocales({ en: { title: 'Title' }, kr: { title: '제목' } }); - expect(table).toHaveProperty('kr.title', '제목'); - }); - - it('should refuse a key english does not declare', () => { - // @ts-expect-error a key the fallback lacks is left over from a rename, and never drawn - const table = defineLocales({ en: { title: 'Title' }, it: { heading: 'Titolo' } }); - expect(table).toHaveProperty('it.heading', 'Titolo'); - }); - - it('should refuse a placeholder english does not supply', () => { - const table = defineLocales({ - en: { title: 'Stats' }, - // @ts-expect-error the card passes what the english wording asks for, so this would throw - it: { title: 'Statistiche di {name}' }, - }); - expect(table).toHaveProperty('it.title', 'Statistiche di {name}'); - }); - - it('should refuse plural forms for a wording english gives no count', () => { - const table = defineLocales({ - en: { title: 'Repositories' }, - // @ts-expect-error a plural form needs a `count`, which the card never passes here - it: { title: { one: 'Repository', other: 'Repository' } }, - }); - expect(table).toHaveProperty('it.title.other', 'Repository'); - }); - - it('should refuse a plural form under a category no locale has', () => { - const table = defineLocales({ - en: { repos: { one: '{count} repo', other: '{count} repos' } }, - // @ts-expect-error `fwe` is no plural category, so its form would never be drawn - ru: { repos: { one: '{count} репо', fwe: '{count} репо', other: '{count} репо' } }, - }); - expect(table).toHaveProperty('ru.repos.fwe', '{count} репо'); - }); - }); - - describe('interpolation', () => { - const locales = defineLocales({ - en: { title: "{name}'{apostrophe} stats", repeated: '{name}, {name}' }, - it: { title: 'Statistiche di {name}' }, - }); - - it('should substitute a placeholder in the requested locale', () => { - const t = localize(locales, 'it'); - expect(t.title({ name: 'Anurag Hazra', apostrophe: 's' })).toBe( - 'Statistiche di Anurag Hazra', - ); - }); - - it('should substitute every occurrence of a placeholder', () => { - const t = localize(locales, 'en'); - expect(t.repeated({ name: 'Anurag' })).toBe('Anurag, Anurag'); - }); - - it('should leave a value that looks like a placeholder alone', () => { - const t = localize(locales, 'en'); - expect(t.repeated({ name: '{apostrophe}' })).toBe('{apostrophe}, {apostrophe}'); - }); - - it('should throw when the wording needs a value it was not given', () => { - const t = localize(locales, 'en'); - expect(() => - // @ts-expect-error omitting a value the wording declares should be reported by ts - t.title({ name: 'Anurag Hazra' }), - ).toThrow("'title' needs a value for '{apostrophe}'"); - }); - }); - - describe('plural forms', () => { - const locales = defineLocales({ - en: { - repos: { one: '{count} repository', other: '{count} repositories' }, - commits: { one: '{count} commit', other: '{count} commits' }, - stars: { one: '{count} star', other: '{count} stars' }, - }, - // Russian distinguishes a category english has no wording for - ru: { commits: { one: '{count} коммит', few: '{count} коммита', other: '{count} коммитов' } }, - // Chinese has no singular; a `one` here is what english rules would wrongly pick - 'zh-cn': { stars: { one: '一颗星', other: '{count} 颗星' } }, - }); - - it.each([ - [0, '0 repositories'], - [1, '1 repository'], - [2, '2 repositories'], - ])('should pick the form the count calls for (%i)', (count, expected) => { - const t = localize(locales, 'en'); - expect(t.repos({ count })).toBe(expected); - }); - - it.each([ - [1, '1 коммит'], - [3, '3 коммита'], - // `many`, which this table has no wording for, so `other` answers for it - [11, '11 коммитов'], - ])("should pick it by the locale's own rules (%i)", (count, expected) => { - const t = localize(locales, 'ru'); - expect(t.commits({ count })).toBe(expected); - }); - - it('should pick it by the rules of a locale with no singular', () => { - const t = localize(locales, 'zh-cn'); - expect(t.stars({ count: 1 })).toBe('1 颗星'); - }); - - it('should fall back to english, and to english rules, for an untranslated key', () => { - const t = localize(locales, 'ru'); - expect(t.repos({ count: 3 })).toBe('3 repositories'); - }); - }); -}); diff --git a/packages/core/tests/markup.spec.ts b/packages/core/tests/markup.spec.ts deleted file mode 100644 index 6870503d..00000000 --- a/packages/core/tests/markup.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - atRule, - changedBy, - cssComment, - rule, - el, - renderMarkup, - style, -} from '../src/markup/index.ts'; - -describe(renderMarkup, () => { - it('should write one element per line, two spaces per level', () => { - expect(renderMarkup(el('svg', { width: 10 }, el('g', {}, el('rect', { x: 0, y: 1.5 }))))) - .toMatchInlineSnapshot(` - " - - - - - " - `); - }); - - it('should leave out an attribute whose value is undefined', () => { - expect(renderMarkup(el('rect', { x: 0, transform: undefined, fill: '#fff' }))) - .toMatchInlineSnapshot(` - " - " - `); - }); - - it('should drop the children that carry nothing, and flatten the arrays', () => { - expect( - renderMarkup(el('g', {}, undefined, false, null, '', [el('rect', {}), [el('circle', {})]])), - ).toMatchInlineSnapshot(` - " - - - - " - `); - }); - - it('should write an element with no children self-closing', () => { - expect(renderMarkup(el('g', { class: 'a' }))).toMatchInlineSnapshot(` - " - " - `); - }); - - it('should keep a whitespace-sensitive element on one line', () => { - expect( - renderMarkup( - el('text', { class: 'stat' }, el('tspan', { x: 0 }, 'one'), el('tspan', { x: 0 }, 'two')), - ), - ).toMatchInlineSnapshot(` - "onetwo - " - `); - }); - - it('should keep a zero child, which carries a value', () => { - expect(renderMarkup(el('text', {}, 0))).toMatchInlineSnapshot(` - "0 - " - `); - }); - - it('should escape text children and attribute values', () => { - expect(renderMarkup(el('text', { 'data-testid': '' }, ""))) - .toMatchInlineSnapshot(` - "<script>alert('xss')</script> - " - `); - }); - - it('should render a stylesheet inside the style element', () => { - expect( - renderMarkup( - el( - 'svg', - {}, - style( - rule('.header', { fill: '#000', 'font-size': undefined }), - atRule( - '@media (prefers-color-scheme: dark)', - cssComment('night'), - rule('.header', { fill: '#fff' }), - ), - ), - ), - ), - ).toMatchInlineSnapshot(` - " - - - " - `); - }); - - it('should leave out a rule with nothing to declare, and the block that held only it', () => { - expect( - renderMarkup(el('svg', {}, style(rule('.a', { fill: undefined }), atRule('@media print')))), - ).toMatchInlineSnapshot(` - " - " - `); - }); -}); - -/** @returns The override `next` makes of `base`, as the stylesheet it writes. */ -const written = (...[base, next]: Parameters): string => - renderMarkup(el('svg', {}, style(changedBy(base, next)))); - -/** @returns One rule built for one scheme's fill. */ -const statRule = (fill: string): ReturnType => - rule('.stat', { font: '400 14px sans', fill }); - -/** @returns One at-rule built for one scheme's fill. */ -const printRule = (fill: string): ReturnType => - atRule('@media print', rule('.a', { fill, opacity: 1 })); - -/** @returns Keyframes whose first stop is built for one scheme's fill. */ -const growKeyframes = (fill: string): ReturnType => - atRule('@keyframes grow', rule('from', { fill, opacity: 0 }), rule('to', { opacity: 1 })); - -describe(changedBy, () => { - it('keeps only the declarations the second build changes', () => { - const css = written([statRule('#000')], [statRule('#fff')]); - expect(css).toContain('.stat {'); - expect(css).toContain('fill: #fff;'); - expect(css).not.toContain('font'); - }); - - it('drops a rule, an at-rule and a comment nothing changed', () => { - const same = [ - cssComment('note'), - rule('.a', { opacity: 0.5 }), - atRule('@keyframes grow', rule('from', { width: 0 })), - ]; - - expect(written(same, same)).toBe('\n'); - }); - - it('looks inside an at-rule', () => { - const css = written([printRule('#000')], [printRule('#fff')]); - expect(css).toContain('fill: #fff;'); - expect(css).not.toContain('opacity'); - }); - - it('keeps a changed @keyframes whole, since a later one replaces it', () => { - const css = written([growKeyframes('#000')], [growKeyframes('#fff')]); - - expect(css).toContain('fill: #fff;'); - expect(css).toContain('opacity: 0;'); - expect(css).toContain('opacity: 1;'); - }); - - it('keeps a node whole when the two builds do not line up', () => { - expect(written([rule('.a', { fill: '#000' })], [rule('.b', { fill: '#000' })])).toContain( - '.b {\n fill: #000;', - ); - }); -}); diff --git a/packages/core/tests/markup.types.spec-d.ts b/packages/core/tests/markup.types.spec-d.ts deleted file mode 100644 index 8b08ce20..00000000 --- a/packages/core/tests/markup.types.spec-d.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expectTypeOf, it } from 'vitest'; - -import type { MarkupElement } from '../src/markup/index.ts'; -import { el } from '../src/markup/index.ts'; - -describe('markup element types', () => { - it('should accept an attribute on an element it applies to', () => { - expectTypeOf(el('stop', { offset: '0%', 'stop-color': '#fff' })).toExtend(); - expectTypeOf(el('g', { fill: '#fff', 'text-anchor': 'middle' })).toExtend(); - expectTypeOf(el('rect', { x: 0, rx: 4, 'data-testid': 'bar' })).toEqualTypeOf< - MarkupElement<'rect'> - >(); - }); - - it('should reject an attribute on an element it does not apply to', () => { - // @ts-expect-error a gradient stop takes `stop-color`, not paint - el('stop', { fill: '#fff' }); - // @ts-expect-error `transform` applies to graphics elements, which a `tspan` is not - el('tspan', { transform: 'rotate(1)' }); - // @ts-expect-error `r` belongs to a circle - el('rect', { r: 4 }); - // @ts-expect-error not an SVG attribute - el('circle', { size: 100 }); - // @ts-expect-error not an element a card draws - el('circel', {}); - }); -}); diff --git a/packages/core/tests/ops.spec.ts b/packages/core/tests/ops.spec.ts index 1bd3d5c0..0025aa73 100644 --- a/packages/core/tests/ops.spec.ts +++ b/packages/core/tests/ops.spec.ts @@ -1,13 +1,7 @@ +import { clampValue, parseArray } from '@stats-forge/card-kit'; import { describe, expect, it } from 'vitest'; -import { - chunkArray, - clampValue, - excludes, - lowercaseTrim, - parseArray, - parseEmojis, -} from '../src/common/ops.ts'; +import { chunkArray, excludes, lowercaseTrim, parseEmojis } from '../src/common/ops.ts'; describe('common/ops', () => { it('should match an excluded repository by owner/name or by name, ignoring case', () => { diff --git a/packages/core/tests/query.spec.ts b/packages/core/tests/query.spec.ts deleted file mode 100644 index 1ee50658..00000000 --- a/packages/core/tests/query.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { queryOf } from '../src/api/query.ts'; - -describe(queryOf, () => { - it('joins a repeated key on commas, the way a list param reads', () => { - expect(queryOf(new URLSearchParams('hide=stars&hide=prs&username=x'))).toStrictEqual({ - hide: 'stars,prs', - username: 'x', - }); - }); - - it('keeps an empty value, for the handler to resolve', () => { - expect(queryOf(new URLSearchParams('hide_border'))).toStrictEqual({ hide_border: '' }); - }); -}); diff --git a/packages/core/tests/render.spec.ts b/packages/core/tests/render.spec.ts deleted file mode 100644 index ac7917ea..00000000 --- a/packages/core/tests/render.spec.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { screen } from '@testing-library/dom'; -import { describe, expect, it } from 'vitest'; - -import { CARD_STYLE } from '../src/common/brand.ts'; -import { HEAT_PAINT } from '../src/common/color.ts'; -import { - createLanguageNode, - createProgressNode, - createTextNode, - countFormatter, - glowingCircle, - iconWithLabel, - renderError, -} from '../src/common/render.ts'; -import type { NumberFormat } from '../src/common/render.ts'; -import { splitWrappedText, wrapTextMultiline } from '../src/common/text.ts'; -import { el, renderMarkup } from '../src/markup/index.ts'; -import { themes } from '../src/themes/index.ts'; - -import { draw, exactly } from './_card.ts'; - -describe(splitWrappedText, () => { - it('should return an empty array for empty text', () => { - expect(splitWrappedText('', 10, 200)).toStrictEqual([]); - }); - - it('should split a two-word string across lines', () => { - expect(splitWrappedText('hello world', 10, 25)).toStrictEqual(['hello', 'world']); - }); - - it('should split a word wider than maxWidth', () => { - expect(splitWrappedText('aaaa', 10, 15)).toStrictEqual(['aa', 'aa']); - }); - - it('should handle mix of short and long words', () => { - expect(splitWrappedText('short looooong', 10, 40)).toStrictEqual(['short', 'looooon', 'g']); - }); - - it('should handle complex whitespace characters', () => { - expect(splitWrappedText('One         two three', 10, 25)).toStrictEqual([ - 'One', - '     ', - '  ', - 'two', - 'three', - ]); - }); - - it('trailing spaces should not cause line breaks', () => { - expect(splitWrappedText('hi hi ', 10, 12)).toStrictEqual(['hi', 'hi']); - }); -}); - -describe(wrapTextMultiline, () => { - it('should not wrap small texts', () => { - expect(wrapTextMultiline('Small text should not wrap', 130, 11, 3)).toStrictEqual([ - 'Small text should not wrap', - ]); - }); - - it('should wrap large texts', () => { - const multiLineText = wrapTextMultiline('Hello world long long long text', 130, 11, 3); - expect(multiLineText).toStrictEqual(['Hello world long long', 'long text']); - }); - - it('should wrap large texts and limit max lines', () => { - const multiLineText = wrapTextMultiline('Hello world long long long text', 53, 11, 2); - expect(multiLineText).toStrictEqual(['Hello', 'world long...']); - }); - - it('should handle chinese characters', () => { - const multiLineText = wrapTextMultiline( - '专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!', - 130, - 11, - 3, - ); - expect(multiLineText).toHaveLength(3); - // Plain characters: the escaping happens when the line is serialized into the card. - expect(multiLineText[0]).toHaveLength(11); - }); -}); - -/** @returns A 300px bar at `progress`, asked for its tip, inside an ``. */ -const tipped = (progress: number): string => - `${renderMarkup(createProgressNode({ x: 0, y: 0, width: 300, color: '#f00', progress, delay: 0, tip: true }))}`; - -describe(createProgressNode, () => { - // the bar grows to this width, so a renderer that skips the animation still draws it whole - it('should draw the fill at its full width', () => { - draw( - `${renderMarkup(createProgressNode({ x: 0, y: 0, width: 200, color: '#f00', progress: 40, delay: 0 }))}`, - ); - - expect(screen.getByTestId('lang-progress')).toHaveAttribute('width', '40%'); - expect(document.querySelector('.lang-progress')).toHaveAttribute('width', '100%'); - }); - - it('should cap a bar with a tip only when the bar is longer than it', () => { - draw(tipped(40)); - expect(screen.getByTestId('lang-tip')).toHaveAttribute('cx', '115.5'); - - draw(tipped(2)); - expect(screen.queryAllByTestId(/^lang-tip/u)).toHaveLength(0); - }); -}); - -describe(glowingCircle, () => { - // WebKit draws a filtered shape at 1x, so the circle stays out of its glow, drawn under it. - it('draws the bloom alone, then the circle unfiltered over it', () => { - draw(renderMarkup(el('svg', {}, glowingCircle('dot', { cx: 1, cy: 2, r: 3 })))); - - const [glow, dot] = screen.getAllByTestId(/^dot/u); - expect(glow).toHaveAttribute('data-testid', 'dot-glow'); - expect(glow).toHaveAttribute('filter', HEAT_PAINT.bloom); - expect(dot).toHaveAttribute('data-testid', 'dot'); - expect(dot).not.toHaveAttribute('filter'); - expect(dot).toHaveAttribute('r', '3'); - }); -}); - -describe(renderError, () => { - it('should contain error messages', () => { - document.body.innerHTML = renderError({ message: 'Something went wrong' }); - expect(screen.getByTestId('message').children[0]).toHaveTextContent( - exactly('Something went wrong'), - ); - expect(screen.getByTestId('message').children[1]).toBeEmptyDOMElement(); - - document.body.innerHTML = renderError({ - message: 'Something went wrong', - secondaryMessage: 'Secondary Message', - }); - expect(screen.getByTestId('message').children[1]).toHaveTextContent( - exactly('Secondary Message'), - ); - }); - - it('should sit its text on the side padding every card uses', () => { - document.body.innerHTML = renderError({ message: 'Nope', secondaryMessage: 'Again' }); - - const x = String(CARD_STYLE.padding.x); - for (const text of document.querySelectorAll('text, tspan')) { - expect(text).toHaveAttribute('x', x); - } - }); - - it('points a reader at this project, on its own line', () => { - document.body.innerHTML = renderError({ message: 'Nope' }); - expect(screen.getByTestId('report')).toHaveTextContent( - exactly('File an issue at https://tinyurl.com/github-stats-forge-bug'), - ); - }); - - it('offers no report line for a failure that is not reportable', () => { - document.body.innerHTML = renderError({ - message: 'Nope', - renderOptions: { reportable: false }, - }); - expect(screen.queryByTestId('report')).not.toBeInTheDocument(); - }); - - it("should follow the viewer's color scheme in the default theme", () => { - draw(renderError({ message: 'Nope' })); - - const css = document.querySelector('style')?.textContent ?? ''; - expect(css).toMatch( - new RegExp( - `prefers-color-scheme: dark[^@]*\\.error-bg \\{\\s*fill: #${themes.ember_dark.bg_color};`, - 'u', - ), - ); - expect(document.querySelector('.error-bg')).toHaveAttribute( - 'fill', - `#${themes.ember_light.bg_color}`, - ); - }); - - it('should carry one palette for a pinned variant', () => { - draw(renderError({ message: 'Nope', renderOptions: { theme: 'cobalt_dark' } })); - - expect(document.querySelector('style')).not.toHaveTextContent('prefers-color-scheme: dark'); - expect(document.querySelector('.error-bg')).toHaveAttribute( - 'fill', - `#${themes.cobalt_dark.bg_color}`, - ); - }); - - it('should encode error message', () => { - const errorSVG = renderError({ - message: "", - }); - - expect(errorSVG).toContain('<script>alert('xss')</script>'); - - document.body.innerHTML = errorSVG; - const svg = document.querySelector('svg'); - expect(svg?.querySelector('script')).toBeNull(); - }); - - it('should encode secondary error message', () => { - const errorSVG = renderError({ - message: 'Error', - secondaryMessage: '"title_color"', - }); - - expect(errorSVG).toContain('<img'); - - document.body.innerHTML = errorSVG; - const svg = document.querySelector('svg'); - expect(svg?.querySelector('img')).toBeNull(); - }); -}); - -describe(countFormatter, () => { - it.each<{ format: NumberFormat; precision: number | undefined; written: string }>([ - { format: 'short', precision: undefined, written: '1.2k' }, - { format: 'short', precision: 2, written: '1.23k' }, - { format: 'short', precision: 5, written: '1.23k' }, - { format: 'short', precision: -1, written: '1k' }, - // a precision that is no number keeps the abbreviation's own default - { format: 'short', precision: Number.NaN, written: '1.2k' }, - { format: 'long', precision: 2, written: '1234' }, - ])( - 'writes 1234 as $written for $format with $precision decimals', - ({ format, precision, written }) => { - const formatCount = countFormatter({ number_format: format, number_precision: precision }); - expect(formatCount(1234)).toBe(written); - }, - ); - - it('abbreviates when no format is asked for', () => { - expect(countFormatter({})(1234)).toBe('1.2k'); - }); - - it("writes an abbreviation's decimal as the locale does", () => { - expect(countFormatter({ locale: 'de' })(1234)).toBe('1,2k'); - }); -}); - -describe('the node builders', () => { - const progress = { x: 0, y: 0, width: 200, progress: 40, delay: 0 }; - const text = { - icon: undefined, - label: 'Stars', - value: 3, - id: 'stars', - index: 0, - showIcons: false, - bold: false, - formatValue: String, - }; - - // each value lands in an attribute, so anything else would be written into the SVG as is - it.each([ - { what: 'language color', build: (): unknown => createLanguageNode('TypeScript', 'red') }, - { - what: 'progress color', - build: (): unknown => createProgressNode({ ...progress, color: 'red' }), - }, - { what: 'width', build: (): unknown => createProgressNode({ ...progress, width: Number.NaN }) }, - { what: 'x', build: (): unknown => createProgressNode({ ...progress, x: Number.NaN }) }, - { what: 'y', build: (): unknown => createProgressNode({ ...progress, y: Number.NaN }) }, - { what: 'delay', build: (): unknown => createProgressNode({ ...progress, delay: Number.NaN }) }, - { what: 'iconSize', build: (): unknown => iconWithLabel(undefined, 1, 'stars', Number.NaN) }, - { - what: 'labelXOffset', - build: (): unknown => createTextNode({ ...text, labelXOffset: Number.NaN }), - }, - { what: 'index', build: (): unknown => createTextNode({ ...text, index: Number.NaN }) }, - { - what: 'valueAnchorX', - build: (): unknown => createTextNode({ ...text, valueAnchorX: Number.NaN }), - }, - ])('refuses an invalid $what', ({ what, build }) => { - expect(build).toThrow(`Invalid ${what}: `); - }); -}); diff --git a/packages/core/tests/renderOrgActivityCard.spec.ts b/packages/core/tests/renderOrgActivityCard.spec.ts index 387b8a01..74e73664 100644 --- a/packages/core/tests/renderOrgActivityCard.spec.ts +++ b/packages/core/tests/renderOrgActivityCard.spec.ts @@ -1,8 +1,8 @@ +import { CARD_STYLE } from '@stats-forge/card-kit'; import { screen } from '@testing-library/dom'; import { describe, expect, it } from 'vitest'; import { renderOrgActivityCard } from '../src/cards/org-activity/index.ts'; -import { CARD_STYLE } from '../src/common/brand.ts'; import { CardError } from '../src/common/error.ts'; import type { OrgActivityData } from '../src/fetchers/org-activity/types.ts'; import { themes } from '../src/themes/index.ts'; diff --git a/packages/core/tests/renderOrgCalendarCard.spec.ts b/packages/core/tests/renderOrgCalendarCard.spec.ts index 6f566bac..e4517925 100644 --- a/packages/core/tests/renderOrgCalendarCard.spec.ts +++ b/packages/core/tests/renderOrgCalendarCard.spec.ts @@ -1,9 +1,8 @@ +import { CARD_STYLE, getCardColors } from '@stats-forge/card-kit'; import { screen } from '@testing-library/dom'; import { describe, expect, it } from 'vitest'; import { renderOrgCalendarCard } from '../src/cards/org-calendar/index.ts'; -import { CARD_STYLE } from '../src/common/brand.ts'; -import { getCardColors } from '../src/common/color.ts'; import type { OrgCalendarData } from '../src/fetchers/org-calendar/types.ts'; import { cssValue, exactly } from './_card.ts'; diff --git a/packages/core/tests/renderRepoCard.spec.ts b/packages/core/tests/renderRepoCard.spec.ts index 7b7c4629..c9c24b89 100644 --- a/packages/core/tests/renderRepoCard.spec.ts +++ b/packages/core/tests/renderRepoCard.spec.ts @@ -1,12 +1,10 @@ +import { AVAILABLE_LOCALES, CARD_STYLE, measureText } from '@stats-forge/card-kit'; +import type { Locale, LocaleTable } from '@stats-forge/card-kit'; import { screen } from '@testing-library/dom'; import { describe, expect, it } from 'vitest'; import { renderRepoCard } from '../src/cards/repo/index.ts'; import { repoCardLocales } from '../src/cards/repo/locales.ts'; -import { CARD_STYLE } from '../src/common/brand.ts'; -import { AVAILABLE_LOCALES } from '../src/common/localize.ts'; -import type { Locale, LocaleTable } from '../src/common/localize.ts'; -import { measureText } from '../src/common/text.ts'; import type { RepositoryData } from '../src/fetchers/repo/types.ts'; import { themes } from '../src/themes/index.ts'; diff --git a/packages/core/tests/renderStatsCard.spec.ts b/packages/core/tests/renderStatsCard.spec.ts index 3d8bc7aa..8bc3b252 100644 --- a/packages/core/tests/renderStatsCard.spec.ts +++ b/packages/core/tests/renderStatsCard.spec.ts @@ -1,9 +1,8 @@ +import { CARD_STYLE, HEAT_PAINT } from '@stats-forge/card-kit'; import { screen } from '@testing-library/dom'; import { describe, expect, it, vi } from 'vitest'; import { renderStatsCard } from '../src/cards/stats/index.ts'; -import { CARD_STYLE } from '../src/common/brand.ts'; -import { HEAT_PAINT } from '../src/common/color.ts'; import type { StatsData } from '../src/fetchers/stats/types.ts'; import { themes } from '../src/themes/index.ts'; diff --git a/packages/core/tests/renderTopLanguagesCard.spec.ts b/packages/core/tests/renderTopLanguagesCard.spec.ts index 7593dfe2..dcaeebe8 100644 --- a/packages/core/tests/renderTopLanguagesCard.spec.ts +++ b/packages/core/tests/renderTopLanguagesCard.spec.ts @@ -1,3 +1,4 @@ +import { CARD_STYLE, measureText } from '@stats-forge/card-kit'; import { screen } from '@testing-library/dom'; import { describe, expect, it } from 'vitest'; @@ -21,8 +22,6 @@ import { calculateNormalLayoutHeight, calculatePieLayoutHeight, } from '../src/cards/top-languages/layouts.ts'; -import { CARD_STYLE } from '../src/common/brand.ts'; -import { measureText } from '../src/common/text.ts'; import type { Lang, TopLangData } from '../src/fetchers/top-languages/types.ts'; import { themes } from '../src/themes/index.ts'; diff --git a/packages/core/tests/renderWakatimeCard.spec.ts b/packages/core/tests/renderWakatimeCard.spec.ts index cecd1b6b..c8cfc38f 100644 --- a/packages/core/tests/renderWakatimeCard.spec.ts +++ b/packages/core/tests/renderWakatimeCard.spec.ts @@ -1,11 +1,9 @@ +import { CARD_STYLE, getCardColors, measureText } from '@stats-forge/card-kit'; import { screen } from '@testing-library/dom'; import { describe, expect, it } from 'vitest'; import { renderWakatimeCard } from '../src/cards/wakatime/index.ts'; -import { CARD_STYLE } from '../src/common/brand.ts'; -import { getCardColors } from '../src/common/color.ts'; import { getLanguageColor } from '../src/common/languageColors.ts'; -import { measureText } from '../src/common/text.ts'; import type { WakaTimeData } from '../src/fetchers/wakatime/types.ts'; import { cssValue, draw, exactly, xOf } from './_card.ts'; diff --git a/packages/core/tests/xss.spec.ts b/packages/core/tests/xss.spec.ts index 8bdeb667..955edf3a 100644 --- a/packages/core/tests/xss.spec.ts +++ b/packages/core/tests/xss.spec.ts @@ -1,6 +1,6 @@ +import type { ApiResult } from '@stats-forge/card-kit'; import { describe, expect, it, vi } from 'vitest'; -import type { ApiResult } from '../src/api/api-result.ts'; import { gist } from '../src/api/gist.ts'; import { pin } from '../src/api/pin.ts'; import { stats } from '../src/api/stats.ts'; From 895433d8dc4b127f55dee8ab6a1874cb0ff29c6b Mon Sep 17 00:00:00 2001 From: Marco Pasqualetti <24919330+marcalexiei@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:13:06 +0100 Subject: [PATCH 2/2] build: install card-kit 0.1.1 from npm --- AGENTS.md | 5 ++++- packages/cli/vitest.config.ts | 2 ++ packages/core/package.json | 2 +- pnpm-lock.yaml | 13 +++++++++++++ pnpm-workspace.yaml | 1 + 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2cb65211..ab92c1a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -288,7 +291,7 @@ pnpm is **12.x** ([#110]); `pnpm-workspace.yaml` gives each setting's reason, an `pnpm why `, 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 diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 729ba80e..2efae810 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -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 diff --git a/packages/core/package.json b/packages/core/package.json index 172d3ba1..122da522 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,7 +82,7 @@ "generate-emoji-map": "node scripts/generate-emoji-map.ts" }, "dependencies": { - "@stats-forge/card-kit": "^0.1.0", + "@stats-forge/card-kit": "^0.1.1", "zod": "^4.6.7" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87899e36..13c848e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -337,6 +337,9 @@ importers: packages/core: dependencies: + '@stats-forge/card-kit': + specifier: ^0.1.1 + version: 0.1.1(zod@4.6.7) zod: specifier: ^4.6.7 version: 4.6.7 @@ -2196,6 +2199,12 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@stats-forge/card-kit@0.1.1': + resolution: {integrity: sha512-c5Bi93GGcFJa0J5xvUyGH7i0bv4Sf181mnlC5i5b0/H1pPX6v/NZwO+vPzQd29K5CoxeqOyU7dUmzPw1oUu0Qg==} + engines: {node: ^24 || >=26} + peerDependencies: + zod: ^4.6.7 + '@testing-library/dom@10.4.2': resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} @@ -5856,6 +5865,10 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@stats-forge/card-kit@0.1.1(zod@4.6.7)': + dependencies: + zod: 4.6.7 + '@testing-library/dom@10.4.2': dependencies: '@babel/code-frame': 7.29.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4342f1a3..05b44928 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,6 +28,7 @@ minimumReleaseAge: 4320 minimumReleaseAgeExclude: - '@marcalexiei/oxfmt-config' - '@marcalexiei/oxlint-config' + - '@stats-forge/card-kit' # Reject a version with weaker publish trust evidence than an earlier one. trustPolicy: no-downgrade