diff --git a/.agents/skills/ngx-theme-stack/SKILL.md b/.agents/skills/ngx-theme-stack/SKILL.md index 289621d..490db3c 100644 --- a/.agents/skills/ngx-theme-stack/SKILL.md +++ b/.agents/skills/ngx-theme-stack/SKILL.md @@ -4,7 +4,7 @@ description: Signal-based theme manager for Angular 20+. Use this skill to confi compatibility: Angular 20+ with TypeScript. Optional Tailwind CSS v4. metadata: author: WanderleeDev - version: '1.2.2' + version: '1.3.0' --- # ngx-theme-stack @@ -18,16 +18,19 @@ Headless, signal-based theme manager for Angular 20+. - **Cycle** (`ThemeCycleService`) - Rotate through all themes. - **Select** (`ThemeSelectService`) - Full picker dropdown/radio selection. - **Exception**: If the user explicitly mentions which switcher type they want in their query, skip the question and implement it directly. -- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. `sunset`, colors, or CSS variables). +- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. `sunset`, colors, or CSS variables). Suggested names must match `/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/` (ASCII, no leading digit, no whitespace, no CSS metacharacter). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as `light` or `dark`, then declare it with the object form in `themes`: `{ name: 'sepia', scheme: 'light' }`. Answering `auto`, leaving it blank, or anything unrecognised means no hint is declared, which leaves scrollbars and form controls on the OS colour scheme. - **DO NOT** generate code or configs until the user responds to these questions. ## Constraints & Rules - Call `provideThemeStack()` once in root `app.config.ts`. Custom themes merge with defaults. +- `themes` accepts plain names and `{ name, scheme }` objects, mixable in one array. `scheme` is `'light' | 'dark' | 'auto'` and only controls the CSS `color-scheme` hint for native widgets; it does not select a theme. Any other value is invalid and behaves as `'auto'`. Built-in names keep their implicit hint. +- **Theme names must match `/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/`** (ASCII only). A name becomes a CSS class and is written into `classList`, so a leading digit makes `.2lucky` invalid CSS (the browser drops the rule and the theme loses its styles), ASCII whitespace throws `InvalidCharacterError` in `classList.add`, and a CSS metacharacter turns `.lucky.theme` into a two-class selector that can never match the single class token `lucky.theme` — silently. `provideThemeStack()` throws `NgxThemeStackError` for a bad name; pick a name in that shape before writing config. - **Theme Synchronization**: Syncs theme configuration in `app.config.ts` with `index.html` assets. - **Manual execution**: Run `pnpm run ngx-theme-stack:sync` (or `npm run ngx-theme-stack:sync` / `yarn run ngx-theme-stack:sync`). - **Auto-Sync**: Runs automatically before serving or building via `"prestart"` and `"prebuild"` hooks in `package.json`. - **When to sync**: Run after adding/removing themes, renaming themes, changing configuration settings (storageKey, mode, strategy), or manually editing index.html. + - **What it reports**: an invalid theme name, or a `scheme` outside `'light' | 'dark' | 'auto'`, is dropped from the regenerated config and reported as a warning. It never rewrites silently, and it never emits a value the compiler would reject. - **Debugging**: If a theme reverts to default/system on reload, check if the theme identifier is missing in the valid themes array (`v`) in `index.html`. If missing, run synchronization. - `isDark()` / `isLight()` return false for custom themes (use `resolvedTheme()`). - `selectedTheme()` can be `'system'`; `resolvedTheme()` is always the concrete theme applied to the DOM (never `'system'`). diff --git a/.agents/skills/ngx-theme-stack/references/api-reference.md b/.agents/skills/ngx-theme-stack/references/api-reference.md index 8778da1..4e69c8d 100644 --- a/.agents/skills/ngx-theme-stack/references/api-reference.md +++ b/.agents/skills/ngx-theme-stack/references/api-reference.md @@ -14,10 +14,74 @@ provideThemeStack({ }) ``` +### Themes: string form and object form + +Each entry in `themes` is either a plain name or an object that adds a +`color-scheme` hint. Both shapes can be mixed in one array. The hint only +affects native browser widgets (scrollbars, inputs, form controls); it does +not pick a theme for you. + +```typescript +provideThemeStack({ + themes: [ + 'system', + 'light', + 'dark', + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ] as const, +}) +``` + +| `scheme` | Effect on `color-scheme` | +| --- | --- | +| `'light'` | Sets `color-scheme: light` while the theme is active. | +| `'dark'` | Sets `color-scheme: dark` while the theme is active. | +| `'auto'` (default when omitted) | No hint; the browser decides. | + +Any other value is invalid and falls back to `'auto'`. The `ng-add` prompt only +accepts `light`, `dark`, or `auto`; an unknown answer falls back to `'auto'`. +The `sync` schematic drops an invalid `scheme` from an existing config and warns. + +Built-in names keep their implicit hint (`light` → light, `dark` → dark, +`system` → auto). Prefer the object form for any custom theme whose native +widgets must match the theme, otherwise scrollbars keep the OS colour. + +```typescript +// Wrong: sepia's scrollbars stay OS-coloured +provideThemeStack({ themes: ['sepia'] as const }) + +// Right: declares the intended native colour scheme +provideThemeStack({ themes: [{ name: 'sepia', scheme: 'light' }] as const }) +``` + +### Theme names + +A theme name becomes a CSS class (`.name`) and is written into `classList`, so it +must be usable as a CSS identifier. It has to match: + +``` +/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/ +``` + +| Name | Accepted | Why | +| --- | --- | --- | +| `lucky`, `lucky-theme`, `lucky_theme`, `lucky2`, `_lucky`, `-lucky` | yes | usable as-is | +| `2lucky` | no | `.2lucky` is invalid CSS, so the browser drops the rule and the theme silently loses its styles | +| `lucky theme` | no | `classList.add` throws `InvalidCharacterError` on ASCII whitespace | +| `lucky.theme` | no | `.lucky.theme` matches two classes while the class token is the single string `lucky.theme`, so the styles never apply, with no error | +| `sueño`, `café` | no | deliberately ASCII-only, a narrower subset than CSS allows | + **Throws `NgxThemeStackError` when:** - A theme entry is empty, or `defaultTheme` is not in themes, or `storageKey` is empty. +- **A theme name does not match the pattern above.** This is a build error on purpose: the failure it prevents (a first-paint flash, or a theme whose styles never match) used to be silent. - `setTheme()` is called with a theme not in the configured themes list. +> After changing `themes` (adding, removing, renaming, or adding a `scheme`), +> run the sync schematic so the anti-flash script in `index.html` picks up the +> new names and hints. Otherwise a custom theme can silently fall back to the +> default on first paint. + --- ## CoreThemeService @@ -33,6 +97,8 @@ Foundation service managing state (signals), persistence, system preference, and | `isDark()` / `isLight()` | `Signal` | `true` for dark/light (returns `false` for custom themes). | | `isSystem()` / `isHydrated()` | `Signal` | System choice active / SSR hydration finished. | | `availableThemes` | `string[]` | All configured themes including built-ins. | +| `themeNames` | `string[]` | Same list, plain names only — bind directly in templates. | +| `resolvedThemes` | `{ name, scheme }[]` | Normalized themes with their color-scheme hint. | | `setTheme(theme)` | `(theme: string) => void` | Validates, persists, and applies the theme to DOM. | --- diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f186e69..2b9491b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,6 +21,40 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v4 + with: + # Full history is required by the guard below: it asks whether a stable + # tag is an ancestor of origin/main. The default `fetch-depth: 1` + # leaves no remote-tracking refs, so the check would fail closed and + # reject every valid release. + fetch-depth: 0 + + # ── Release guards ────────────────────────────────────────────────────── + # `latest` is what `npm install ` resolves for everyone, so nothing + # unstable may reach it. Prereleases (tag name containing '-') skip both + # guards on purpose: they are published under the `next` dist-tag. + - name: Guard — a stable tag must live on main + if: ${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }} + run: | + set -euo pipefail + if ! git rev-parse --verify --quiet origin/main >/dev/null; then + echo "::error::origin/main is unavailable, so the tag cannot be verified against main." + exit 1 + fi + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::Stable tag '$GITHUB_REF_NAME' is not an ancestor of main. Refusing to publish it as 'latest'." + exit 1 + fi + echo "OK: '$GITHUB_REF_NAME' is on main and may be published as 'latest'." + + - name: Guard — a manual publish must run from main + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + set -euo pipefail + if [ "$GITHUB_REF_NAME" != "main" ]; then + echo "::error::Manual publish is only allowed from 'main' (got '$GITHUB_REF_NAME')." + exit 1 + fi + echo "OK: manual publish from main." - name: Install pnpm uses: pnpm/action-setup@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd1733..91aa497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +# [3.10.0-next.1](https://github.com/WanderleeDev/ngx-theme-stack/compare/v3.10.0-next.0...v3.10.0-next.1) (2026-10-06) + + +### Bug Fixes + +* **lib:** whitelist the runtime color scheme, and make the yes/no prompt strict ([8f39ef5](https://github.com/WanderleeDev/ngx-theme-stack/commit/8f39ef5a172b491c6dc09e5277be2f8a7badc5d7)) +* **schematics:** validate the color-scheme prompt and drop the duplicate none ([9c83a9c](https://github.com/WanderleeDev/ngx-theme-stack/commit/9c83a9c15ab5ff0909b4938ab836bea8c12fa1aa)) + + +### Features + +* **lib:** enforce theme names as CSS identifiers, and generate the schematic copies ([5c9044e](https://github.com/WanderleeDev/ngx-theme-stack/commit/5c9044e678de0d169c497932cbab22643274a5c1)) + +# [3.10.0-next.0](https://github.com/WanderleeDev/ngx-theme-stack/compare/v3.9.4...v3.10.0-next.0) (2026-10-05) + + +### Bug Fixes + +* **ci:** stop unstable code from reaching the latest dist-tag ([16fb4c4](https://github.com/WanderleeDev/ngx-theme-stack/commit/16fb4c45c776d0a9c0d081287b47cff32d1444be)) +* **deps:** pin jsdom's undici and drop the duplicate Angular copy ([5622c07](https://github.com/WanderleeDev/ngx-theme-stack/commit/5622c07453a8ee623dc8f335e74aed85e1944e33)) +* **lib:** return the merged theme list from provideThemeStack ([585e945](https://github.com/WanderleeDev/ngx-theme-stack/commit/585e945374faefb68aa11962c8e4bb1596304452)) +* **schematics:** keep object-form themes intact in sync ([f349eab](https://github.com/WanderleeDev/ngx-theme-stack/commit/f349eabfebef5fe52caa12d5774c52f690d7729d)) + + +### Features + +* **lib:** per-theme color-scheme hints for custom themes ([5c631ae](https://github.com/WanderleeDev/ngx-theme-stack/commit/5c631ae9ff0c1a75e5fa7201fcf7c5af55d632be)) + ## [3.9.4](https://github.com/WanderleeDev/ngx-theme-stack/compare/v3.9.3...v3.9.4) (2026-08-03) diff --git a/eslint.config.js b/eslint.config.js index ba5ff55..021c599 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,26 @@ const tseslint = require("typescript-eslint"); const angular = require("angular-eslint"); module.exports = defineConfig([ + { + // Generated output only. `files: ['**/*.ts']` below matches the whole + // workspace, and ESLint 9+/flat config does not ignore `dist` or build + // caches by default, so any glob that reaches them reports errors on code + // nobody wrote: + // - dist/**.d.ts: Angular emits `#private` fields and `any`. + // - .agents/**: generated skill assets, whose example components use an + // `app-` selector (correct for a consumer app, but the workspace rule + // below demands the `lib` prefix). + // Scoped per-project lint already skips them; this keeps IDE/plugin/root + // globs honest too. + ignores: [ + 'dist/**', + '.agents/**', + '.angular/**', + 'out-tsc/**', + 'coverage/**', + 'vitest-coverage/**', + ], + }, { files: ["**/*.ts"], extends: [ diff --git a/odd/tasks/color-scheme-validation.md b/odd/tasks/color-scheme-validation.md new file mode 100644 index 0000000..3d9b32b --- /dev/null +++ b/odd/tasks/color-scheme-validation.md @@ -0,0 +1,81 @@ +# Consolidate `auto` and fix the unvalidated scheme prompt + +## Context + +The `color-scheme` feature (see `theme-color-scheme.md`) shipped with two +defects that were only visible while testing it by hand: + +1. **`'none'` was a duplicate.** It was the same code path as `'auto'` (both call + `removeProperty('color-scheme')`), so it offered two spellings for one + behaviour. Since `3.10.0-next.0` is a prerelease and `'none'` never reached a + stable release, this was the moment to drop it. + +2. **The scheme prompt validated nothing.** It stored whatever the user typed, and + `buildProvideCall` interpolated it verbatim into `app.config.ts`. Typing + `banana` therefore produced an app that **does not compile**: + + ``` + error TS2322: Type '"banana"' is not assignable to type 'NgColorScheme | undefined' + ``` + +## Scope (approved) + +Consolidate `auto`, make the prompt explain the options and fall back to `auto`, +and make `sync` do the same. Items 2 and 3 from the audit (validating theme +*names*, and a runtime whitelist) were explicitly deferred. + +### Changes + +| Area | Change | +| --- | --- | +| `lib/types.ts` | `NgColorScheme = 'light' \| 'dark' \| 'auto'`. `'none'` removed. | +| `lib/core/core-theme.service.ts` | Hint removal now keyed on `scheme === 'auto'` only. | +| `schematics/ng-add/constants.ts` | New `COLOR_SCHEMES`, `DEFAULT_COLOR_SCHEME`, `parseColorScheme()`. Duplicated from the lib on purpose (schematics are CommonJS and cannot import the ESM library), with the same KEEP-IN-SYNC warning used for `DEFAULT_THEMES`. | +| `schematics/ng-add/index.ts` | The prompt lists `light`/`dark`/`auto` and what each does, states that anything else falls back to `'auto'`, and warns when it does. The `[y/N]` gate before it was removed. | +| `schematics/utils/theme-input.ts` | Validates `scheme`. An invalid value is dropped (the entry behaves as scheme-less) and reported via `invalidSchemes`. | +| `schematics/sync/index.ts` | Warns for each dropped invalid scheme instead of silently rewriting it. | +| `schematics/utils/anti-flash-script.ts` | Generated JS checks `sc==='auto'` only. | +| docs | README table, skill template, regenerated `.agents/**`, re-synced demo. | + +### Prompt flow change (flagging it) + +The previous flow asked `Do your custom themes need explicit color-scheme hints? +[y/N]` and **defaulted to no**, so most users never saw the explanation. That +gate was removed: the explanation is printed, then each custom theme is asked +once, and Enter accepts `auto`. Only custom themes are asked; built-ins keep +their implicit hint. Reverting to a gate is a one-line change if the extra +prompt is unwanted. + +## Verification + +- `pnpm exec ng test ngx-theme-stack --watch=false` → **117 passed** (was 110; + +7 for `parseColorScheme` and the invalid-scheme paths). +- `tsc --noEmit` clean for `tsconfig.lib.json`, `tsconfig.schematics.json`, and + `tsconfig.spec.json`. +- `pnpm exec ng lint ngx-theme-stack` clean. +- `pnpm run build:lib`, `pnpm run build:demo` OK. +- `pnpm run ngx-theme-stack:sync` regenerated the demo's `index.html`; its + anti-flash script now reads `sc==='auto'` and contains no `'none'`. +- `node scripts/verify-publish-guards.mjs` still passes. + +## Incident during verification: the stale Angular cache + +Bisecting with `git stash` / `git checkout --` produced a misleading result: the +library suite crashed the vitest worker with `Worker exited unexpectedly` and +**no** `Caused by` line, and it crashed even with only part of the change +applied. The cause was a corrupted `.angular/cache` incremental state left by +repeatedly reverting and restoring the same files. Deleting `.angular/cache` and +re-running passed 117 tests with the full change set. + +**Rule for this repo**: when bisecting library changes, remove `.angular/cache` +between runs, or the incremental build state will lie to you. + +## Deferred + +- **Item 2** — validate theme *names* against `/^[a-zA-Z][a-zA-Z0-9_-]*$/`, the + rule the anti-flash script and the runtime already enforce. A name like + `2lucky` or `lucky theme` is accepted by the schematic but makes the anti-flash + fall back to the default theme on first paint (a flash), and produces + `
` (two classes) in the critters trick. +- **Item 3** — runtime whitelist, so a hand-written config bypassing the types + cannot push an arbitrary string into `setProperty('color-scheme', ...)`. diff --git a/odd/tasks/release-hardening.md b/odd/tasks/release-hardening.md new file mode 100644 index 0000000..920d33b --- /dev/null +++ b/odd/tasks/release-hardening.md @@ -0,0 +1,78 @@ +# Release Hardening — `latest` only from `main` + +## Context + +`publish.yml` published to the `latest` dist-tag based on a single heuristic: +whether the ref name contained a `-`. + +That heuristic is correct for prereleases, but it had two holes: + +1. **A stable tag created outside `main` still published to `latest`.** + Tag `v4.0.0` on a feature branch → no `-` → `npm publish` → `latest`. +2. **`workflow_dispatch` published to `latest` from any branch.** + `github.ref_name` for a manual run is the branch (`dev`), which has no `-`, + so the `else` branch ran and published whatever version was in the manifest. + +`latest` is what `npm install ngx-theme-stack` resolves for everyone, so +unstable code must never reach it. + +## Scope (option A — approved) + +`dev` keeps its name and remains the channel for `next` prereleases. + +Two guards added to `publish.yml`, before the install/build/publish steps: + +| Guard | Applies when | Rejects when | +| --- | --- | --- | +| A stable tag must live on `main` | `ref_type == 'tag'` and the tag has no `-` | the tagged commit is not an ancestor of `origin/main` | +| A manual publish must run from `main` | `event_name == 'workflow_dispatch'` | `ref_name != 'main'` | + +Plus `fetch-depth: 0` on checkout: the first guard asks +`git merge-base --is-ancestor "$GITHUB_SHA" origin/main`, and the default +`fetch-depth: 1` leaves no remote-tracking refs, so the check would fail closed +and reject **every** valid release. + +Prereleases keep working untouched: a tag containing `-` skips both guards and +is published under the `next` dist-tag. + +## Verification + +`scripts/verify-publish-guards.mjs` extracts the guard `run:` bodies **from the +workflow file** (not a copy) and executes them with real SHAs from this repo. +It also asserts the mirrored `if:` evaluators are byte-identical to the ones in +the workflow, so the mirror cannot drift silently. + +```bash +node scripts/verify-publish-guards.mjs +``` + +| Scenario | Expected | Result | +| --- | --- | --- | +| 1. stable tag ON main | pass | PASS | +| 2. stable tag OFF main | fail | PASS — `::error::Stable tag 'v4.0.0' is not an ancestor of main` | +| 3. prerelease tag on dev | guards skip | PASS | +| 4. manual dispatch from dev | fail | PASS — `::error::Manual publish is only allowed from 'main'` | +| 5. manual dispatch from main | pass | PASS | + +Also verified: the workflow parses as valid YAML and the step order is intact. + +## Decisions taken + +- **Branch name**: `dev` stays. A rename to `next` (for symmetry with the + dist-tag) was offered and declined. +- **No OIDC migration yet**: `NPM_TOKEN` remains. Tracked as follow-up B. +- **No LTS machinery yet**: the project has a single live major (3.x). Tracked + as follow-up C. + +## Follow-ups (not in this unit) + +- **B** — migrate to npm trusted publishing (OIDC), dropping the long-lived + `NPM_TOKEN` and getting provenance automatically. +- **C** — full channel model: `release/N.x` branches + `vN-lts` dist-tags. +- Wire `scripts/verify-publish-guards.mjs` into CI so guard edits are checked. +- Observation, unchanged: `deploy.yml` deploys the public demo only on a push to + `main`, never on a tag. Publishing `next` therefore leaves the demo untouched. +- Observation, unchanged: the GitHub Release step passes the **whole** + `CHANGELOG.md` as release notes for every release, not just the new section. +- Observation, unchanged: every prerelease currently maps to the `next` + dist-tag, so an `rc` tag would also land on `next`. diff --git a/odd/tasks/theme-color-scheme.md b/odd/tasks/theme-color-scheme.md new file mode 100644 index 0000000..4a11320 --- /dev/null +++ b/odd/tasks/theme-color-scheme.md @@ -0,0 +1,116 @@ +# Feature: Per-Theme Color Scheme Hints + +## Context + +Today, the library only applies a `color-scheme` hint for the built-in +`'light'` and `'dark'` themes. Custom themes (e.g. `sepia`, `ocean`) get no +hint at all — native UI widgets (scrollbars, inputs) don't match the active +custom theme. + +## Design + +Accept two input shapes in `provideThemeStack().themes` (mixable in one array): + +```ts +// Legacy: string-only (no hints on custom themes) +themes: ['sepia', 'ocean'] as const + +// New: object form with scheme hints +themes: [ + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, +] as const +``` + +- `scheme` is `'light' | 'dark' | 'auto' | 'none'` (default `'auto'` when + omitted). +- `'auto'` and `'none'` → no `color-scheme` is set on the root element. +- `'light'` / `'dark'` → hint is set while that theme is active. +- Built-in themes keep their implicit hint (`'light'` → light, + `'dark'` → dark, `'system'` → auto). + +## Scope + +- **Types**: `NgColorScheme`, `NgThemeOption`, `NgThemeInput`, + `ResolvedTheme`, `normalizeThemeInputs()`, `implicitScheme()`. +- **Config**: `provideThemeStack()` normalizes inputs → `resolvedThemes`. +- **CoreThemeService**: applies per-theme `color-scheme` hint from the + normalized map; exposes `themeNames: string[]`. +- **Public services** (`ThemeSelectService`, `ThemeCycleService`): expose + `themeNames` for template binding. +- **ng-add schematic**: prompt for scheme hints on custom themes; render + object-form entries in `provideThemeStack()`; pass `schemeMap` to the + anti-flash script. +- **Anti-flash script**: when `schemeMap` is provided, sets the hint + before Angular boots. +- **Sync schematic**: detects object-form themes + schemeMap in the existing + config and regenerates the provider call + anti-flash script. +- **Demo**: `sepia` theme with `scheme: 'light'` added. + +## Tasks + +- [x] T1: Types (`types.ts`) +- [x] T2: `CoreThemeService` scheme hint logic +- [x] T3: Public services expose `themeNames` +- [x] T4: ng-add schematic (prompt + object form rendering) +- [x] T5: Anti-flash script respects per-theme scheme +- [x] T6: Sync schematic detects + preserves object form +- [x] T7: Specs (types, config, core, services, anti-flash) +- [x] T8: Demo app updated (sepia theme + index.html + themes.css) +- [x] T9: Work-unit commits + verify build +- [x] T10: Repair the unit-test runner (jsdom/undici + duplicate Angular) +- [x] T11: Fix sync theme parsing for object form + +## Commits (branch `dev`) + +| Commit | Subject | +| --- | --- | +| `5c631ae` | feat(lib): per-theme color-scheme hints for custom themes | +| `15622ea` | docs(lib): document per-theme color-scheme hints | +| `5622c07` | fix(deps): pin jsdom's undici and drop the duplicate Angular copy | +| `585e945` | fix(lib): return the merged theme list from provideThemeStack | +| `f349eab` | fix(schematics): keep object-form themes intact in sync | + +## Verification + +- `pnpm test`: 110 passing in the library + 1 in the demo (was: worker crash, + zero tests executed). +- `pnpm run build:lib`: succeeds; dist exports `NgColorScheme`, + `ResolvedTheme` and `normalizeThemeInputs`, and the schematics carry + `schemeMap`. +- `pnpm run build:demo`: succeeds; the `prebuild` sync runs end to end and the + generated anti-flash script contains `s={"sunset":"dark","sepia":"light"}`. +- `pnpm exec ng lint `: clean for both projects. +- Sync is idempotent: a second run produces byte-identical files. +- `pnpm install --frozen-lockfile`: OK (CI parity). + +## Test runner incident (T10) — root cause + +Two independent defects had made the Angular unit-test runner unusable: + +1. **jsdom/undici mismatch.** `jsdom@28.1.0` requires `undici@^7.21.0` and + imports `undici/lib/handler/wrap-handler.js`, a subpath removed in undici 8. + The workspace hoisted `undici@8.11.2` (via `release-it`), so the vitest + worker died at startup with `MODULE_NOT_FOUND` and no test ever ran. + Fixed with a jsdom-scoped override: `jsdom@28.1.0>undici: '^7.21.0'`. + +2. **Duplicate Angular copy.** The library declares `@angular/core` and + `@angular/common` as `peerDependencies`, so pnpm auto-installed its own copy + (`@angular/core@22.0.4`) under `projects/ngx-theme-stack/node_modules` while + the workspace root had `22.2.1`. The builder initializes `TestBed` from the + root copy, so every spec failed with "Need to call + TestBed.initTestEnvironment() first". Fixed with `autoInstallPeers: false` + (camelCase — pnpm silently ignores the snake_case key) so the library + resolves Angular from the workspace root. + +## Notes + +- `tsc --noEmit` passes cleanly for `tsconfig.lib.json`, + `tsconfig.schematics.json`, and `tsconfig.spec.json`. +- Pre-existing and out of scope: `pnpm run lint` (all projects) also lints the + generated `dist/**/*.d.ts`, reporting 8 unrelated errors. Per-project lint is + clean. Also pre-existing: 3 `@angular-eslint/component-selector` errors in the + demo components (`theme-cycle.ts`, `theme-select.ts`, `theme-toggle.ts`), + identical on `main`. +- Brand-new finding worth a follow-up: `.gitignore` does not exclude `dist` + from lint, and the demo components use a non-project selector prefix. diff --git a/odd/tasks/theme-name-validation.md b/odd/tasks/theme-name-validation.md new file mode 100644 index 0000000..fec476d --- /dev/null +++ b/odd/tasks/theme-name-validation.md @@ -0,0 +1,116 @@ +# Theme name validation, and centralizing the values shared with the schematics + +## Context + +Two things surfaced while testing the `color-scheme` prerelease by hand. + +**1. Nothing validated a theme name.** `provideThemeStack()` only checked that a +name was not empty, while `types.ts` documented a pattern and the anti-flash +script enforced a *different* one. Declaring a bad name degraded silently: + +| Name | What actually happened | +| --- | --- | +| `2lucky` | `.2lucky` is invalid CSS (esbuild: `Unexpected ".2lucky"`), the browser drops the rule, and the theme silently loses its styles | +| `lucky theme` | `classList.add` throws `InvalidCharacterError` | +| `lucky.theme` | `.lucky.theme` matches two classes while the class token is the single string `lucky.theme`, so the styles never apply — with no error at all | + +**2. The shared values were duplicated with a "KEEP IN SYNC" comment**, which is a +promise rather than a mechanism. There were four copies: `DEFAULT_THEMES`, +`DEFAULTS`, `COLOR_SCHEMES`, and now the theme-name pattern. + +## Why the duplication existed + +Not laziness — a hard compile-unit boundary, measured with a probe: + +``` +error TS6059: File '.../src/lib/types.ts' is not under 'rootDir' '.../schematics'. + 'rootDir' is expected to contain all source files. +``` + +| Unit | Config | Output | +| --- | --- | --- | +| Schematics | `rootDir: "schematics"`, `module: commonjs` | `dist/.../schematics/` | +| Library | ng-packagr from `src/public-api.ts` | `dist/.../fesm2022/` (ESM) | + +One source file cannot belong to both. + +## What was done + +### The rule (unchanged policy: ASCII-only) + +``` +/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/ +``` + +Two fixes over the old `/^[a-zA-Z][a-zA-Z0-9_-]*$/`: + +- it accepts `_lucky` and `-lucky`, which are valid CSS and worked in + `classList`, but the old pattern rejected (causing an unnecessary flash); +- it is now **enforced**, so what used to be a silent runtime degradation is a + build error. + +Non-ASCII names (`sueño`, `café`) stay rejected **on purpose**: a narrower, +easy-to-reason-about subset. They are valid CSS, so this is a documented policy +choice, not a bug. The README and the skill say so explicitly. + +### Enforcement + +| Where | Behaviour | +| --- | --- | +| `provideThemeStack()` | throws `NgxThemeStackError` with the pattern and the reason | +| `ng-add` prompt | re-asks the whole list, printing why each name was rejected | +| `sync` | reports invalid names through `parseThemeInputArray().invalidNames` | +| anti-flash inline script | keeps validating — see below | + +### The inline guard was kept, not simplified + +An earlier proposal was to drop the anti-flash regex as redundant, since that +line also checks `v.indexOf(t) !== -1`. That was rejected, correctly: the value +comes from `localStorage`, which anyone with browser access can write, and the +declared list it is compared against is a plain, hand-editable array in +`index.html`. It runs before Angular and writes to `classList` and to an +attribute, so it validates on its own. Defence in depth, ~30 bytes. It now uses +the same pattern, so it no longer flashes `_lucky` / `-lucky`. + +### Centralization (replaces the "KEEP IN SYNC" comments) + +`scripts/generate-schematic-constants.mjs` copies the library-owned values into +`schematics/ng-add/generated-config.ts`. The library is the source of truth; the +schematic copy is generated. + +- Extraction uses the **TypeScript compiler API**, not regular expressions, so it + does not break on formatting, comments or trailing commas. +- The canonical values are exposed as **strings and string arrays** + (`NG_COLOR_SCHEMES`, `THEME_NAME_SOURCE`), and both sides build their `RegExp` + with `new RegExp(...)`. That removes the whole class of escaping bugs from the + inlined script. +- Wired to `prebuild`, so `pnpm run build` (the schematics build) regenerates + first and the copy cannot go stale. +- The generated file is **committed** (CI runs tests before `build:lib`, so a test + importing it must find it without a prior build) and carries an + AUTO-GENERATED header. + +One mechanism replaces four duplicated value sets. + +## Verification + +- **138 tests pass** (was 117). +- `tsc --noEmit` clean for `tsconfig.lib.json`, `tsconfig.schematics.json`, + `tsconfig.spec.json`. +- `ng lint ngx-theme-stack` clean; `build:lib` and `build:demo` OK; publish + guards 5/5. +- The generator is idempotent (`unchanged` on a second run). +- **The drift test was verified by breaking it**: the generated pattern was + tampered with by hand and the suite failed with 2 errors (`copies the theme + name pattern source` and `agrees with the library on the names it accepts`). + A drift test that never fails is worthless, so this was checked on purpose. +- The demo's `index.html` was re-synced and its inline script now reads + `if(!/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/.test(t)`, proving the + generator → constants → inline-script chain end to end. + +## Follow-ups + +- `item 3` from the earlier audit is now covered for names; a runtime whitelist + for `scheme` values in a hand-written config is still open. +- The `[Y/n]` prompt for the agent skill treats any input other than `n` as yes. + Inconsistent with the other prompts, and untouched. diff --git a/package.json b/package.json index a645387..ecce39c 100644 --- a/package.json +++ b/package.json @@ -74,5 +74,5 @@ "typescript-eslint": "8.62.1", "vitest": "^4.1.11" }, - "version": "3.9.4" + "version": "3.10.0-next.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5342be6..83d709d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,7 +1,7 @@ lockfileVersion: '9.0' settings: - autoInstallPeers: true + autoInstallPeers: false excludeLinksFromLockfile: false overrides: @@ -14,6 +14,7 @@ overrides: undici@<6.27.0: '>=6.27.0' undici@>=7.0.0 <7.28.0: '>=7.28.0' undici@>=8.0.0 <8.5.0: '>=8.5.0' + jsdom@28.1.0>undici: ^7.21.0 vite@<=7.3.4: '>=7.3.5' esbuild@<0.28.1: '>=0.28.1' '@babel/core@<=7.29.0': '>=7.29.6' @@ -64,7 +65,7 @@ importers: version: 22.5.0(chokidar@5.0.0)(eslint@10.12.0(jiti@2.7.0))(typescript@6.0.3) '@angular/build': specifier: ^22.2.1 - version: 22.2.1(0d531d1c0b4397fb59f5dda158b6a676) + version: 22.2.1(780bab2ee8da2e8b36c64fa126dcb6da) '@angular/cli': specifier: ^22.2.1 version: 22.2.1(@types/node@20.19.43)(chokidar@5.0.0) @@ -133,16 +134,10 @@ importers: version: 8.62.1(eslint@10.12.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: ^4.1.11 - version: 4.1.11(@types/node@20.19.43)(jsdom@28.1.0)(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1)) + version: 4.1.11(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@28.1.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1) projects/ngx-theme-stack: dependencies: - '@angular/common': - specifier: ^20.0.0 || ^21.0.0 || ^22.0.0 - version: 22.0.4(@angular/core@22.0.4(@angular/compiler@22.0.4)(rxjs@7.8.2))(rxjs@7.8.2) - '@angular/core': - specifier: ^20.0.0 || ^21.0.0 || ^22.0.0 - version: 22.0.4(@angular/compiler@22.0.4)(rxjs@7.8.2) tslib: specifier: ^2.3.0 version: 2.8.1 @@ -283,13 +278,6 @@ packages: engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} hasBin: true - '@angular/common@22.0.4': - resolution: {integrity: sha512-ibHINcvajoXfKfb4aGdLx8aOFkUOadAYF9/Yy4j2C1gQdLS6uXOCPFD9+W9zWf8OeMhL0H+i+5KeNlh6XbGaZw==} - engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - peerDependencies: - '@angular/core': 22.0.4 - rxjs: ^6.5.3 || ^7.4.0 - '@angular/common@22.2.1': resolution: {integrity: sha512-OqBH3EY4i8e3PvnYDugZGipSs1HWs2ZrqoClndKtFDS9mQULSSuLoxjG9nF1J/c+yUqhuyZlHjkXWS0n32pEfw==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} @@ -308,27 +296,10 @@ packages: typescript: optional: true - '@angular/compiler@22.0.4': - resolution: {integrity: sha512-PvbsPr3d0OHm90vgq1v+0oPP/oHNuCcZQAhhD3fhHlx3DTxf6nkjUqBulzdJ6d5QwDsy378pwf8+dpGquFzbGQ==} - engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/compiler@22.2.1': resolution: {integrity: sha512-KRSz0c/T5eEiFa+F1l9OppAebJj9pT8kU3ArBMlxR3v/vzhpCfuQsFVQ4xEFC7dEm3zZIdv9ASKAP6ocNC4D/Q==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - '@angular/core@22.0.4': - resolution: {integrity: sha512-QTGwZcnc6dBhGPMID8DvwRQdHMvpqV0vXDX8jSdmVDiWDsFUjbe9AUFTHEizDm+wnmxOrCQ0DHkgmHICuh0GpA==} - engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} - peerDependencies: - '@angular/compiler': 22.0.4 - rxjs: ^6.5.3 || ^7.4.0 - zone.js: ~0.15.0 || ~0.16.0 - peerDependenciesMeta: - '@angular/compiler': - optional: true - zone.js: - optional: true - '@angular/core@22.2.1': resolution: {integrity: sha512-a8WlYTRiaMPz1KvLE5xjeWrLyZUuQX8bh6Ak7zajUci9iWsTUQefschjSwP1WKlin3JhM3XjmsTEDyZvSLIfRg==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0} @@ -426,10 +397,6 @@ packages: resolution: {integrity: sha512-2/oWkgTbBYoqioCWAE4XJobOrzwxTDa5/XjDP3tJ1BhDr/owcd9qnXBp4xc3/2G5X4bvXM04nrxbRJxFcXAxFQ==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/core@8.0.6': - resolution: {integrity: sha512-5zwYt1V4ji3mXKKRIAkohSJ29WepxNwxQi+7y7P5AcVPh1dcxnZBU2cfxHnPwS+u9+lZnXPsv30TQaF2xg+Tfg==} - engines: {node: ^22.18.0 || >=24.11.0} - '@babel/generator@8.0.6': resolution: {integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==} engines: {node: ^22.18.0 || >=24.11.0} @@ -442,10 +409,6 @@ packages: resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@8.0.6': resolution: {integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==} engines: {node: ^22.18.0 || >=24.11.0} @@ -466,11 +429,6 @@ packages: resolution: {integrity: sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/parser@7.29.9': - resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@8.0.6': resolution: {integrity: sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg==} engines: {node: ^22.18.0 || >=24.11.0} @@ -484,10 +442,6 @@ packages: resolution: {integrity: sha512-ilYV+qLStGzSKLipJlETdvdtpK3rAuwfCmCoGKv15MU4EoeIuLkespSNoNLFGOPSciEHx9TLWtN5mv0fz5w5mQ==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/types@7.29.8': - resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} - engines: {node: '>=6.9.0'} - '@babel/types@8.0.6': resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} engines: {node: ^22.18.0 || >=24.11.0} @@ -1069,10 +1023,6 @@ packages: '@types/node': optional: true - '@istanbuljs/schema@0.1.6': - resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} - engines: {node: '>=8'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1790,144 +1740,6 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@rollup/rollup-android-arm-eabi@4.62.2': - resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.62.2': - resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.62.2': - resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.62.2': - resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.62.2': - resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.62.2': - resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.62.2': - resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.62.2': - resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.62.2': - resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.62.2': - resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.62.2': - resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.62.2': - resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.62.2': - resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.62.2': - resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} - cpu: [x64] - os: [win32] - '@schematics/angular@22.2.1': resolution: {integrity: sha512-L4Nl8D43znCzXwze9PtbQhG1GKlhWq9ip8Hz+e3LHgG3WpT5Tlr7aVsyHDqb9Qetuy0Mpd4tXE15STtGdA897w==} engines: {node: ^22.22.3 || ^24.15.0 || >=26.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -2425,11 +2237,6 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -3460,14 +3267,6 @@ packages: resolution: {integrity: sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==} engines: {node: ^18.17 || >=20.6.1} - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-instrument@6.0.3: - resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} - engines: {node: '>=10'} - jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -4286,11 +4085,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - rollup@4.62.2: - resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -4870,6 +4664,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.30.0: + resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} + engines: {node: '>=20.18.1'} + undici@8.11.2: resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} engines: {node: '>=22.19.0'} @@ -4990,7 +4788,6 @@ packages: '@vitest/ui': 4.1.11 happy-dom: '*' jsdom: '*' - vite: '>=7.3.5' peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -5183,7 +4980,7 @@ snapshots: '@angular-devkit/core@22.2.1(chokidar@5.0.0)': dependencies: ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) + ajv-formats: 3.0.1 jsonc-parser: 3.3.1 picomatch: 4.0.7 rxjs: 7.8.2 @@ -5264,7 +5061,7 @@ snapshots: eslint: 10.12.0(jiti@2.7.0) typescript: 6.0.3 - '@angular/build@22.2.1(0d531d1c0b4397fb59f5dda158b6a676)': + '@angular/build@22.2.1(780bab2ee8da2e8b36c64fa126dcb6da)': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2202.1(chokidar@5.0.0) @@ -5273,7 +5070,7 @@ snapshots: '@babel/core': 8.0.5 '@inquirer/confirm': 6.3.2(@types/node@20.19.43) '@parcel/watcher': 2.6.0 - '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1)) + '@vitejs/plugin-basic-ssl': 2.3.0(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1)) beasties: 0.5.4 browserslist: 4.29.3 chokidar: 5.0.0 @@ -5302,14 +5099,12 @@ snapshots: '@angular/platform-browser': 22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2)) '@angular/platform-server': 22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/compiler@22.2.1)(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(@angular/platform-browser@22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2)))(rxjs@7.8.2) '@angular/ssr': 22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(@angular/platform-server@22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/compiler@22.2.1)(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(@angular/platform-browser@22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2)))(rxjs@7.8.2))(@angular/router@22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(@angular/platform-browser@22.2.1(@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2)))(rxjs@7.8.2)) - istanbul-lib-instrument: 6.0.3 less: 4.9.1 lmdb: 3.5.6 ng-packagr: 22.2.4(@angular/compiler-cli@22.2.1(@angular/compiler@22.2.1)(typescript@6.0.3))(tailwindcss@4.3.3)(tslib@2.8.1)(typescript@6.0.3) postcss: 8.5.29 - rollup: 4.62.2 tailwindcss: 4.3.3 - vitest: 4.1.11(@types/node@20.19.43)(jsdom@28.1.0)(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1)) + vitest: 4.1.11(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@28.1.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1) transitivePeerDependencies: - '@types/node' - '@vitejs/devtools' @@ -5342,12 +5137,6 @@ snapshots: - '@types/node' - chokidar - '@angular/common@22.0.4(@angular/core@22.0.4(@angular/compiler@22.0.4)(rxjs@7.8.2))(rxjs@7.8.2)': - dependencies: - '@angular/core': 22.0.4(@angular/compiler@22.0.4)(rxjs@7.8.2) - rxjs: 7.8.2 - tslib: 2.8.1 - '@angular/common@22.2.1(@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: '@angular/core': 22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2) @@ -5368,22 +5157,10 @@ snapshots: optionalDependencies: typescript: 6.0.3 - '@angular/compiler@22.0.4': - dependencies: - tslib: 2.8.1 - optional: true - '@angular/compiler@22.2.1': dependencies: tslib: 2.8.1 - '@angular/core@22.0.4(@angular/compiler@22.0.4)(rxjs@7.8.2)': - dependencies: - rxjs: 7.8.2 - tslib: 2.8.1 - optionalDependencies: - '@angular/compiler': 22.0.4 - '@angular/core@22.2.1(@angular/compiler@22.2.1)(rxjs@7.8.2)': dependencies: rxjs: 7.8.2 @@ -5506,26 +5283,6 @@ snapshots: obug: 2.2.1 verkit: 0.3.2 - '@babel/core@8.0.6': - dependencies: - '@babel/code-frame': 8.0.6 - '@babel/generator': 8.0.6 - '@babel/helper-compilation-targets': 8.0.6 - '@babel/helpers': 8.0.5 - '@babel/parser': 8.0.6 - '@babel/template': 8.0.0 - '@babel/traverse': 8.0.6 - '@babel/types': 8.0.6 - '@types/gensync': 1.0.5 - convert-source-map: 2.0.0 - empathic: 2.1.0 - gensync: 1.0.0-beta.2 - import-meta-resolve: 4.2.0 - json5: 2.2.3 - obug: 2.2.1 - verkit: 0.3.2 - optional: true - '@babel/generator@8.0.6': dependencies: '@babel/parser': 8.0.6 @@ -5545,9 +5302,6 @@ snapshots: '@babel/helper-globals@8.0.6': {} - '@babel/helper-string-parser@7.29.7': - optional: true - '@babel/helper-string-parser@8.0.6': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -5561,11 +5315,6 @@ snapshots: '@babel/template': 8.0.0 '@babel/types': 8.0.6 - '@babel/parser@7.29.9': - dependencies: - '@babel/types': 7.29.8 - optional: true - '@babel/parser@8.0.6': dependencies: '@babel/types': 8.0.6 @@ -5586,12 +5335,6 @@ snapshots: '@babel/types': 8.0.6 obug: 2.2.1 - '@babel/types@7.29.8': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - optional: true - '@babel/types@8.0.6': dependencies: '@babel/helper-string-parser': 8.0.6 @@ -6038,9 +5781,6 @@ snapshots: optionalDependencies: '@types/node': 20.19.43 - '@istanbuljs/schema@0.1.6': - optional: true - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -6518,81 +6258,6 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/rollup-android-arm-eabi@4.62.2': - optional: true - - '@rollup/rollup-android-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-x64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-arm64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-x64@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-musl@4.62.2': - optional: true - - '@rollup/rollup-openbsd-x64@4.62.2': - optional: true - - '@rollup/rollup-openharmony-arm64@4.62.2': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.62.2': - optional: true - '@schematics/angular@22.2.1(chokidar@5.0.0)': dependencies: '@angular-devkit/core': 22.2.1(chokidar@5.0.0) @@ -6896,9 +6561,9 @@ snapshots: '@typescript-eslint/types': 8.71.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-basic-ssl@2.3.0(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1))': + '@vitejs/plugin-basic-ssl@2.3.0(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1))': dependencies: - vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1) + vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1) '@vitest/expect@4.1.11': dependencies: @@ -6909,13 +6574,13 @@ snapshots: chai: 6.3.0 tinyrainbow: 3.2.0 - '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1))': + '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1) + vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1) '@vitest/pretty-format@4.1.11': dependencies: @@ -7030,8 +6695,8 @@ snapshots: agent-base@9.0.0: {} - ajv-formats@3.0.1(ajv@8.20.0): - optionalDependencies: + ajv-formats@3.0.1: + dependencies: ajv: 8.20.0 ajv@6.15.0: @@ -8126,18 +7791,6 @@ snapshots: lodash.isstring: 4.0.1 lodash.uniqby: 4.7.0 - istanbul-lib-coverage@3.2.2: - optional: true - - istanbul-lib-instrument@6.0.3: - dependencies: - '@babel/core': 8.0.6 - '@babel/parser': 7.29.9 - '@istanbuljs/schema': 0.1.6 - istanbul-lib-coverage: 3.2.2 - semver: 7.8.5 - optional: true - jiti@2.7.0: {} js-tokens@10.0.0: {} @@ -8165,7 +7818,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.2 - undici: 8.11.2 + undici: 7.30.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -9026,38 +8679,6 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.8 '@rolldown/binding-win32-x64-msvc': 1.2.8 - rollup@4.62.2: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.2 - '@rollup/rollup-android-arm64': 4.62.2 - '@rollup/rollup-darwin-arm64': 4.62.2 - '@rollup/rollup-darwin-x64': 4.62.2 - '@rollup/rollup-freebsd-arm64': 4.62.2 - '@rollup/rollup-freebsd-x64': 4.62.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 - '@rollup/rollup-linux-arm-musleabihf': 4.62.2 - '@rollup/rollup-linux-arm64-gnu': 4.62.2 - '@rollup/rollup-linux-arm64-musl': 4.62.2 - '@rollup/rollup-linux-loong64-gnu': 4.62.2 - '@rollup/rollup-linux-loong64-musl': 4.62.2 - '@rollup/rollup-linux-ppc64-gnu': 4.62.2 - '@rollup/rollup-linux-ppc64-musl': 4.62.2 - '@rollup/rollup-linux-riscv64-gnu': 4.62.2 - '@rollup/rollup-linux-riscv64-musl': 4.62.2 - '@rollup/rollup-linux-s390x-gnu': 4.62.2 - '@rollup/rollup-linux-x64-gnu': 4.62.2 - '@rollup/rollup-linux-x64-musl': 4.62.2 - '@rollup/rollup-openbsd-x64': 4.62.2 - '@rollup/rollup-openharmony-arm64': 4.62.2 - '@rollup/rollup-win32-arm64-msvc': 4.62.2 - '@rollup/rollup-win32-ia32-msvc': 4.62.2 - '@rollup/rollup-win32-x64-gnu': 4.62.2 - '@rollup/rollup-win32-x64-msvc': 4.62.2 - fsevents: 2.3.3 - optional: true - router@2.2.0: dependencies: debug: 4.4.3 @@ -9571,6 +9192,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.30.0: {} + undici@8.11.2: {} unicorn-magic@0.1.0: {} @@ -9639,10 +9262,26 @@ snapshots: sass: 1.104.1 sass-embedded: 1.104.1 - vitest@4.1.11(@types/node@20.19.43)(jsdom@28.1.0)(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1)): + vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + less: 4.9.1 + sass: 1.105.1 + sass-embedded: 1.105.1 + + vitest@4.1.11(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@28.1.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1)) + '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -9659,13 +9298,24 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.2.0 - vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.104.1)(sass@1.104.1) + vite: 8.3.0(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(less@4.9.1)(sass-embedded@1.105.1)(sass@1.105.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.43 jsdom: 28.1.0 transitivePeerDependencies: + - '@vitejs/devtools' + - esbuild + - jiti + - less - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml w3c-xmlserializer@5.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 375473c..b22948e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,17 @@ packages: - projects/* +# Do not auto-install the library's peer dependencies. The root already owns a +# single @angular/* copy; auto-installing peers created a second, divergent +# @angular/core under projects/ngx-theme-stack/node_modules and gave the +# Angular unit-test runner two TestBed instances (every spec then failed with +# "Need to call TestBed.initTestEnvironment() first"). +autoInstallPeers: false + +# Resolve peer dependencies from the workspace root so the library and the +# apps share a single copy of @angular/*. +resolvePeersFromWorkspaceRoot: true + onlyBuiltDependencies: - '@parcel/watcher' - esbuild @@ -17,6 +28,10 @@ overrides: undici@<6.27.0: '>=6.27.0' undici@>=7.0.0 <7.28.0: '>=7.28.0' undici@>=8.0.0 <8.5.0: '>=8.5.0' + # jsdom@28 requires undici v7's subpath layout (lib/handler/wrap-handler.js), + # which undici v8 removed. Scope the override to jsdom so release-it keeps + # the undici 8.x it wants. + jsdom@28.1.0>undici: '^7.21.0' vite@<=7.3.4: '>=7.3.5' esbuild@<0.28.1: '>=0.28.1' '@babel/core@<=7.29.0': '>=7.29.6' diff --git a/projects/demo-ngx-theme-stack/src/app/app.config.ts b/projects/demo-ngx-theme-stack/src/app/app.config.ts index 8700eaa..d66828d 100644 --- a/projects/demo-ngx-theme-stack/src/app/app.config.ts +++ b/projects/demo-ngx-theme-stack/src/app/app.config.ts @@ -16,7 +16,7 @@ export const appConfig: ApplicationConfig = { ), provideClientHydration(withEventReplay()), provideThemeStack({ - themes: ['system', 'light', 'dark', 'sunset'] as const, + themes: ['system', 'light', 'dark', { name: 'sunset', scheme: 'dark' }, { name: 'sepia', scheme: 'light' }] as const, defaultTheme: 'sunset', storageKey: 'ngx-theme-stack', mode: 'class', diff --git a/projects/demo-ngx-theme-stack/src/index.html b/projects/demo-ngx-theme-stack/src/index.html index 0bf3377..884f5bf 100644 --- a/projects/demo-ngx-theme-stack/src/index.html +++ b/projects/demo-ngx-theme-stack/src/index.html @@ -5,7 +5,7 @@ - + ngx-theme-stack — Powerful Angular Theme Management @@ -50,6 +50,7 @@
+
diff --git a/projects/demo-ngx-theme-stack/src/themes.css b/projects/demo-ngx-theme-stack/src/themes.css index 3ca75bb..232a52d 100644 --- a/projects/demo-ngx-theme-stack/src/themes.css +++ b/projects/demo-ngx-theme-stack/src/themes.css @@ -88,3 +88,31 @@ --btn-shadow: rgba(255, 165, 0, 0.4); --info-icon: var(--color-white); } + +.sepia { + --bg-dir: to right; + --bg-from: #f5f0e1; + --bg-via: #e8dcc8; + --bg-to: #d4c5a0; + + --sun-transform: translateY(0); + --sun-opacity: 0.6; + --moon-transform: translateY(10rem); + --moon-opacity: 0; + --stars-opacity: 0; + --clouds-opacity: 0.3; + + --card-bg: rgba(139, 109, 68, 0.08); + --card-border: rgba(139, 109, 68, 0.2); + --divider: rgba(139, 109, 68, 0.1); + --tag-bg: rgba(139, 109, 68, 0.1); + --tag-text: #8b6d44; + --text-main: #3d2e1a; + --text-muted: #7a6a52; + --nav-bg: rgba(245, 240, 225, 0.7); + --nav-active: rgba(139, 109, 68, 0.15); + --btn-bg: #3d2e1a; + --btn-text: #f5f0e1; + --btn-shadow: rgba(61, 46, 26, 0.3); + --info-icon: #8b6d44; +} diff --git a/projects/ngx-theme-stack/README.md b/projects/ngx-theme-stack/README.md index 7044506..cd4ba90 100644 --- a/projects/ngx-theme-stack/README.md +++ b/projects/ngx-theme-stack/README.md @@ -27,6 +27,7 @@ Built for performance, signal-driven reactivity, and SSR zero-flash support. - **🧱 Angular Signals:** Built natively with reactive signals for maximum performance. - **🌍 SSR & Hydration Ready:** Guaranteed zero flicker or layout shift on startup. - **🔄 Extensible & Custom Themes:** Seamlessly add sepia, sunset, or custom color palettes. +- **🎛️ Per-Theme Color Scheme Hints:** Declara el `color-scheme` para cada custom theme y deja que los widgets nativos del browser (scrollbars, inputs) coincidan con el tema activo. - **🤖 AI Code Assistants Integration:** Work with AI coding assistants using the generated `SKILL.md` standard. --- @@ -94,6 +95,73 @@ export class ThemeToggle { --- +## 🎛️ Per-Theme Color Scheme Hints + +When you use custom themes, the library can also apply a `color-scheme` hint to the root element so that native UI widgets (scrollbars, form controls) match the active theme. + +```typescript +// Legacy: string form — no hint on custom themes +provideThemeStack({ + themes: ['sepia', 'ocean'] as const, +}) + +// New: object form — explicit hint per custom theme +provideThemeStack({ + themes: [ + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ] as const, +}) +``` + +Both shapes can be mixed in the same array: + +```typescript +provideThemeStack({ + themes: ['light', 'dark', 'system', { name: 'forest' }] as const, + // 'light' and 'dark' keep their implicit hint; 'forest' defaults to 'auto' (no hint) +}) +``` + +`scheme` values: + +| Value | Effect on `color-scheme` | +|---------|-------------------------------------| +| `'light'` | Sets `color-scheme: light` on the root element while the theme is active. | +| `'dark'` | Sets `color-scheme: dark`. | +| `'auto'` | No hint is set; the browser decides, usually from the OS. Also the default when `scheme` is omitted. | + +Any other value is invalid and falls back to `'auto'`. The hint is applied to `` in the runtime and in the anti-flash script (before Angular boots), and is regenerated by the `sync` schematic — which drops an invalid `scheme` and warns about it instead of writing it back. + +--- + +## 🏷️ Theme Names + +A theme name becomes a CSS class (`.name`) in class mode and is written into +`classList`, so it has to be usable as a CSS identifier. Names must match: + +``` +/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/ +``` + +| Name | Accepted | Why | +|------|----------|-----| +| `lucky`, `lucky-theme`, `lucky_theme`, `lucky2`, `_lucky`, `-lucky` | ✅ | usable as-is | +| `2lucky` | ❌ | `.2lucky` is invalid CSS, so the browser would drop the whole rule and the theme would silently lose its styles | +| `lucky theme` | ❌ | `classList.add` throws `InvalidCharacterError` on ASCII whitespace | +| `lucky.theme` | ❌ | `.lucky.theme` matches two classes, but the class token is the single string `lucky.theme`, so the styles would never apply — with no error at all | +| `sueño`, `café` | ❌ | deliberately ASCII-only. Valid CSS, but a narrower subset is easier to reason about; rejected on purpose, not by accident | + +`provideThemeStack()` throws `NgxThemeStackError` for a name that does not match, +so a bad name is a build error instead of a silent first-paint flash. The +`ng-add` prompt re-asks with the reason, and `sync` reports invalid names it finds +in an existing config. + +> The same pattern is inline in the anti-flash script, which validates the +> untrusted `localStorage` value before writing it to the DOM. + +--- + ## 📚 Advanced Guides & Documentation For details on more advanced topics, check out the official guides: diff --git a/projects/ngx-theme-stack/package.json b/projects/ngx-theme-stack/package.json index af1ce35..843ac99 100644 --- a/projects/ngx-theme-stack/package.json +++ b/projects/ngx-theme-stack/package.json @@ -1,6 +1,6 @@ { "name": "ngx-theme-stack", - "version": "3.9.4", + "version": "3.10.0-next.1", "description": "A stack of themes for Angular applications.", "keywords": [ "angular", @@ -22,6 +22,8 @@ }, "homepage": "https://ngx-theme-stack-docs.wanderlee.site/", "scripts": { + "generate:constants": "node ../../scripts/generate-schematic-constants.mjs", + "prebuild": "npm run generate:constants", "build": "tsc -p tsconfig.schematics.json", "postbuild": "copyfiles schematics/*/schema.json schematics/*/files/** schematics/collection.json ../../dist/ngx-theme-stack/ && node -e \"const f='../../dist/ngx-theme-stack/package.json',p=JSON.parse(require('fs').readFileSync(f,'utf8'));delete p.type;require('fs').writeFileSync(f,JSON.stringify(p,null,2)+'\\n');\"" }, diff --git a/projects/ngx-theme-stack/schematics/ng-add/anti-flash.ts b/projects/ngx-theme-stack/schematics/ng-add/anti-flash.ts index 7d6c0d4..e15c7f4 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/anti-flash.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/anti-flash.ts @@ -28,6 +28,7 @@ export function patchIndexHtml( defaultTheme: string; mode: string; themes: string[]; + schemeMap?: Record; strategy: 'critters' | 'blocking'; }, ): void { @@ -46,6 +47,7 @@ export function patchIndexHtml( defaultTheme: options.defaultTheme, mode: options.mode, themes: options.themes, + schemeMap: options.schemeMap, })}`; let updated = content; diff --git a/projects/ngx-theme-stack/schematics/ng-add/constants.ts b/projects/ngx-theme-stack/schematics/ng-add/constants.ts index f3644af..2246bfb 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/constants.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/constants.ts @@ -1,19 +1,86 @@ /** - * ⚠ ATTENTION: SHARED CONFIGURATION VALUES + * Values and helpers used by the schematics. * - * These values MUST match the library defaults in: - * - projects/ngx-theme-stack/src/lib/types.ts → DEFAULT_THEMES - * - projects/ngx-theme-stack/src/lib/config/index.ts → DEFAULT_NG_CONFIG + * The values that the library also owns live in ./generated-config.ts, which is + * produced by scripts/generate-schematic-constants.mjs. The schematics compile to + * CommonJS with `rootDir: "schematics"` and cannot import the ESM library, so the + * build copies those values instead of relying on a "KEEP IN SYNC" comment: * - * Schematics run in Node.js (CommonJS) and cannot import from the library (ESM), - * so the values are intentionally duplicated. Change all three at the same time. + * error TS6059: File '.../src/lib/types.ts' is not under 'rootDir' '.../schematics' + * + * Everything below is derived from that generated file, or is schematic-only. + */ +import { + DEFAULT_NG_CONFIG, + DEFAULT_THEMES as GENERATED_DEFAULT_THEMES, + NG_COLOR_SCHEMES, + THEME_NAME_SOURCE, +} from './generated-config'; + +export const DEFAULT_THEMES = GENERATED_DEFAULT_THEMES; + +/** + * Pattern a theme identifier must match to be usable as a CSS class. + * + * Why each part matters is documented on the library copy + * (src/lib/types.ts → THEME_NAME_SOURCE). In short: a leading digit breaks the + * class selector, an ASCII space throws in `classList.add`, and a CSS + * metacharacter turns `.lucky.theme` into a two-class selector that can never + * match the single class token `lucky.theme`. + * + * The anti-flash script inlines this same pattern, because it runs before + * anything else and writes an untrusted localStorage value into the DOM. It is + * defence in depth: the value is also checked against the declared list. + */ +export const THEME_NAME_PATTERN = new RegExp(THEME_NAME_SOURCE); + +/** Whether `name` is usable as a theme identifier. See {@link THEME_NAME_PATTERN}. */ +export function isValidThemeName(name: string): boolean { + return THEME_NAME_PATTERN.test(name); +} + +/** Explains why a name was rejected, so prompts and warnings can be actionable. */ +export function describeInvalidThemeName(name: string): string { + if (name.trim() === '') return 'it is empty'; + if (/^[0-9]/.test(name)) return 'it starts with a digit, which is not a valid CSS class'; + // Only ASCII whitespace throws in classList.add; anything else non-ASCII falls + // through to the allowed-set message below, which is the accurate reason. + if (/[\t\n\f\r ]/.test(name)) + return 'it contains whitespace, which throws in classList.add'; + if (/[.#>+~[\]:*(),]/.test(name)) + return 'it contains a CSS metacharacter, which changes what the selector matches'; + return 'it contains a character outside the allowed set'; +} + +/** + * Valid `color-scheme` hints for a custom theme. + * + * Derived from the library's NgColorScheme. That union lost its `'none'` value on + * purpose: it was the same code path as `'auto'` (both simply remove the inline + * hint), so it only added a way to express the same thing twice. + */ +export const COLOR_SCHEMES = NG_COLOR_SCHEMES; +export type ColorScheme = (typeof COLOR_SCHEMES)[number]; + +/** The hint used when none is declared, or when the declared one is invalid. */ +export const DEFAULT_COLOR_SCHEME: ColorScheme = 'auto'; + +/** + * Maps user input to a valid {@link ColorScheme}, or `undefined` when the input + * does not match one of the accepted values. + * + * Matching is exact after trimming, so `'Light'` is rejected rather than guessed + * at: the prompt lists the accepted values, and an unknown one must not end up + * in the generated config as a type error. */ -export const DEFAULT_THEMES = ['system', 'light', 'dark'] as const; +export function parseColorScheme(input: string): ColorScheme | undefined { + const value = input.trim(); + return (COLOR_SCHEMES as readonly string[]).includes(value) + ? (value as ColorScheme) + : undefined; +} export const DEFAULTS = { - defaultTheme: 'system', - storageKey: 'ngx-theme-stack', - mode: 'class', - strategy: 'critters', + ...DEFAULT_NG_CONFIG, themes: [...DEFAULT_THEMES], } as const; diff --git a/projects/ngx-theme-stack/schematics/ng-add/generated-config.ts b/projects/ngx-theme-stack/schematics/ng-add/generated-config.ts new file mode 100644 index 0000000..a9cc74e --- /dev/null +++ b/projects/ngx-theme-stack/schematics/ng-add/generated-config.ts @@ -0,0 +1,26 @@ +/** + * AUTO-GENERATED by scripts/generate-schematic-constants.mjs — DO NOT EDIT. + * + * Source of truth: + * - src/lib/types.ts (DEFAULT_THEMES, NG_COLOR_SCHEMES, THEME_NAME_SOURCE) + * - src/lib/config/index.ts (DEFAULT_NG_CONFIG) + * + * The schematics compile to CommonJS with `rootDir: "schematics"` and cannot + * import the ESM library, so the build copies these values here. Regenerate with + * `pnpm --filter ngx-theme-stack run generate:constants` (also runs on prebuild). + * + * src/schematics/generated-config.spec.ts asserts these still match the library. + */ + +export const DEFAULT_THEMES = ['system', 'light', 'dark'] as const; + +export const NG_COLOR_SCHEMES = ['light', 'dark', 'auto'] as const; + +export const THEME_NAME_SOURCE = '^-?[a-zA-Z_][a-zA-Z0-9_-]*$'; + +export const DEFAULT_NG_CONFIG = { + defaultTheme: 'system', + storageKey: 'ngx-theme-stack', + mode: 'class', + strategy: 'critters', +} as const; diff --git a/projects/ngx-theme-stack/schematics/ng-add/index.ts b/projects/ngx-theme-stack/schematics/ng-add/index.ts index b573b70..a59932e 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/index.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/index.ts @@ -1,9 +1,17 @@ import { chain, Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; import { patchIndexHtml } from './anti-flash'; import { patchAppConfig } from './app-config'; -import { DEFAULT_THEMES, DEFAULTS } from './constants'; +import { + DEFAULT_COLOR_SCHEME, + DEFAULT_THEMES, + DEFAULTS, + describeInvalidThemeName, + isValidThemeName, + parseColorScheme, + THEME_NAME_PATTERN, +} from './constants'; import { Schema } from './schema'; -import { ask, askList, assertAngularProject, buildProvideCall, createRl, detectPackageManager } from './utils'; +import { ask, askList, askYesNo, assertAngularProject, buildProvideCall, createRl, detectPackageManager } from './utils'; import { generateSkill } from '../skill/index'; interface SchematicConfig { @@ -11,6 +19,7 @@ interface SchematicConfig { storageKey: string; mode: string; themes: string[]; + schemeMap: Record; strategy: 'critters' | 'blocking'; provideCall: string; addSkill: boolean; @@ -22,14 +31,38 @@ async function collectCustomOptions(cliAddSkill?: boolean): Promise t.trim()) - .filter(Boolean) - : []; - const themes = [...DEFAULT_THEMES, ...customThemes]; + let customThemes: string[] = []; + for (;;) { + const rawThemes = await ask(rl, ' Custom themes (comma-separated, Enter to skip): '); + const candidates = rawThemes + ? rawThemes + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : []; + + // A theme name becomes a CSS class (`.name`) and is written into + // classList, so a bad one used to produce a silent first-paint flash, or a + // theme whose styles never matched. Reject it here, with the reason. + const invalid = candidates.filter((name) => !isValidThemeName(name)); + if (invalid.length > 0) { + for (const bad of invalid) { + process.stdout.write( + ` \u26a0 "${bad}" is not a valid theme name: ${describeInvalidThemeName(bad)}.\n`, + ); + } + process.stdout.write( + ` A theme name must match ${THEME_NAME_PATTERN.source}. Please try again.\n\n`, + ); + continue; + } + + customThemes = [...new Set(candidates)]; + break; + } + + const customOnly = customThemes.filter((t) => !DEFAULT_THEMES.includes(t as never)); + const themes = [...DEFAULT_THEMES, ...customOnly]; const defaultTheme = await askList(rl, 'Default theme:', themes, 0); @@ -52,14 +85,54 @@ async function collectCustomOptions(cliAddSkill?: boolean): Promise = {}; + if (customOnly.length > 0) { + process.stdout.write( + '\n A custom theme can declare the color-scheme used by native browser\n' + + ' widgets (scrollbars, inputs, form controls) while it is active:\n' + + " light -> color-scheme: light\n" + + " dark -> color-scheme: dark\n" + + ' auto -> no hint; the browser decides, usually from the OS\n' + + " Anything that is not one of those falls back to 'auto'.\n" + + " Built-in themes keep their own hint and are not asked about.\n\n", + ); + + for (const name of customOnly) { + const rawScheme = await ask( + rl, + ` color-scheme for "${name}" (light|dark|auto) [auto]: `, + ); + + const parsed = rawScheme === '' ? DEFAULT_COLOR_SCHEME : parseColorScheme(rawScheme); + + if (parsed === undefined) { + process.stdout.write( + ` \u26a0 "${rawScheme}" is not a valid color-scheme \u2014 falling back to '${DEFAULT_COLOR_SCHEME}'.\n`, + ); + continue; + } + + // 'auto' is the default behaviour of a scheme-less entry, so it is not + // written out: the theme stays a plain string and the output stays clean. + if (parsed !== DEFAULT_COLOR_SCHEME) { + schemeMap[name] = parsed; + } + } } - const provideCall = buildProvideCall(defaultTheme, storageKey, mode, themes, strategy); + const provideCall = buildProvideCall( + defaultTheme, storageKey, mode, themes, strategy, schemeMap, + ); process.stdout.write('\n'); - return { defaultTheme, storageKey, mode, themes, strategy, provideCall, addSkill }; + return { defaultTheme, storageKey, mode, themes, schemeMap, strategy, provideCall, addSkill }; } finally { rl.close(); } @@ -101,6 +174,7 @@ export function ngAdd(options: Schema): Rule { storageKey, mode, themes, + schemeMap: {}, strategy: strategy as 'critters' | 'blocking', provideCall: buildProvideCall(defaultTheme, storageKey, mode, themes, strategy), addSkill: options.addSkill ?? false, @@ -238,6 +312,7 @@ export function ngAdd(options: Schema): Rule { defaultTheme: config.defaultTheme, mode: config.mode, themes: config.themes, + schemeMap: config.schemeMap, strategy: config.strategy, }); changeset.push(' \u001b[33mM\u001b[0m index.html (injected anti-flash)'); diff --git a/projects/ngx-theme-stack/schematics/ng-add/utils.ts b/projects/ngx-theme-stack/schematics/ng-add/utils.ts index a64f710..d511538 100644 --- a/projects/ngx-theme-stack/schematics/ng-add/utils.ts +++ b/projects/ngx-theme-stack/schematics/ng-add/utils.ts @@ -15,6 +15,43 @@ export function ask(rl: readline.Interface, question: string): Promise { return new Promise((resolve) => rl.question(question, (a) => resolve(a.trim()))); } +/** + * Parses a yes/no answer. + * + * Returns `undefined` for anything unrecognised, so callers can re-ask instead of + * guessing. Previously the agent-skill prompt used `input !== 'n'`, which treated + * a typo like `asdf` as "yes". + * + * An empty answer takes the prompt's default, which is what the `[Y/n]` or + * `[y/N]` suffix communicates. + */ +export function parseYesNo(input: string, defaultYes: boolean): boolean | undefined { + const value = input.trim().toLowerCase(); + if (value === '') return defaultYes; + if (value === 'y' || value === 'yes') return true; + if (value === 'n' || value === 'no') return false; + return undefined; +} + +/** + * Asks a yes/no question, re-asking until the answer is recognised. + * + * @param question Prompt text. Include the default, e.g. `"... [Y/n]: "`. + * @param defaultYes Whether an empty answer means yes. Defaults to `true`. + */ +export async function askYesNo( + rl: readline.Interface, + question: string, + defaultYes = true, +): Promise { + for (;;) { + const answer = await ask(rl, question); + const parsed = parseYesNo(answer, defaultYes); + if (parsed !== undefined) return parsed; + process.stdout.write(` \u26a0 Please answer "y" or "n".\n`); + } +} + /** * Displays a numbered list of items to the user and returns the selected item. * If the input is invalid or ignored, the default index item is returned. @@ -38,8 +75,16 @@ export function buildProvideCall( mode: string, themes: string[], strategy: string, + schemeMap?: Record, ): string { - const themesArr = themes.map((t) => `'${t}'`).join(', '); + const entries = themes.map((t) => { + const scheme = schemeMap?.[t]; + if (scheme) { + return `{ name: '${t}', scheme: '${scheme}' }`; + } + return `'${t}'`; + }); + const themesArr = entries.join(', '); return [ 'provideThemeStack({', ` themes: [${themesArr}] as const,`, diff --git a/projects/ngx-theme-stack/schematics/skill/index.ts b/projects/ngx-theme-stack/schematics/skill/index.ts index 7329971..9a03d30 100644 --- a/projects/ngx-theme-stack/schematics/skill/index.ts +++ b/projects/ngx-theme-stack/schematics/skill/index.ts @@ -11,7 +11,7 @@ description: Signal-based theme manager for Angular 20+. Use this skill to confi compatibility: Angular 20+ with TypeScript. Optional Tailwind CSS v4. metadata: author: WanderleeDev - version: '1.2.2' + version: '1.3.0' --- # ngx-theme-stack @@ -25,16 +25,19 @@ Headless, signal-based theme manager for Angular 20+. - **Cycle** (\`ThemeCycleService\`) - Rotate through all themes. - **Select** (\`ThemeSelectService\`) - Full picker dropdown/radio selection. - **Exception**: If the user explicitly mentions which switcher type they want in their query, skip the question and implement it directly. -- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. \`sunset\`, colors, or CSS variables). +- **Custom Themes Inquiry**: Ask if they want custom themes (e.g. \`sunset\`, colors, or CSS variables). Suggested names must match \`/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/\` (ASCII, no leading digit, no whitespace, no CSS metacharacter). For each custom theme, also ask whether native browser widgets (scrollbars, inputs) should read as \`light\` or \`dark\`, then declare it with the object form in \`themes\`: \`{ name: 'sepia', scheme: 'light' }\`. Answering \`auto\`, leaving it blank, or anything unrecognised means no hint is declared, which leaves scrollbars and form controls on the OS colour scheme. - **DO NOT** generate code or configs until the user responds to these questions. ## Constraints & Rules - Call \`provideThemeStack()\` once in root \`app.config.ts\`. Custom themes merge with defaults. +- \`themes\` accepts plain names and \`{ name, scheme }\` objects, mixable in one array. \`scheme\` is \`'light' | 'dark' | 'auto'\` and only controls the CSS \`color-scheme\` hint for native widgets; it does not select a theme. Any other value is invalid and behaves as \`'auto'\`. Built-in names keep their implicit hint. +- **Theme names must match \`/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/\`** (ASCII only). A name becomes a CSS class and is written into \`classList\`, so a leading digit makes \`.2lucky\` invalid CSS (the browser drops the rule and the theme loses its styles), ASCII whitespace throws \`InvalidCharacterError\` in \`classList.add\`, and a CSS metacharacter turns \`.lucky.theme\` into a two-class selector that can never match the single class token \`lucky.theme\` — silently. \`provideThemeStack()\` throws \`NgxThemeStackError\` for a bad name; pick a name in that shape before writing config. - **Theme Synchronization**: Syncs theme configuration in \`app.config.ts\` with \`index.html\` assets. - **Manual execution**: Run \`pnpm run ngx-theme-stack:sync\` (or \`npm run ngx-theme-stack:sync\` / \`yarn run ngx-theme-stack:sync\`). - **Auto-Sync**: Runs automatically before serving or building via \`"prestart"\` and \`"prebuild"\` hooks in \`package.json\`. - **When to sync**: Run after adding/removing themes, renaming themes, changing configuration settings (storageKey, mode, strategy), or manually editing index.html. + - **What it reports**: an invalid theme name, or a \`scheme\` outside \`'light' | 'dark' | 'auto'\`, is dropped from the regenerated config and reported as a warning. It never rewrites silently, and it never emits a value the compiler would reject. - **Debugging**: If a theme reverts to default/system on reload, check if the theme identifier is missing in the valid themes array (\`v\`) in \`index.html\`. If missing, run synchronization. - \`isDark()\` / \`isLight()\` return false for custom themes (use \`resolvedTheme()\`). - \`selectedTheme()\` can be \`'system'\`; \`resolvedTheme()\` is always the concrete theme applied to the DOM (never \`'system'\`). @@ -188,10 +191,74 @@ provideThemeStack({ }) \`\`\` +### Themes: string form and object form + +Each entry in \`themes\` is either a plain name or an object that adds a +\`color-scheme\` hint. Both shapes can be mixed in one array. The hint only +affects native browser widgets (scrollbars, inputs, form controls); it does +not pick a theme for you. + +\`\`\`typescript +provideThemeStack({ + themes: [ + 'system', + 'light', + 'dark', + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ] as const, +}) +\`\`\` + +| \`scheme\` | Effect on \`color-scheme\` | +| --- | --- | +| \`'light'\` | Sets \`color-scheme: light\` while the theme is active. | +| \`'dark'\` | Sets \`color-scheme: dark\` while the theme is active. | +| \`'auto'\` (default when omitted) | No hint; the browser decides. | + +Any other value is invalid and falls back to \`'auto'\`. The \`ng-add\` prompt only +accepts \`light\`, \`dark\`, or \`auto\`; an unknown answer falls back to \`'auto'\`. +The \`sync\` schematic drops an invalid \`scheme\` from an existing config and warns. + +Built-in names keep their implicit hint (\`light\` → light, \`dark\` → dark, +\`system\` → auto). Prefer the object form for any custom theme whose native +widgets must match the theme, otherwise scrollbars keep the OS colour. + +\`\`\`typescript +// Wrong: sepia's scrollbars stay OS-coloured +provideThemeStack({ themes: ['sepia'] as const }) + +// Right: declares the intended native colour scheme +provideThemeStack({ themes: [{ name: 'sepia', scheme: 'light' }] as const }) +\`\`\` + +### Theme names + +A theme name becomes a CSS class (\`.name\`) and is written into \`classList\`, so it +must be usable as a CSS identifier. It has to match: + +\`\`\` +/^-?[a-zA-Z_][a-zA-Z0-9_-]*$/ +\`\`\` + +| Name | Accepted | Why | +| --- | --- | --- | +| \`lucky\`, \`lucky-theme\`, \`lucky_theme\`, \`lucky2\`, \`_lucky\`, \`-lucky\` | yes | usable as-is | +| \`2lucky\` | no | \`.2lucky\` is invalid CSS, so the browser drops the rule and the theme silently loses its styles | +| \`lucky theme\` | no | \`classList.add\` throws \`InvalidCharacterError\` on ASCII whitespace | +| \`lucky.theme\` | no | \`.lucky.theme\` matches two classes while the class token is the single string \`lucky.theme\`, so the styles never apply, with no error | +| \`sueño\`, \`café\` | no | deliberately ASCII-only, a narrower subset than CSS allows | + **Throws \`NgxThemeStackError\` when:** - A theme entry is empty, or \`defaultTheme\` is not in themes, or \`storageKey\` is empty. +- **A theme name does not match the pattern above.** This is a build error on purpose: the failure it prevents (a first-paint flash, or a theme whose styles never match) used to be silent. - \`setTheme()\` is called with a theme not in the configured themes list. +> After changing \`themes\` (adding, removing, renaming, or adding a \`scheme\`), +> run the sync schematic so the anti-flash script in \`index.html\` picks up the +> new names and hints. Otherwise a custom theme can silently fall back to the +> default on first paint. + --- ## CoreThemeService @@ -207,6 +274,8 @@ Foundation service managing state (signals), persistence, system preference, and | \`isDark()\` / \`isLight()\` | \`Signal\` | \`true\` for dark/light (returns \`false\` for custom themes). | | \`isSystem()\` / \`isHydrated()\` | \`Signal\` | System choice active / SSR hydration finished. | | \`availableThemes\` | \`string[]\` | All configured themes including built-ins. | +| \`themeNames\` | \`string[]\` | Same list, plain names only — bind directly in templates. | +| \`resolvedThemes\` | \`{ name, scheme }[]\` | Normalized themes with their color-scheme hint. | | \`setTheme(theme)\` | \`(theme: string) => void\` | Validates, persists, and applies the theme to DOM. | --- diff --git a/projects/ngx-theme-stack/schematics/sync/index.ts b/projects/ngx-theme-stack/schematics/sync/index.ts index 6bfffb4..af0a4c3 100644 --- a/projects/ngx-theme-stack/schematics/sync/index.ts +++ b/projects/ngx-theme-stack/schematics/sync/index.ts @@ -1,9 +1,10 @@ import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; import { patchAppConfig } from '../ng-add/app-config'; import { assertAngularProject, buildProvideCall } from '../ng-add/utils'; -import { DEFAULTS } from '../ng-add/constants'; +import { COLOR_SCHEMES, DEFAULT_COLOR_SCHEME, DEFAULTS, describeInvalidThemeName, THEME_NAME_PATTERN } from '../ng-add/constants'; import { Schema } from './schema'; import { buildAntiFlashScript } from '../utils/anti-flash-script'; +import { parseThemeInputArray } from '../utils/theme-input'; // ── Regex patterns ──────────────────────────────────────────────────────────── @@ -61,6 +62,7 @@ interface ExtractedConfig { mode: string; strategy?: string; themes: string[]; + schemeMap?: Record; } // ── Config extraction ───────────────────────────────────────────────────────── @@ -100,14 +102,32 @@ function extractConfig( const strategy = OPTION_STRATEGY_RE.exec(opts)?.[1] ?? undefined; const themesRaw = OPTION_THEMES_RE.exec(opts)?.[1] ?? ''; - const themes: string[] = themesRaw - ? themesRaw - .split(',') - .map((t) => t.trim().replace(/^['"]|['"]$/g, '')) - .filter(Boolean) - : [...DEFAULTS.themes]; - - return { mode, strategy, storageKey, defaultTheme, themes }; + const { themes, schemeMap, invalidSchemes, invalidNames } = parseThemeInputArray(themesRaw); + if (themes.length === 0) themes.push(...DEFAULTS.themes); + + for (const { name, scheme } of invalidSchemes) { + context.logger.warn( + `⚠ "${name}" declares scheme '${scheme}', which is not one of ` + + `[${COLOR_SCHEMES.join(', ')}]. Dropping the hint so the theme uses '${DEFAULT_COLOR_SCHEME}'.`, + ); + } + + for (const name of invalidNames) { + context.logger.warn( + `⚠ "${name}" is not a valid theme name: ${describeInvalidThemeName(name)}. ` + + `A theme name becomes a CSS class, so it must match ${THEME_NAME_PATTERN.source}. ` + + 'provideThemeStack() will reject it at build time; rename it before continuing.', + ); + } + + return { + mode, + strategy, + storageKey, + defaultTheme, + themes, + schemeMap: Object.keys(schemeMap).length ? schemeMap : undefined, + }; } // Fallback to defaults if no config file found @@ -116,6 +136,7 @@ function extractConfig( storageKey: DEFAULTS.storageKey, defaultTheme: DEFAULTS.defaultTheme, themes: [...DEFAULTS.themes], + schemeMap: undefined, }; } @@ -226,7 +247,10 @@ export function sync(options: Schema): Rule { const changeset: string[] = []; // Ensure provideThemeStack exists and is up to date in app.config.ts - const provideCall = buildProvideCall(config.defaultTheme, config.storageKey, config.mode, config.themes, strategy); + const provideCall = buildProvideCall( + config.defaultTheme, config.storageKey, config.mode, + config.themes, strategy, config.schemeMap, + ); await patchAppConfig(tree, context, sourceRoot, provideCall, projectName); context.logger.info(''); @@ -245,7 +269,13 @@ export function sync(options: Schema): Rule { let changed = false; if (content.includes('ngx-theme-stack anti-flash')) { - const newScriptBlock = `\n `; + const newScriptBlock = `\n `; if (SCRIPT_BLOCK_RE.test(content)) { const updatedScript = content.replace(SCRIPT_BLOCK_RE, newScriptBlock); if (updatedScript !== content) { diff --git a/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts b/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts index 2d4a90a..aab6c7d 100644 --- a/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts +++ b/projects/ngx-theme-stack/schematics/utils/anti-flash-script.ts @@ -1,8 +1,11 @@ +import { THEME_NAME_PATTERN } from '../ng-add/constants'; + export interface AntiFlashScriptOptions { storageKey: string; defaultTheme: string; mode: string; themes: string[]; + schemeMap?: Record; } /** @@ -11,9 +14,17 @@ export interface AntiFlashScriptOptions { * * This is the single source of truth for the anti-flash script logic, shared * between ng-add and sync schematics. + * + * When `schemeMap` is provided (theme → color-scheme hint), the script also + * sets the `color-scheme` hint on the root element so that native UI widgets + * (scrollbars, inputs) match the theme even before Angular boots. */ export function buildAntiFlashScript(options: AntiFlashScriptOptions): string { - const { storageKey, defaultTheme, mode, themes } = options; + const { storageKey, defaultTheme, mode, themes, schemeMap } = options; + + const schemeLookup = schemeMap + ? `s=${JSON.stringify(schemeMap)},` + : ''; return ( `(function(){try{` + @@ -21,13 +32,21 @@ export function buildAntiFlashScript(options: AntiFlashScriptOptions): string { `d=${JSON.stringify(defaultTheme)},` + `m=${JSON.stringify(mode)},` + `v=${JSON.stringify(themes)},` + + schemeLookup + `t=localStorage.getItem(k)||d,` + `e=document.documentElement;` + - `if(!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(t)||v.indexOf(t)===-1)t=d;` + + // Same rule as the library and the schematics. This guard is NOT redundant: + // the value comes from localStorage, which the user (or anything with access + // to the browser) can write, and the declared list it is compared against is + // a plain, hand-editable array in index.html. It runs before Angular and + // writes to classList and to an attribute, so it validates on its own. + `if(!/${THEME_NAME_PATTERN.source}/.test(t)||v.indexOf(t)===-1)t=d;` + `if(t==='system')t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';` + `if(m==='class'||m==='both')e.classList.add(t);` + `if(m==='attribute'||m==='both')e.setAttribute('data-theme',t);` + - `if(t==='dark'||t==='light')e.style.setProperty('color-scheme',t);` + + (schemeMap + ? `var sc=(s&&s[t])||'';if(sc==='auto'){e.style.removeProperty('color-scheme');}else{e.style.setProperty('color-scheme',sc);}` + : `if(t==='dark'||t==='light')e.style.setProperty('color-scheme',t);`) + `}catch(x){}})();` ); } diff --git a/projects/ngx-theme-stack/schematics/utils/theme-input.ts b/projects/ngx-theme-stack/schematics/utils/theme-input.ts new file mode 100644 index 0000000..1b65834 --- /dev/null +++ b/projects/ngx-theme-stack/schematics/utils/theme-input.ts @@ -0,0 +1,124 @@ +/** + * Parsing helpers for the `themes` option of `provideThemeStack()`. + * + * Themes can be written in two shapes, mixable in one array: + * themes: ['system', 'light', { name: 'sepia', scheme: 'light' }] as const + * + * A naive `split(',')` breaks the object entries, so the array body is split + * on top-level commas only (ignoring commas nested in braces, brackets, + * parentheses, or quotes). + */ + +import { isValidThemeName, parseColorScheme } from '../ng-add/constants'; + +/** One parsed theme entry: its name and optional color-scheme hint. */ +export interface ParsedThemeInput { + name: string; + /** A valid scheme only. An invalid one is dropped and reported separately. */ + scheme?: string; +} + +/** A scheme found in the config that is not one of the accepted values. */ +export interface InvalidScheme { + name: string; + scheme: string; +} + +/** + * Splits the body of a `themes: [...]` array into top-level entries. + * + * Commas inside `{}`, `[]`, `()`, or string literals do not split. + */ +export function splitTopLevelEntries(body: string): string[] { + const entries: string[] = []; + let depth = 0; + let current = ''; + let quote: string | null = null; + + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + + if (quote) { + current += ch; + if (ch === quote && body[i - 1] !== '\\') quote = null; + continue; + } + + if (ch === '"' || ch === "'") { + quote = ch; + current += ch; + continue; + } + + if (ch === '{' || ch === '[' || ch === '(') depth++; + else if (ch === '}' || ch === ']' || ch === ')') depth--; + + if (ch === ',' && depth === 0) { + entries.push(current); + current = ''; + continue; + } + + current += ch; + } + + if (current.trim()) entries.push(current); + return entries.map((e) => e.trim()).filter(Boolean); +} + +/** + * Parses one theme entry, either `'name'` or `{ name: 'n', scheme: 's' }`. + * Property order inside the object is not significant. + * + * A scheme outside the accepted values is dropped, so the entry behaves as if + * no hint had been declared instead of being written back as a type error. The + * dropped value is reported through `droppedScheme` for the caller to warn about. + */ +export function parseThemeEntry( + entry: string, +): (ParsedThemeInput & { droppedScheme?: string }) | null { + if (entry.startsWith('{')) { + const name = /name\s*:\s*['"]([^'"]+)['"]/.exec(entry)?.[1]; + if (!name) return null; + + const rawScheme = /scheme\s*:\s*['"]([^'"]+)['"]/.exec(entry)?.[1]; + if (rawScheme === undefined) return { name }; + + const scheme = parseColorScheme(rawScheme); + return scheme === undefined + ? { name, droppedScheme: rawScheme } + : { name, scheme }; + } + const match = /^['"]([^'"]+)['"]$/.exec(entry); + return match ? { name: match[1] } : null; +} + +/** + * Parses the raw body of a `themes: [...]` array into ordered theme names and + * a `name -> scheme` map for the entries that declare a hint. + */ +export function parseThemeInputArray(body: string): { + themes: string[]; + schemeMap: Record; + invalidSchemes: InvalidScheme[]; + invalidNames: string[]; +} { + const themes: string[] = []; + const schemeMap: Record = {}; + const invalidSchemes: InvalidScheme[] = []; + const invalidNames: string[] = []; + + for (const entry of splitTopLevelEntries(body)) { + const parsed = parseThemeEntry(entry); + if (!parsed) continue; + themes.push(parsed.name); + if (!isValidThemeName(parsed.name)) invalidNames.push(parsed.name); + if (parsed.droppedScheme !== undefined) { + invalidSchemes.push({ name: parsed.name, scheme: parsed.droppedScheme }); + } else if (parsed.scheme) { + schemeMap[parsed.name] = parsed.scheme; + } + } + + return { themes, schemeMap, invalidSchemes, invalidNames }; +} diff --git a/projects/ngx-theme-stack/src/lib/config/index.spec.ts b/projects/ngx-theme-stack/src/lib/config/index.spec.ts index 927581f..f4dafcf 100644 --- a/projects/ngx-theme-stack/src/lib/config/index.spec.ts +++ b/projects/ngx-theme-stack/src/lib/config/index.spec.ts @@ -10,7 +10,8 @@ describe('provideThemeStack', () => { }); const config = TestBed.inject(NGX_THEME_STACK_CONFIG); - expect(config).toEqual(DEFAULT_NG_CONFIG); + expect(config.themes).toEqual(DEFAULT_NG_CONFIG.themes); + expect(config.resolvedThemes).toEqual(DEFAULT_NG_CONFIG.resolvedThemes); }); it('should merge custom themes with default themes', () => { @@ -26,6 +27,57 @@ describe('provideThemeStack', () => { // Default themes: ['system', 'light', 'dark'] // Merged: default themes + custom themes (avoiding duplicates) expect(config.themes).toEqual(['system', 'light', 'dark', 'sepia', 'ocean']); + // Custom themes get implicit 'auto' scheme + expect(config.resolvedThemes).toEqual([ + { name: 'system', scheme: 'auto' }, + { name: 'light', scheme: 'light' }, + { name: 'dark', scheme: 'dark' }, + { name: 'sepia', scheme: 'auto' }, + { name: 'ocean', scheme: 'auto' }, + ]); + }); + + it('should normalize object-form themes with explicit scheme', () => { + TestBed.configureTestingModule({ + providers: [ + provideThemeStack({ + themes: [ + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ], + }), + ], + }); + + const config = TestBed.inject(NGX_THEME_STACK_CONFIG); + expect(config.themes).toEqual(['system', 'light', 'dark', 'sepia', 'ocean']); + expect(config.resolvedThemes).toEqual([ + { name: 'system', scheme: 'auto' }, + { name: 'light', scheme: 'light' }, + { name: 'dark', scheme: 'dark' }, + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ]); + }); + + it('should mix string and object-form themes', () => { + TestBed.configureTestingModule({ + providers: [ + provideThemeStack({ + themes: ['forest', { name: 'sand', scheme: 'light' }], + }), + ], + }); + + const config = TestBed.inject(NGX_THEME_STACK_CONFIG); + expect(config.themes).toEqual(['system', 'light', 'dark', 'forest', 'sand']); + expect(config.resolvedThemes).toEqual([ + { name: 'system', scheme: 'auto' }, + { name: 'light', scheme: 'light' }, + { name: 'dark', scheme: 'dark' }, + { name: 'forest', scheme: 'auto' }, + { name: 'sand', scheme: 'light' }, + ]); }); it('should throw an error if a theme name is empty or whitespace', () => { @@ -42,6 +94,35 @@ describe('provideThemeStack', () => { ).toThrow('Theme cannot be empty or whitespace.'); }); + it('should throw an error if an object-form theme name is empty', () => { + expect(() => + provideThemeStack({ + themes: [{ name: ' ' }], + }) + ).toThrow('Theme cannot be empty or whitespace.'); + }); + + it('should throw an error if a theme name is not a usable CSS class', () => { + for (const name of ['2lucky', 'lucky theme', 'lucky.theme', 'lucky#theme', 'sueño']) { + expect(() => provideThemeStack({ themes: [name] })).toThrow(NgxThemeStackError); + expect(() => provideThemeStack({ themes: [name] })).toThrow( + /Invalid theme name.*must match/s, + ); + } + }); + + it('should accept names that are valid CSS classes', () => { + for (const name of ['lucky', 'lucky-theme', 'lucky_theme', 'lucky2', '_lucky', '-lucky']) { + expect(() => provideThemeStack({ themes: [name] })).not.toThrow(); + } + }); + + it('should throw for an invalid name in object form too', () => { + expect(() => provideThemeStack({ themes: [{ name: '2lucky', scheme: 'dark' }] })).toThrow( + NgxThemeStackError, + ); + }); + it('should throw an error if defaultTheme is not in the resolved themes list', () => { expect(() => provideThemeStack({ diff --git a/projects/ngx-theme-stack/src/lib/config/index.ts b/projects/ngx-theme-stack/src/lib/config/index.ts index 4a3c51c..84400ca 100644 --- a/projects/ngx-theme-stack/src/lib/config/index.ts +++ b/projects/ngx-theme-stack/src/lib/config/index.ts @@ -1,6 +1,16 @@ import { InjectionToken } from '@angular/core'; import { NgxThemeStackError } from '../errors'; -import { DEFAULT_THEMES, DefaultNgTheme, NgConfig } from '../types'; +import { + DEFAULT_THEMES, + DefaultNgTheme, + isValidThemeName, + NgConfig, + normalizeThemeInputs, + NgTheme, + NgThemeInput, + NgThemeOption, + THEME_NAME_PATTERN, +} from '../types'; /** * ⚠ ATTENTION: SHARED CONFIGURATION VALUES @@ -15,13 +25,14 @@ import { DEFAULT_THEMES, DefaultNgTheme, NgConfig } from '../types'; * schematics/ng-add/constants.ts → DEFAULTS + DEFAULT_THEMES */ -export const DEFAULT_NG_CONFIG = { +export const DEFAULT_NG_CONFIG: NgConfig = { defaultTheme: 'system', storageKey: 'ngx-theme-stack', mode: 'class', strategy: 'critters', - themes: [...DEFAULT_THEMES], -} satisfies NgConfig; + themes: [...DEFAULT_THEMES] as (NgTheme | NgThemeOption)[], + resolvedThemes: normalizeThemeInputs(DEFAULT_THEMES as unknown as NgThemeInput[]), +}; // The token uses NgConfig because Angular DI resolves types at runtime // and cannot carry generic parameters. Type-safety is enforced at the @@ -91,15 +102,46 @@ export const NGX_THEME_STACK_CONFIG = new InjectionToken>( export function provideThemeStack( config: Partial> = {}, ) { - config.themes?.forEach((t) => { - if (t.trim() === '') throw new NgxThemeStackError('Theme cannot be empty or whitespace.'); - }); + // Validate user-supplied themes. A name has to be a usable CSS class + // identifier: it is emitted as `.name` in class mode, written into + // `classList`, and checked by the anti-flash guard in index.html. Catching it + // here turns what used to be a silent first-paint flash, or a theme that + // never matched its styles, into a build error. + if (config.themes) { + for (const entry of config.themes as NgThemeInput[]) { + const name = typeof entry === 'string' ? entry : entry.name; + if (name.trim() === '') { + throw new NgxThemeStackError('Theme cannot be empty or whitespace.'); + } + if (!isValidThemeName(name)) { + throw new NgxThemeStackError( + `Invalid theme name: "${name}". A theme name must match ${THEME_NAME_PATTERN}. ` + + 'It becomes a CSS class, so it cannot start with a digit, contain ' + + 'whitespace, or contain a CSS metacharacter such as ".", "#" or ">".', + ); + } + } + } + + // Resolve built-in + user themes into a single list of names. + const builtinNames: string[] = [...DEFAULT_NG_CONFIG.themes].map((t) => + typeof t === 'string' ? t : t.name, + ); + const userNames: string[] = config.themes + ? (config.themes as NgThemeInput[]).map((t) => + typeof t === 'string' ? t.trim() : t.name.trim(), + ) + : []; + const themes: string[] = Array.from(new Set([...builtinNames, ...userNames])); - const themes = config.themes - ? Array.from(new Set([...DEFAULT_NG_CONFIG.themes, ...config.themes])) - : DEFAULT_NG_CONFIG.themes; + // Normalize to ResolvedTheme[] (with per-theme scheme hints). + const allInputs: NgThemeInput[] = [ + ...DEFAULT_NG_CONFIG.themes, + ...(config.themes ?? []), + ]; + const resolvedThemes = normalizeThemeInputs(allInputs); - if (config.defaultTheme && !(themes as string[]).includes(config.defaultTheme as string)) { + if (config.defaultTheme && !themes.includes(config.defaultTheme as string)) { throw new NgxThemeStackError( `"defaultTheme" must be one of the resolved themes: [${themes.join(', ')}].`, ); @@ -114,7 +156,10 @@ export function provideThemeStack( useValue: { ...DEFAULT_NG_CONFIG, ...config, - themes, + // `themes` stays the merged, deduplicated name list (backwards compatible); + // `resolvedThemes` carries the normalized per-theme scheme hints. + themes: themes as unknown as NgTheme[], + resolvedThemes, } as NgConfig, }; } diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.scheme.spec.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.scheme.spec.ts new file mode 100644 index 0000000..9072d57 --- /dev/null +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.scheme.spec.ts @@ -0,0 +1,121 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CoreThemeService } from './core-theme.service'; +import { NGX_THEME_STACK_CONFIG } from '../config'; +import { NgConfig, normalizeThemeInputs, NgThemeInput } from '../types'; + +function setup( + config: Partial = {}, + initialStore: Record = {}, + systemPrefersDark = false, + platformId = 'browser', +) { + let store: Record = { ...initialStore }; + + vi.stubGlobal('localStorage', { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + clear: () => { + store = {}; + }, + }); + + const matchMediaMock = { + matches: systemPrefersDark, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(matchMediaMock)); + + const themes = (config.themes as NgThemeInput[] | undefined) ?? ['light', 'dark', 'system']; + const fullConfig: NgConfig = { + defaultTheme: 'system', + storageKey: 'ngx-theme-stack', + mode: 'class', + themes, + strategy: 'critters', + resolvedThemes: normalizeThemeInputs(themes as NgThemeInput[]), + ...config, + } as NgConfig; + + TestBed.configureTestingModule({ + providers: [ + CoreThemeService, + { provide: NGX_THEME_STACK_CONFIG, useValue: fullConfig }, + { provide: PLATFORM_ID, useValue: platformId }, + ], + }); + + const service = TestBed.inject(CoreThemeService); + return { service, matchMediaMock }; +} + +describe('CoreThemeService — per-theme color-scheme hints', () => { + afterEach(() => { + vi.restoreAllMocks(); + const host = document.documentElement; + host.className = ''; + host.removeAttribute('data-theme'); + host.style.removeProperty('color-scheme'); + }); + + it('applies the user-declared scheme for a custom theme', () => { + const { service } = setup({ + themes: ['light', 'dark', 'system', { name: 'sepia', scheme: 'light' } as NgThemeInput], + }); + service.setTheme('sepia'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('light'); + }); + + it('applies dark scheme for a custom theme', () => { + const { service } = setup({ + themes: ['light', 'dark', 'system', { name: 'ocean', scheme: 'dark' } as NgThemeInput], + }); + service.setTheme('ocean'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark'); + }); + + it('removes color-scheme when custom theme scheme is auto', () => { + const { service } = setup({ + themes: ['light', 'dark', 'system', { name: 'forest' } as NgThemeInput], + }); + service.setTheme('dark'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark'); + + service.setTheme('forest'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe(''); + }); + + it('keeps the implicit hint for built-in light/dark', () => { + const { service } = setup({ themes: ['light', 'dark', 'system'] }); + service.setTheme('light'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('light'); + + service.setTheme('dark'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark'); + }); + + it('accepts object-form themes and validates setTheme on the name', () => { + const { service } = setup({ + themes: ['light', 'dark', 'system', { name: 'moss', scheme: 'dark' } as NgThemeInput], + }); + service.setTheme('moss'); + expect(service.selectedTheme()).toBe('moss'); + expect(() => service.setTheme('nope')).toThrow('Invalid theme'); + }); + + it('exposes themeNames as a plain string array on select/cycle', () => { + const { service } = setup({ + themes: ['light', 'dark', 'system', { name: 'sepia', scheme: 'light' } as NgThemeInput], + }); + expect(service.themeNames).toEqual(['light', 'dark', 'system', 'sepia']); + }); +}); diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts index a6ce270..d520656 100644 --- a/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.service.spec.ts @@ -1,9 +1,10 @@ import { Component, PLATFORM_ID, ChangeDetectionStrategy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NGX_THEME_STACK_CONFIG } from '../config'; -import { NgConfig } from '../types'; +import { NgConfig, NgThemeInput, normalizeThemeInputs } from '../types'; import { CoreThemeService } from './core-theme.service'; + @Component({ template: '', changeDetection: ChangeDetectionStrategy.OnPush, @@ -52,14 +53,20 @@ function setup( vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(matchMediaMock)); + const effectiveThemes = (config.themes as NgThemeInput[] | undefined) ?? [ + 'light', + 'dark', + 'system', + ]; const fullConfig: NgConfig = { defaultTheme: 'system', storageKey: 'ngx-theme-stack', mode: 'class', - themes: ['light', 'dark', 'system'], strategy: 'critters', + themes: effectiveThemes, + resolvedThemes: normalizeThemeInputs(effectiveThemes), ...config, - }; + } as NgConfig; TestBed.configureTestingModule({ providers: [ @@ -276,6 +283,27 @@ describe('CoreThemeService', () => { expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe(''); }); + it('never writes a scheme that is not light/dark onto the root element', () => { + // A hand-written config can bypass provideThemeStack() and the compiler, so + // resolvedThemes may carry a value the types would have rejected. An unknown + // scheme must clear the hint rather than reach setProperty('color-scheme'). + const { service } = setup({ + themes: ['dark2', 'ghost'], + resolvedThemes: [ + { name: 'dark2', scheme: 'dark' }, + { name: 'ghost', scheme: 'banana' }, + ], + } as unknown as Partial); + + service.setTheme('dark2'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe('dark'); + + service.setTheme('ghost'); + TestBed.tick(); + expect(document.documentElement.style.getPropertyValue('color-scheme')).toBe(''); + }); + // ── Hydration Signal ── it('should set isHydrated to true in browser environment after next render', () => { diff --git a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts index b97cea8..fb90b22 100644 --- a/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts +++ b/projects/ngx-theme-stack/src/lib/core/core-theme.service.ts @@ -12,7 +12,7 @@ import { } from '@angular/core'; import { NGX_THEME_STACK_CONFIG } from '../config'; import { NgxThemeStackError } from '../errors'; -import { NgSystemTheme, NgTheme } from '../types'; +import { NgSystemTheme, NgTheme, NgThemeInput, ResolvedTheme, THEME_NAME_PATTERN, normalizeThemeInputs } from '../types'; /** * Core service for managing the application's color theme. @@ -37,8 +37,28 @@ export class CoreThemeService { /** List of available themes for Select/Cycle services. Defaults to ['system', 'light', 'dark']. */ readonly availableThemes = this.#config.themes; - /** Internal Set for O(1) existence checks. */ - readonly #validThemes = new Set(this.availableThemes); + /** + * Normalized themes with per-theme color-scheme hints. + * + * Falls back to deriving the list from `themes` when a config is injected + * without `resolvedThemes` (e.g. a hand-rolled `NGX_THEME_STACK_CONFIG` + * provider in tests), so the service never loses its valid-theme set. + */ + readonly resolvedThemes: ResolvedTheme[] = + this.#config.resolvedThemes ?? normalizeThemeInputs(this.#config.themes as NgThemeInput[]); + + /** Plain string array of theme names, suitable for template binding. */ + readonly themeNames: string[] = this.resolvedThemes.map((t) => t.name); + + /** O(1) name→scheme lookup. */ + readonly #schemeByName: ReadonlyMap = new Map( + this.resolvedThemes.map((t) => [t.name, t.scheme]), + ); + + /** Internal Set for O(1) existence checks. Built from normalized names. */ + readonly #validThemes = new Set( + this.resolvedThemes.map((t) => t.name as NgTheme), + ); /** * The anti-flash class to remove from the host element. @@ -210,16 +230,21 @@ export class CoreThemeService { } private applyThemeClasses(host: HTMLElement, theme: NgTheme): void { - host.classList.remove(...this.availableThemes); + host.classList.remove(...this.resolvedThemes.map((t) => t.name)); host.classList.add(theme); } private applyColorSchemeHint(host: HTMLElement, theme: NgTheme): void { - if (theme === 'dark' || theme === 'light') { - host.style.setProperty('color-scheme', theme); + // A positive whitelist, not a comparison against 'auto': only the two values + // that mean something are written. Anything else — including a hand-written + // config whose `resolvedThemes` bypassed normalizedThemeInputs and the + // compiler — falls through to removing the hint, which is what 'auto' means. + // Without this, an arbitrary string would reach setProperty('color-scheme'). + const scheme = this.#schemeByName.get(theme); + if (scheme === 'light' || scheme === 'dark') { + host.style.setProperty('color-scheme', scheme); return; } - host.style.removeProperty('color-scheme'); } @@ -227,7 +252,7 @@ export class CoreThemeService { if (!this.#isBrowser || !this.#initialStoredTheme) return; if ( - !/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(this.#initialStoredTheme) || + !THEME_NAME_PATTERN.test(this.#initialStoredTheme) || !this.#validThemes.has(this.#initialStoredTheme as NgTheme) ) { this.#antiFlashClass = null; diff --git a/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.spec.ts b/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.spec.ts index f032097..8370fa9 100644 --- a/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.spec.ts +++ b/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.spec.ts @@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { ThemeCycleService } from './theme-cycle.service'; import { NGX_THEME_STACK_CONFIG } from '../config'; import { CoreThemeService } from '../core/core-theme.service'; -import { NgConfig } from '../types'; +import { NgConfig, normalizeThemeInputs, NgThemeInput } from '../types'; function setup(config: Partial = {}) { let store: Record = {}; @@ -20,14 +20,16 @@ function setup(config: Partial = {}) { removeEventListener: vi.fn(), })); + const themes = (config.themes as NgThemeInput[] | undefined) ?? ['light', 'dark', 'system']; const fullConfig: NgConfig = { defaultTheme: 'system', storageKey: 'ngx-theme-stack', mode: 'class', - themes: ['light', 'dark', 'system'], + themes, strategy: 'critters', + resolvedThemes: normalizeThemeInputs(themes as NgThemeInput[]), ...config, - }; + } as NgConfig; TestBed.configureTestingModule({ providers: [ diff --git a/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.ts b/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.ts index f57d176..7950218 100644 --- a/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.ts +++ b/projects/ngx-theme-stack/src/lib/services/theme-cycle.service.ts @@ -1,5 +1,6 @@ import { computed, inject, Injectable } from '@angular/core'; import { CoreThemeService } from '../core/core-theme.service'; +import { NgTheme } from '../types'; /** * Convenience service for cycling through themes in a fixed order. @@ -13,8 +14,15 @@ import { CoreThemeService } from '../core/core-theme.service'; export class ThemeCycleService { readonly #core = inject(CoreThemeService); + /** + * List of all configured theme **names** for cycling. + * Returns a plain `string[]` so template consumers can bind directly. + * Defaults to `['system', 'light', 'dark']` plus any user-declared themes. + */ + readonly themeNames: string[] = this.#core.themeNames; + /** List of all configured themes for cycling. Defaults to `['light', 'dark', 'system']`. */ - readonly availableThemes = this.#core.availableThemes; + readonly availableThemes = this.themeNames as NgTheme[]; /** The theme explicitly selected by the user. May be `'system'`. */ readonly selectedTheme = this.#core.selectedTheme; @@ -24,20 +32,20 @@ export class ThemeCycleService { /** Index of the currently selected theme in the cycle. */ readonly cycleIndex = computed(() => { - return this.availableThemes.indexOf(this.selectedTheme()); + return this.themeNames.indexOf(this.selectedTheme()); }); /** The theme that comes before the currently selected theme in the cycle. */ readonly preceding = computed(() => { const index = this.cycleIndex(); - const len = this.availableThemes.length; - return this.availableThemes[(index - 1 + len) % len]; + const len = this.themeNames.length; + return this.themeNames[(index - 1 + len) % len]; }); /** The theme that comes after the currently selected theme in the cycle. */ readonly upcoming = computed(() => { const index = this.cycleIndex(); - return this.availableThemes[(index + 1) % this.availableThemes.length]; + return this.themeNames[(index + 1) % this.themeNames.length]; }); /** Whether the currently applied theme is `'dark'`. */ diff --git a/projects/ngx-theme-stack/src/lib/services/theme-select.service.spec.ts b/projects/ngx-theme-stack/src/lib/services/theme-select.service.spec.ts index c0d2251..a0cd002 100644 --- a/projects/ngx-theme-stack/src/lib/services/theme-select.service.spec.ts +++ b/projects/ngx-theme-stack/src/lib/services/theme-select.service.spec.ts @@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { ThemeSelectService } from './theme-select.service'; import { NGX_THEME_STACK_CONFIG } from '../config'; import { CoreThemeService } from '../core/core-theme.service'; -import { NgConfig } from '../types'; +import { NgConfig, normalizeThemeInputs, NgThemeInput } from '../types'; function setup(config: Partial = {}) { let store: Record = {}; @@ -20,14 +20,16 @@ function setup(config: Partial = {}) { removeEventListener: vi.fn(), })); + const themes = (config.themes as NgThemeInput[] | undefined) ?? ['light', 'dark', 'system']; const fullConfig: NgConfig = { defaultTheme: 'system', storageKey: 'ngx-theme-stack', mode: 'class', - themes: ['light', 'dark', 'system'], + themes, strategy: 'critters', + resolvedThemes: normalizeThemeInputs(themes as NgThemeInput[]), ...config, - }; + } as NgConfig; TestBed.configureTestingModule({ providers: [ diff --git a/projects/ngx-theme-stack/src/lib/services/theme-select.service.ts b/projects/ngx-theme-stack/src/lib/services/theme-select.service.ts index cb1990d..61b69e9 100644 --- a/projects/ngx-theme-stack/src/lib/services/theme-select.service.ts +++ b/projects/ngx-theme-stack/src/lib/services/theme-select.service.ts @@ -12,8 +12,15 @@ import { NgTheme } from '../types'; export class ThemeSelectService { readonly #core = inject(CoreThemeService); + /** + * List of all configured theme **names** for select UI. + * Returns a plain `string[]` so template consumers can bind directly. + * Defaults to `['system', 'light', 'dark']`. + */ + readonly themeNames: string[] = this.#core.themeNames; + /** List of all configured themes. Defaults to `['light', 'dark', 'system']`. */ - readonly availableThemes = this.#core.availableThemes; + readonly availableThemes = this.themeNames as NgTheme[]; /** The theme explicitly selected by the user. May be `'system'`. */ readonly selectedTheme = this.#core.selectedTheme; diff --git a/projects/ngx-theme-stack/src/lib/services/theme-toggle.service.spec.ts b/projects/ngx-theme-stack/src/lib/services/theme-toggle.service.spec.ts index b847f54..31551c9 100644 --- a/projects/ngx-theme-stack/src/lib/services/theme-toggle.service.spec.ts +++ b/projects/ngx-theme-stack/src/lib/services/theme-toggle.service.spec.ts @@ -2,7 +2,7 @@ import { PLATFORM_ID } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NGX_THEME_STACK_CONFIG } from '../config'; import { CoreThemeService } from '../core/core-theme.service'; -import { NgConfig } from '../types'; +import { NgConfig, normalizeThemeInputs } from '../types'; import { ThemeToggleService } from './theme-toggle.service'; function setup(systemPrefersDark = false) { @@ -33,6 +33,7 @@ function setup(systemPrefersDark = false) { mode: 'class', themes: ['light', 'dark', 'system'], strategy: 'critters', + resolvedThemes: normalizeThemeInputs(['light', 'dark', 'system']), }; TestBed.configureTestingModule({ diff --git a/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts b/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts new file mode 100644 index 0000000..9f35944 --- /dev/null +++ b/projects/ngx-theme-stack/src/lib/types.theme-scheme.spec.ts @@ -0,0 +1,162 @@ +import { + implicitScheme, + isNgColorScheme, + isValidThemeName, + NG_COLOR_SCHEMES, + normalizeThemeInputs, + THEME_NAME_PATTERN, + THEME_NAME_SOURCE, +} from './types'; + +describe('normalizeThemeInputs', () => { + it('normalizes string entries with implicit scheme', () => { + const result = normalizeThemeInputs(['light', 'dark', 'sepia']); + expect(result).toEqual([ + { name: 'light', scheme: 'light' }, + { name: 'dark', scheme: 'dark' }, + { name: 'sepia', scheme: 'auto' }, + ]); + }); + + it('deduplicates entries by name (first wins)', () => { + const result = normalizeThemeInputs([ + 'light', + { name: 'light', scheme: 'dark' }, + 'light', + ]); + expect(result).toEqual([{ name: 'light', scheme: 'light' }]); + }); + + it('handles object entries with explicit scheme', () => { + const result = normalizeThemeInputs([ + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ]); + expect(result).toEqual([ + { name: 'sepia', scheme: 'light' }, + { name: 'ocean', scheme: 'dark' }, + ]); + }); + + it('defaults object entries to auto when scheme is omitted', () => { + const result = normalizeThemeInputs([{ name: 'forest' }]); + expect(result).toEqual([{ name: 'forest', scheme: 'auto' }]); + }); + + it('replaces an unknown scheme with auto instead of passing it through', () => { + // A hand-written config can reach the runtime without the compiler having + // checked it; the value must not travel to setProperty('color-scheme', ...). + const result = normalizeThemeInputs([ + { name: 'ghost', scheme: 'banana' } as never, + { name: 'paper', scheme: 'none' } as never, + ]); + expect(result).toEqual([ + { name: 'ghost', scheme: 'auto' }, + { name: 'paper', scheme: 'auto' }, + ]); + }); + + it('keeps every accepted scheme', () => { + for (const scheme of NG_COLOR_SCHEMES) { + expect(normalizeThemeInputs([{ name: 'x', scheme }])).toEqual([{ name: 'x', scheme }]); + } + }); + + it('ignores empty-string entries', () => { + const result = normalizeThemeInputs(['', ' ', 'dark']); + expect(result).toEqual([{ name: 'dark', scheme: 'dark' }]); + }); + + it('ignores object entries with empty names', () => { + const result = normalizeThemeInputs([{ name: ' ' }, 'dark']); + expect(result).toEqual([{ name: 'dark', scheme: 'dark' }]); + }); + + it('mixes string and object entries', () => { + const result = normalizeThemeInputs([ + 'system', + 'light', + 'dark', + { name: 'sepia', scheme: 'light' }, + ]); + expect(result).toEqual([ + { name: 'system', scheme: 'auto' }, + { name: 'light', scheme: 'light' }, + { name: 'dark', scheme: 'dark' }, + { name: 'sepia', scheme: 'light' }, + ]); + }); + + it('trims whitespace in names', () => { + const result = normalizeThemeInputs([' ocean ', ' reef']); + expect(result).toEqual([ + { name: 'ocean', scheme: 'auto' }, + { name: 'reef', scheme: 'auto' }, + ]); + }); +}); + +describe('isValidThemeName', () => { + it('accepts names usable as a CSS class', () => { + for (const name of ['lucky', 'Lucky', 'lucky-theme', 'lucky_theme', 'lucky2', '_lucky', '-lucky']) { + expect(isValidThemeName(name)).toBe(true); + } + }); + + it('rejects a leading digit, which is not a valid CSS class selector', () => { + expect(isValidThemeName('2lucky')).toBe(false); + }); + + it('rejects whitespace, which throws in classList.add', () => { + expect(isValidThemeName('lucky theme')).toBe(false); + expect(isValidThemeName('lucky\ttheme')).toBe(false); + }); + + it('rejects CSS metacharacters, which change what the selector matches', () => { + // .lucky.theme looks for two classes, but the class token is the single + // string 'lucky.theme', so the styles would never match — with no error. + for (const name of ['lucky.theme', 'lucky#id', 'lucky>child', 'lucky[attr]', 'lucky:hover']) { + expect(isValidThemeName(name)).toBe(false); + } + }); + + it('rejects non-ASCII on purpose, as a documented narrower subset', () => { + expect(isValidThemeName('sueño')).toBe(false); + expect(isValidThemeName('año')).toBe(false); + }); + + it('rejects the empty string', () => { + expect(isValidThemeName('')).toBe(false); + }); + + it('exposes the same source the schematics copy', () => { + expect(THEME_NAME_PATTERN.source).toBe(THEME_NAME_SOURCE); + }); +}); + +describe('isNgColorScheme', () => { + it('accepts every declared scheme', () => { + for (const scheme of NG_COLOR_SCHEMES) { + expect(isNgColorScheme(scheme)).toBe(true); + } + }); + + it('rejects anything else, including non-strings', () => { + for (const value of ['banana', 'Light', 'none', '', undefined, null, 0, {}]) { + expect(isNgColorScheme(value)).toBe(false); + } + }); +}); + +describe('implicitScheme', () => { + it('maps light/dark to their matching scheme', () => { + expect(implicitScheme('light')).toBe('light'); + expect(implicitScheme('dark')).toBe('dark'); + }); + + it('returns auto for all other names', () => { + expect(implicitScheme('system')).toBe('auto'); + expect(implicitScheme('sepia')).toBe('auto'); + expect(implicitScheme('ocean')).toBe('auto'); + }); +}); diff --git a/projects/ngx-theme-stack/src/lib/types.ts b/projects/ngx-theme-stack/src/lib/types.ts index 78aacb8..9d618cd 100644 --- a/projects/ngx-theme-stack/src/lib/types.ts +++ b/projects/ngx-theme-stack/src/lib/types.ts @@ -13,6 +13,25 @@ */ export const DEFAULT_THEMES = ['system', 'light', 'dark'] as const; +/** + * Canonical list of accepted `color-scheme` hints. + * + * The type is derived from this runtime array, so the list and the union can + * never disagree. It is copied into the schematics by + * scripts/generate-schematic-constants.mjs. + */ +export const NG_COLOR_SCHEMES = ['light', 'dark', 'auto'] as const; + +/** + * Whether `value` is one of the accepted color schemes. + * + * Used to validate a hand-written config, which can reach the runtime without + * the compiler having checked it. + */ +export function isNgColorScheme(value: unknown): value is NgColorScheme { + return (NG_COLOR_SCHEMES as readonly unknown[]).includes(value); +} + /** Literal union of built-in themes: `'system' | 'light' | 'dark'`. */ export type DefaultNgTheme = (typeof DEFAULT_THEMES)[number]; @@ -36,6 +55,144 @@ export type NgTheme = DefaultNgTheme | T; */ export type NgSystemTheme = Exclude; +/** + * Color scheme hint applied to the root element via `color-scheme`. + * + * - `'light'` / `'dark'` — forces the browser to use matching native UI widgets. + * - `'auto'` — no explicit hint; the browser chooses, usually from the OS. + * + * An unknown value is not a valid hint: the schematic falls back to `'auto'`. + * + * This value is forwarded to `document.documentElement.style.color-scheme`. + * It only affects how the browser paints native widgets (scrollbars, inputs, + * form controls) in that color scheme — it does not pick a theme for you. + */ +export type NgColorScheme = (typeof NG_COLOR_SCHEMES)[number]; + +/** + * Pattern a theme identifier must match to be usable as a CSS class. + * + * A theme name is emitted as a class selector (`.name`) in class mode, so it has + * to be a valid CSS identifier. The anti-flash script also puts it into + * `classList`, where an ASCII whitespace throws `InvalidCharacterError`. + * + * This is deliberately ASCII-only: a narrower, easy-to-reason-about subset than + * the full CSS identifier grammar, which also admits non-ASCII characters. The + * point is that the rule is **enforced**, so a name that would break is a build + * error instead of a silent first-paint flash. + * + * What each part excludes, and why it matters: + * - a leading digit (`2lucky`) makes `.2lucky` invalid CSS, so the browser drops + * the whole rule and the theme silently loses its styles; + * - an ASCII space (`lucky theme`) throws in `classList.add`; + * - a CSS metacharacter (`.`, `#`, `>`, `[`, `:`, ...) changes what the selector + * means: `.lucky.theme` looks for two classes while the class token is the + * single string `lucky.theme`, so the styles never match, with no error. + * + * ⚠ KEEP IN SYNC with THEME_NAME_PATTERN in + * projects/ngx-theme-stack/schematics/ng-add/constants.ts — the schematics are + * CommonJS and cannot import this ESM module, so the pattern is duplicated and + * a spec asserts both copies are identical. + */ +export const THEME_NAME_SOURCE = '^-?[a-zA-Z_][a-zA-Z0-9_-]*$'; + +export const THEME_NAME_PATTERN = new RegExp(THEME_NAME_SOURCE); + +/** Whether `name` is usable as a theme identifier. See {@link THEME_NAME_PATTERN}. */ +export function isValidThemeName(name: string): boolean { + return THEME_NAME_PATTERN.test(name); +} + +/** + * Declarative theme entry. + * + * A theme can be declared as a plain string (no color-scheme hint) or as an + * object that pairs a name with a color-scheme hint. + * + * @example + * ```ts + * provideThemeStack({ + * themes: ['system', 'light', 'dark', { name: 'sepia', scheme: 'light' }], + * }); + * ``` + */ +export interface NgThemeOption { + /** Theme identifier. Must match {@link THEME_NAME_PATTERN}. */ + name: string; + /** + * Optional color-scheme hint for native UI widgets when this theme is active. + * - Omitted (or `'auto'`): no hint is set; the browser decides. + * - `'light'` / `'dark'`: hint is set on the root element while the theme is active. + * + * Any other value is invalid and is treated as `'auto'`. + */ + scheme?: NgColorScheme; +} + +/** + * Single entry in a `themes` array. Accepts either a plain string or a + * {@link NgThemeOption} object. + */ +export type NgThemeInput = string | NgThemeOption; + +/** + * Normalized internal representation of every theme, regardless of the + * user-facing input shape. + */ +export interface ResolvedTheme { + /** Theme identifier, validated to be a valid CSS identifier. */ + name: string; + /** Color-scheme hint to apply. Defaults to `'auto'` when omitted. */ + scheme: NgColorScheme; +} + +/** + * Normalizes a `themes` input (string or object form) into `ResolvedTheme[]`. + * + * - String entries are treated as scheme-less and default to `'auto'`. + * - Object entries must have a non-empty `name`. A `scheme` outside + * {@link NG_COLOR_SCHEMES} is replaced by `'auto'` rather than passed through: + * a hand-written config can reach the runtime without the compiler having + * checked it, and `'banana'` would otherwise be written straight into + * `style.setProperty('color-scheme', ...)`. + * - Built-in themes `'system'`, `'light'`, `'dark'` are special-cased: + * `'light'` and `'dark'` carry an implicit scheme of their own name; + * `'system'` is scheme-less (resolved via `matchMedia` at runtime). + */ +export function normalizeThemeInputs(themes: readonly NgThemeInput[]): ResolvedTheme[] { + const seen = new Set(); + const out: ResolvedTheme[] = []; + for (const entry of themes) { + if (typeof entry === 'string') { + const name = entry.trim(); + if (name === '') continue; + if (seen.has(name)) continue; + seen.add(name); + out.push({ name, scheme: implicitScheme(name) }); + } else { + const name = entry.name.trim(); + if (name === '') continue; + if (seen.has(name)) continue; + seen.add(name); + out.push({ name, scheme: isNgColorScheme(entry.scheme) ? entry.scheme : 'auto' }); + } + } + return out; +} + +/** + * Returns the implicit color-scheme hint for a built-in theme name. + * + * - `'light'` → `'light'`, `'dark'` → `'dark'` (matching CSS `color-scheme`). + * - `'system'` → `'auto'` (resolved at runtime via `matchMedia`). + * - any other name → `'auto'` (no hint; the browser picks). + */ +export function implicitScheme(name: string): NgColorScheme { + if (name === 'light') return 'light'; + if (name === 'dark') return 'dark'; + return 'auto'; +} + /** * Theme application mode. * - `'attribute'`: sets `data-theme` attribute on `` @@ -79,20 +236,41 @@ export interface NgConfig { strategy: NgStrategy; /** - * The **resolved** list of supported theme identifiers, always including the - * built-in themes (`'light'`, `'dark'`, `'system'`). + * The list of supported theme identifiers. * - * When you pass custom themes to {@link provideThemeStack}, they are **merged** - * with the built-in defaults — your custom values are appended after them. + * Accepts two input shapes (mixable in one array): + * - **String**: plain theme name. No color-scheme hint is applied for custom + * themes; built-in names keep their implicit hint (`light`/`dark`). + * - **Object**: `{ name, scheme? }`. Use when a custom theme needs an + * explicit `color-scheme` hint so native UI widgets match the theme. + * + * The built-in themes (`'light'`, `'dark'`, `'system'`) are **always + * included** in the resolved list even if not in your array. Your entries + * are appended after them. * * @example - * // Input to provideThemeStack: + * // Legacy: string-only (works, no hints on custom themes) * themes: ['sepia', 'ocean'] as const * - * // Resolved value stored in NgConfig: - * // ['system', 'light', 'dark', 'sepia', 'ocean'] + * @example + * // New: object form with scheme hints + * themes: [ + * { name: 'sepia', scheme: 'light' }, + * { name: 'ocean', scheme: 'dark' }, + * ] as const * - * Default (no custom themes): `['system', 'light', 'dark']`. + * Resolved value stored in `resolvedThemes` (see {@link NgConfig.resolvedThemes}): + * // [{ name: 'system', scheme: 'auto' }, { name: 'light', scheme: 'light' }, + * // { name: 'dark', scheme: 'dark' }, { name: 'sepia', scheme: 'light' }, + * // { name: 'ocean', scheme: 'dark' }] + */ + themes: (NgTheme | NgThemeOption)[]; + + /** + * Internal, normalized view of {@link NgConfig.themes}. + * Populated by {@link normalizeThemeInputs}; read-only for consumers. + * The public `themes` field is user-facing, while `resolvedThemes` carries + * the parsed scheme hints. */ - themes: NgTheme[]; + resolvedThemes: ResolvedTheme[]; } diff --git a/projects/ngx-theme-stack/src/schematics/anti-flash-script.spec.ts b/projects/ngx-theme-stack/src/schematics/anti-flash-script.spec.ts new file mode 100644 index 0000000..2bbc1b1 --- /dev/null +++ b/projects/ngx-theme-stack/src/schematics/anti-flash-script.spec.ts @@ -0,0 +1,63 @@ +import { buildAntiFlashScript } from '../../schematics/utils/anti-flash-script'; + +describe('buildAntiFlashScript', () => { + it('sets color-scheme for dark/light without schemeMap (legacy behavior)', () => { + const script = buildAntiFlashScript({ + storageKey: 'key', + defaultTheme: 'system', + mode: 'class', + themes: ['system', 'light', 'dark'], + }); + expect(script).toContain( + "if(t==='dark'||t==='light')e.style.setProperty('color-scheme',t);", + ); + }); + + it('uses schemeMap when provided', () => { + const script = buildAntiFlashScript({ + storageKey: 'key', + defaultTheme: 'sunset', + mode: 'class', + themes: ['system', 'light', 'dark', 'sunset', 'sepia'], + schemeMap: { sunset: 'dark', sepia: 'light' }, + }); + expect(script).toContain('s={"sunset":"dark","sepia":"light"}'); + expect(script).toContain('sc=(s&&s[t])'); + expect(script).toContain("e.style.removeProperty('color-scheme')"); + }); + + it('includes custom themes in the valid list', () => { + const script = buildAntiFlashScript({ + storageKey: 'key', + defaultTheme: 'sepia', + mode: 'class', + themes: ['system', 'light', 'dark', 'sepia'], + schemeMap: { sepia: 'light' }, + }); + expect(script).toContain('v=["system","light","dark","sepia"]'); + }); + + it('removes color-scheme when the scheme is auto', () => { + const script = buildAntiFlashScript({ + storageKey: 'key', + defaultTheme: 'system', + mode: 'class', + themes: ['system', 'light', 'dark'], + schemeMap: { sepia: 'auto' }, + }); + expect(script).toContain( + "if(sc==='auto'){e.style.removeProperty('color-scheme');}", + ); + }); + + it('never emits a none branch, because none is no longer a valid scheme', () => { + const script = buildAntiFlashScript({ + storageKey: 'key', + defaultTheme: 'system', + mode: 'class', + themes: ['system', 'light', 'dark'], + schemeMap: { sepia: 'auto' }, + }); + expect(script).not.toContain("'none'"); + }); +}); diff --git a/projects/ngx-theme-stack/src/schematics/generated-config.spec.ts b/projects/ngx-theme-stack/src/schematics/generated-config.spec.ts new file mode 100644 index 0000000..ee5d440 --- /dev/null +++ b/projects/ngx-theme-stack/src/schematics/generated-config.spec.ts @@ -0,0 +1,101 @@ +import { + DEFAULT_THEMES as LIB_DEFAULT_THEMES, + NG_COLOR_SCHEMES, + THEME_NAME_SOURCE, +} from '../lib/types'; +import { DEFAULT_NG_CONFIG } from '../lib/config'; +import { + COLOR_SCHEMES, + DEFAULT_THEMES, + DEFAULTS, + THEME_NAME_PATTERN, + describeInvalidThemeName, +} from '../../schematics/ng-add/constants'; + +/** + * The schematics compile to CommonJS with `rootDir: "schematics"` and cannot + * import the ESM library, so scripts/generate-schematic-constants.mjs copies the + * shared values into schematics/ng-add/generated-config.ts. + * + * This spec is the mechanism that keeps the copies honest. It fails when the + * generated file is stale — the library changed and the generator was not re-run + * — or when someone edits the generated file by hand. + * + * Regenerate with: pnpm --filter ngx-theme-stack run generate:constants + */ +describe('generated schematic constants stay in sync with the library', () => { + it('copies DEFAULT_THEMES', () => { + expect(DEFAULT_THEMES).toEqual(LIB_DEFAULT_THEMES); + }); + + it('copies the color schemes', () => { + expect(COLOR_SCHEMES).toEqual(NG_COLOR_SCHEMES); + }); + + it('copies the theme name pattern source', () => { + expect(THEME_NAME_PATTERN.source).toBe(THEME_NAME_SOURCE); + }); + + it('copies the default configuration', () => { + expect(DEFAULTS.defaultTheme).toBe(DEFAULT_NG_CONFIG.defaultTheme); + expect(DEFAULTS.storageKey).toBe(DEFAULT_NG_CONFIG.storageKey); + expect(DEFAULTS.mode).toBe(DEFAULT_NG_CONFIG.mode); + expect(DEFAULTS.strategy).toBe(DEFAULT_NG_CONFIG.strategy); + expect(DEFAULTS.themes).toEqual(DEFAULT_NG_CONFIG.themes); + }); + + // Equal `.source` already implies equal behaviour, but asserting the verdicts + // documents what the pattern is FOR, and catches a future change that keeps the + // two copies in step while making them both wrong. + it('agrees with the library on the names it accepts', () => { + const libPattern = new RegExp(THEME_NAME_SOURCE); + const cases = [ + 'lucky', + 'lucky-theme', + 'lucky_theme', + 'lucky2', + 'Lucky', + '_lucky', + '-lucky', + '', + '2lucky', + 'lucky theme', + 'lucky.theme', + 'lucky#theme', + 'lucky>theme', + 'sueño', + 'año', + ]; + for (const name of cases) { + expect(THEME_NAME_PATTERN.test(name)).toBe(libPattern.test(name)); + } + }); +}); + +describe('describeInvalidThemeName', () => { + it('explains an empty name', () => { + expect(describeInvalidThemeName(' ')).toMatch(/empty/); + }); + + it('explains a leading digit', () => { + expect(describeInvalidThemeName('2lucky')).toMatch(/starts with a digit/); + }); + + it('explains whitespace', () => { + expect(describeInvalidThemeName('lucky theme')).toMatch(/whitespace/); + expect(describeInvalidThemeName('lucky\ttheme')).toMatch(/whitespace/); + }); + + it('explains a CSS metacharacter', () => { + expect(describeInvalidThemeName('lucky.theme')).toMatch(/metacharacter/); + expect(describeInvalidThemeName('lucky#theme')).toMatch(/metacharacter/); + expect(describeInvalidThemeName('lucky:hover')).toMatch(/metacharacter/); + }); + + it('falls back to the allowed-set explanation', () => { + expect(describeInvalidThemeName('sueño')).toMatch(/outside the allowed set/); + // Non-breaking space is not ASCII whitespace, so it does not throw in + // classList; the accurate reason is that it is outside the allowed set. + expect(describeInvalidThemeName('lucky\u00a0theme')).toMatch(/outside the allowed set/); + }); +}); diff --git a/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts b/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts index 5264695..585de1b 100644 --- a/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts +++ b/projects/ngx-theme-stack/src/schematics/ng-add-utils.spec.ts @@ -1,10 +1,39 @@ import { HostTree } from '@angular-devkit/schematics'; import { assertAngularProject, + buildProvideCall, detectPackageManager, looksLikeNestMain, + parseYesNo, } from '../../schematics/ng-add/utils'; +describe('parseYesNo', () => { + it('accepts y and yes, in any case', () => { + for (const answer of ['y', 'Y', 'yes', 'YES', 'Yes', ' y ']) { + expect(parseYesNo(answer, false)).toBe(true); + } + }); + + it('accepts n and no, in any case', () => { + for (const answer of ['n', 'N', 'no', 'NO', 'No', ' n ']) { + expect(parseYesNo(answer, true)).toBe(false); + } + }); + + it('takes the prompt default on an empty answer', () => { + expect(parseYesNo('', true)).toBe(true); + expect(parseYesNo(' ', true)).toBe(true); + expect(parseYesNo('', false)).toBe(false); + }); + + it('returns undefined for anything unrecognised, instead of guessing', () => { + // The agent-skill prompt used `input !== 'n'`, so a typo counted as "yes". + for (const answer of ['asdf', 'sí', 'si', 'yep', 'maybe', '1', '0', 'nope']) { + expect(parseYesNo(answer, true)).toBeUndefined(); + } + }); +}); + describe('looksLikeNestMain', () => { it('detects a NestJS import', () => { expect(looksLikeNestMain("import { NestFactory } from '@nestjs/core';")).toBe(true); @@ -55,6 +84,37 @@ describe('assertAngularProject', () => { }); }); +describe('buildProvideCall', () => { + it('renders string themes as plain quotes', () => { + const call = buildProvideCall('system', 'key', 'class', ['system', 'light', 'dark'], 'critters'); + expect(call).toContain("themes: ['system', 'light', 'dark'] as const"); + expect(call).not.toContain('scheme'); + }); + + it('renders object-form entries when schemeMap is provided', () => { + const call = buildProvideCall( + 'system', 'key', 'class', + ['system', 'light', 'dark', 'sepia'], + 'critters', + { sepia: 'light' }, + ); + expect(call).toContain("{ name: 'sepia', scheme: 'light' }"); + expect(call).toContain("'system'"); + expect(call).toContain("'light'"); + }); + + it('leaves entries without a schemeMap entry as plain strings', () => { + const call = buildProvideCall( + 'system', 'key', 'class', + ['system', 'light', 'dark', 'sepia', 'ocean'], + 'critters', + { sepia: 'light' }, + ); + expect(call).toContain("{ name: 'sepia', scheme: 'light' }"); + expect(call).toContain("'ocean'"); + }); +}); + describe('detectPackageManager', () => { it('reads the packageManager field', () => { const tree = new HostTree(); diff --git a/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts b/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts new file mode 100644 index 0000000..3eb16a6 --- /dev/null +++ b/projects/ngx-theme-stack/src/schematics/theme-input.spec.ts @@ -0,0 +1,178 @@ +import { + parseThemeEntry, + parseThemeInputArray, + splitTopLevelEntries, +} from '../../schematics/utils/theme-input'; +import { COLOR_SCHEMES, parseColorScheme } from '../../schematics/ng-add/constants'; + +describe('parseColorScheme', () => { + it('accepts every declared scheme', () => { + for (const scheme of COLOR_SCHEMES) { + expect(parseColorScheme(scheme)).toBe(scheme); + } + }); + + it('rejects anything outside the declared list', () => { + expect(parseColorScheme('banana')).toBeUndefined(); + expect(parseColorScheme('Light')).toBeUndefined(); + expect(parseColorScheme('lightt')).toBeUndefined(); + expect(parseColorScheme('')).toBeUndefined(); + expect(parseColorScheme(' ')).toBeUndefined(); + }); + + it('rejects none, which was removed on purpose', () => { + expect(COLOR_SCHEMES).not.toContain('none'); + expect(parseColorScheme('none')).toBeUndefined(); + }); + + it('trims before matching', () => { + expect(parseColorScheme(' dark ')).toBe('dark'); + }); +}); + +describe('splitTopLevelEntries', () => { + it('splits plain string entries', () => { + expect(splitTopLevelEntries("'light', 'dark', 'system'")).toEqual([ + "'light'", + "'dark'", + "'system'", + ]); + }); + + it('keeps object entries intact', () => { + expect( + splitTopLevelEntries("'system', { name: 'sepia', scheme: 'light' }, 'ocean'"), + ).toEqual(["'system'", "{ name: 'sepia', scheme: 'light' }", "'ocean'"]); + }); + + it('does not split on commas inside quotes', () => { + expect(splitTopLevelEntries("'a,b', 'c'")).toEqual(["'a,b'", "'c'"]); + }); + + it('does not split on commas inside nested brackets', () => { + expect(splitTopLevelEntries("['a', 'b'], 'c'")).toEqual(["['a', 'b']", "'c'"]); + }); + + it('handles a trailing comma gracefully', () => { + expect(splitTopLevelEntries("'a', 'b',")).toEqual(["'a'", "'b'"]); + }); + + it('returns an empty list for an empty body', () => { + expect(splitTopLevelEntries('')).toEqual([]); + }); +}); + +describe('parseThemeEntry', () => { + it('parses a plain quoted name', () => { + expect(parseThemeEntry("'sepia'")).toEqual({ name: 'sepia' }); + }); + + it('parses an object with name and scheme', () => { + expect(parseThemeEntry("{ name: 'sepia', scheme: 'light' }")).toEqual({ + name: 'sepia', + scheme: 'light', + }); + }); + + it('parses an object with scheme before name', () => { + expect(parseThemeEntry("{ scheme: 'dark', name: 'ocean' }")).toEqual({ + name: 'ocean', + scheme: 'dark', + }); + }); + + it('parses an object without a scheme', () => { + expect(parseThemeEntry('{ name: "forest" }')).toEqual({ name: 'forest' }); + }); + + it('returns null for an unrecognized entry', () => { + expect(parseThemeEntry('{ scheme: "dark" }')).toBeNull(); + expect(parseThemeEntry('garbage')).toBeNull(); + }); +}); + +describe('parseThemeInputArray', () => { + it('parses the mixed string + object form used by the demo', () => { + const body = + "'system', 'light', 'dark', { name: 'sunset', scheme: 'dark' }, { name: 'sepia', scheme: 'light' }"; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['system', 'light', 'dark', 'sunset', 'sepia'], + schemeMap: { sunset: 'dark', sepia: 'light' }, + invalidSchemes: [], + invalidNames: [], + }); + }); + + it('parses a string-only array with an empty schemeMap', () => { + expect(parseThemeInputArray("'system', 'light', 'dark'")).toEqual({ + themes: ['system', 'light', 'dark'], + schemeMap: {}, + invalidSchemes: [], + invalidNames: [], + }); + }); + + it('multiline object entries survive a comma split', () => { + const body = ` + 'light', + 'dark', + { + name: 'sepia', + scheme: 'light' + }, + `; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['light', 'dark', 'sepia'], + schemeMap: { sepia: 'light' }, + invalidSchemes: [], + invalidNames: [], + }); + }); + + it('drops an invalid scheme and reports it instead of failing', () => { + const body = + "'light', { name: 'lucky', scheme: 'banana' }, { name: 'sunset', scheme: 'dark' }"; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['light', 'lucky', 'sunset'], + schemeMap: { sunset: 'dark' }, + invalidSchemes: [{ name: 'lucky', scheme: 'banana' }], + invalidNames: [], + }); + }); + + it('drops a legacy none scheme and reports it', () => { + const body = "'light', { name: 'paper', scheme: 'none' }"; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['light', 'paper'], + schemeMap: {}, + invalidSchemes: [{ name: 'paper', scheme: 'none' }], + invalidNames: [], + }); + }); + + it('keeps an explicit auto untouched', () => { + const body = "'light', { name: 'paper', scheme: 'auto' }"; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['light', 'paper'], + schemeMap: { paper: 'auto' }, + invalidSchemes: [], + invalidNames: [], + }); + }); + + it('reports names that are not usable as a CSS class, without dropping them', () => { + const body = "'light', '2lucky', 'lucky theme', 'lucky.theme', 'sueño'"; + + expect(parseThemeInputArray(body)).toEqual({ + themes: ['light', '2lucky', 'lucky theme', 'lucky.theme', 'sueño'], + schemeMap: {}, + invalidSchemes: [], + invalidNames: ['2lucky', 'lucky theme', 'lucky.theme', 'sueño'], + }); + }); +}); diff --git a/scripts/generate-schematic-constants.mjs b/scripts/generate-schematic-constants.mjs new file mode 100755 index 0000000..5635bc0 --- /dev/null +++ b/scripts/generate-schematic-constants.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * Generates the schematic copy of the values that the library owns. + * + * Why this exists + * --------------- + * The schematics compile to CommonJS with `rootDir: "schematics"`, while the + * library is built by ng-packagr from `src/` as ESM. A single source file cannot + * belong to both, and TypeScript enforces it: + * + * error TS6059: File '.../src/lib/types.ts' is not under 'rootDir' '.../schematics' + * + * So the shared values used to be copy-pasted with a "KEEP IN SYNC" comment, + * which is a promise rather than a mechanism. This script is the mechanism: the + * library is the source of truth, and the schematic copy is generated, so it + * cannot drift. `src/schematics/generated-config.spec.ts` fails if it somehow + * does (for example if someone edits the generated file by hand or forgets to + * regenerate after changing the library). + * + * Values are read through the TypeScript compiler API rather than with regular + * expressions, so the extraction does not break on formatting, comments, or + * trailing commas. + * + * Run it directly, or let the `prebuild` hook of projects/ngx-theme-stack run it. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const libRoot = resolve(repoRoot, 'projects/ngx-theme-stack'); +const TYPES = resolve(libRoot, 'src/lib/types.ts'); +const CONFIG = resolve(libRoot, 'src/lib/config/index.ts'); +const OUTPUT = resolve(libRoot, 'schematics/ng-add/generated-config.ts'); + +// ── Extraction helpers over the TypeScript AST ──────────────────────────────── + +function parse(path) { + return ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.Latest, true); +} + +/** Finds `export const NAME = ` and returns the initializer node. */ +function findInitializer(sourceFile, name) { + let found; + const visit = (node) => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === name && + node.initializer + ) { + found = node.initializer; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + if (!found) { + throw new Error(`could not find "export const ${name}" in ${sourceFile.fileName}`); + } + return found; +} + +function expectString(node, name) { + if (!ts.isStringLiteral(node)) { + throw new Error(`"${name}" must be a plain string literal to be generated`); + } + return node.text; +} + +function expectStringArray(node, name) { + if (!ts.isAsExpression(node) && !ts.isArrayLiteralExpression(node)) { + throw new Error(`"${name}" must be a string array literal to be generated`); + } + const array = ts.isAsExpression(node) ? node.expression : node; + if (!ts.isArrayLiteralExpression(array)) { + throw new Error(`"${name}" must be an array literal to be generated`); + } + return array.elements.map((element, index) => { + if (!ts.isStringLiteral(element)) { + throw new Error(`"${name}[${index}]" must be a string literal to be generated`); + } + return element.text; + }); +} + +function expectStringProperties(node, name, properties) { + if (!ts.isObjectLiteralExpression(node)) { + throw new Error(`"${name}" must be an object literal to be generated`); + } + const out = {}; + for (const property of properties) { + const found = node.properties.find( + (p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === property, + ); + if (!found) throw new Error(`"${name}.${property}" not found`); + out[property] = expectString(found.initializer, `${name}.${property}`); + } + return out; +} + +const quote = (value) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +const asConstArray = (values) => `[${values.map(quote).join(', ')}] as const`; + +// ── Read the canonical values ──────────────────────────────────────────────── + +const types = parse(TYPES); +const config = parse(CONFIG); + +const defaultThemes = expectStringArray(findInitializer(types, 'DEFAULT_THEMES'), 'DEFAULT_THEMES'); +const colorSchemes = expectStringArray( + findInitializer(types, 'NG_COLOR_SCHEMES'), + 'NG_COLOR_SCHEMES', +); +const themeNameSource = expectString(findInitializer(types, 'THEME_NAME_SOURCE'), 'THEME_NAME_SOURCE'); +const defaults = expectStringProperties(findInitializer(config, 'DEFAULT_NG_CONFIG'), 'DEFAULT_NG_CONFIG', [ + 'defaultTheme', + 'storageKey', + 'mode', + 'strategy', +]); + +// ── Emit ───────────────────────────────────────────────────────────────────── + +const contents = `/** + * AUTO-GENERATED by scripts/generate-schematic-constants.mjs — DO NOT EDIT. + * + * Source of truth: + * - src/lib/types.ts (DEFAULT_THEMES, NG_COLOR_SCHEMES, THEME_NAME_SOURCE) + * - src/lib/config/index.ts (DEFAULT_NG_CONFIG) + * + * The schematics compile to CommonJS with \`rootDir: "schematics"\` and cannot + * import the ESM library, so the build copies these values here. Regenerate with + * \`pnpm --filter ngx-theme-stack run generate:constants\` (also runs on prebuild). + * + * src/schematics/generated-config.spec.ts asserts these still match the library. + */ + +export const DEFAULT_THEMES = ${asConstArray(defaultThemes)}; + +export const NG_COLOR_SCHEMES = ${asConstArray(colorSchemes)}; + +export const THEME_NAME_SOURCE = ${quote(themeNameSource)}; + +export const DEFAULT_NG_CONFIG = { + defaultTheme: ${quote(defaults['defaultTheme'])}, + storageKey: ${quote(defaults['storageKey'])}, + mode: ${quote(defaults['mode'])}, + strategy: ${quote(defaults['strategy'])}, +} as const; +`; + +let previous = ''; +try { + previous = readFileSync(OUTPUT, 'utf8'); +} catch { + // first run +} + +if (previous === contents) { + console.log(`unchanged ${relative(repoRoot, OUTPUT)}`); +} else { + writeFileSync(OUTPUT, contents); + console.log(`${previous ? 'updated ' : 'created '}${relative(repoRoot, OUTPUT)}`); +} diff --git a/scripts/verify-publish-guards.mjs b/scripts/verify-publish-guards.mjs new file mode 100755 index 0000000..6f7bc29 --- /dev/null +++ b/scripts/verify-publish-guards.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/** + * Verifies the release guards in .github/workflows/publish.yml. + * + * Run it after touching the workflow: + * node scripts/verify-publish-guards.mjs + * + * The guard `run:` bodies are extracted FROM THE WORKFLOW FILE and executed with + * real SHAs from this repo, so this exercises the actual shell logic CI runs + * rather than a copy of it. The `if:` conditions are mirrored below, but the + * harness first asserts that the mirrored strings are byte-identical to the ones + * in the workflow, so the mirror cannot silently drift. + * + * Why this exists: `latest` is what `npm install ngx-theme-stack` resolves for + * everyone. A stable tag created outside main must never reach it, and the + * manual `workflow_dispatch` path used to reach it from any branch. + */ +import { readFileSync } from 'node:fs'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const WF = resolve(repoRoot, '.github/workflows/publish.yml'); +const wf = readFileSync(WF, 'utf8'); +const sh = (args) => execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' }).trim(); + +// ── 1. Parse steps (6-space indented "- name:") ─────────────────────────────── +const lines = wf.split('\n'); +const steps = []; +for (let i = 0; i < lines.length; i++) { + const m = /^ {6}- name: (.+)$/.exec(lines[i]); + if (!m) continue; + const body = []; + for (let j = i + 1; j < lines.length; j++) { + if (/^ {6}- name: /.test(lines[j]) || /^ {4,6}[a-z]+:/.test(lines[j])) break; + body.push(lines[j]); + } + steps.push({ name: m[1].trim(), body: body.join('\n') }); +} + +const guards = steps.filter((s) => s.name.startsWith('Guard — ')); +if (guards.length !== 2) throw new Error(`expected 2 guards, found ${guards.length}`); + +/** Pull the shell out of a step's `run: |` block. */ +function runBody(step) { + const idx = step.body.indexOf('run: |'); + if (idx === -1) throw new Error(`no run block in "${step.name}"`); + const after = step.body.slice(idx + 'run: |'.length).split('\n'); + const out = []; + for (const l of after) { + if (l.trim() === '') { out.push(''); continue; } + if (!/^ {10}/.test(l)) break; // dedent ends the block + out.push(l.slice(10)); + } + return out.join('\n'); +} + +function ifExpr(step) { + const m = /^ {8}if: (.+)$/m.exec(step.body); + return m ? m[1].trim() : null; +} + +// ── 2. Assert the mirrored conditions are identical to the workflow ─────────── +const EXPECTED_IF = { + 'Guard — a stable tag must live on main': + "${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }}", + 'Guard — a manual publish must run from main': + "${{ github.event_name == 'workflow_dispatch' }}", +}; +for (const g of guards) { + const actual = ifExpr(g); + if (actual !== EXPECTED_IF[g.name]) { + throw new Error( + `if: drifted for "${g.name}"\n expected: ${EXPECTED_IF[g.name]}\n actual: ${actual}`, + ); + } +} +console.log('OK conditions in the workflow match the mirrored evaluators\n'); + +/** Mirror of the `if:` expressions (asserted above). */ +function applies(name, env) { + if (name === 'Guard — a stable tag must live on main') { + return env.GITHUB_REF_TYPE === 'tag' && !env.GITHUB_REF_NAME.includes('-'); + } + if (name === 'Guard — a manual publish must run from main') { + return env.GITHUB_EVENT_NAME === 'workflow_dispatch'; + } + throw new Error(`unknown guard: ${name}`); +} + +// ── 3. Scenarios against real commits ──────────────────────────────────────── +const MAIN = sh(['rev-parse', 'main']); +const DEV = sh(['rev-parse', 'dev']); +sh(['rev-parse', '--verify', 'origin/main']); // the guard needs this ref + +const scenarios = [ + { label: '1. stable tag ON main -> must PASS', refType: 'tag', refName: 'v3.9.5', sha: MAIN, event: 'push', expect: 'pass' }, + { label: '2. stable tag OFF main -> must FAIL', refType: 'tag', refName: 'v4.0.0', sha: DEV, event: 'push', expect: 'fail' }, + { label: '3. prerelease tag on dev -> guards SKIP', refType: 'tag', refName: 'v3.10.0-next.0', sha: DEV, event: 'push', expect: 'skip' }, + { label: '4. manual dispatch from dev -> must FAIL', refType: 'branch', refName: 'dev', sha: DEV, event: 'workflow_dispatch', expect: 'fail' }, + { label: '5. manual dispatch from main-> must PASS', refType: 'branch', refName: 'main', sha: MAIN, event: 'workflow_dispatch', expect: 'pass' }, +]; + +let failures = 0; +console.log(`main=${MAIN.slice(0, 7)} dev=${DEV.slice(0, 7)}\n`); + +for (const s of scenarios) { + const env = { + ...process.env, + GITHUB_REF_TYPE: s.refType, + GITHUB_REF_NAME: s.refName, + GITHUB_SHA: s.sha, + GITHUB_EVENT_NAME: s.event, + }; + + const active = guards.filter((g) => applies(g.name, env)); + let outcome = 'skip'; + let detail = 'no guard applies'; + + for (const g of active) { + const r = spawnSync('bash', ['-eo', 'pipefail', '-c', runBody(g)], { + cwd: repoRoot, + env, + encoding: 'utf8', + }); + if (r.status === 0) { + outcome = 'pass'; + detail = `ran "${g.name}"`; + } else { + outcome = 'fail'; + detail = + (r.stderr + r.stdout) + .trim() + .split('\n') + .find((l) => l.includes('::error::')) ?? `exit ${r.status}`; + break; + } + } + + const ok = outcome === s.expect; + if (!ok) failures++; + console.log(`${ok ? 'PASS' : 'FAIL'} ${s.label}`); + console.log(` got=${outcome} expected=${s.expect} | ${detail}`); +} + +console.log( + failures === 0 ? '\nAll scenarios behaved as required.' : `\n${failures} scenario(s) FAILED.`, +); +process.exit(failures === 0 ? 0 : 1);