create-ailk 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/component-catalog.json +3 -3
  2. package/dist/add-module.d.ts +7 -0
  3. package/dist/add-module.js +34 -2
  4. package/dist/add-route.js +18 -2
  5. package/dist/apply-design.d.ts +12 -1
  6. package/dist/apply-design.js +36 -4
  7. package/dist/cli.js +23 -3
  8. package/dist/derive-architecture.d.ts +15 -2
  9. package/dist/derive-architecture.js +34 -6
  10. package/dist/design-package.js +3 -2
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/lib/chrome-readers.d.ts +21 -0
  14. package/dist/lib/chrome-readers.js +100 -0
  15. package/dist/lib/extract-module-bundle.d.ts +11 -0
  16. package/dist/lib/extract-module-bundle.js +36 -6
  17. package/dist/lib/license-key-files.d.ts +6 -1
  18. package/dist/lib/license-key-files.js +9 -4
  19. package/dist/module-architecture.d.ts +9 -0
  20. package/dist/module-architecture.js +6 -0
  21. package/dist/parse-args.d.ts +22 -0
  22. package/dist/parse-args.js +73 -0
  23. package/dist/programmatic.d.ts +20 -0
  24. package/dist/programmatic.js +110 -9
  25. package/dist/site-chrome.d.ts +4 -0
  26. package/dist/site-chrome.js +34 -3
  27. package/dist/site-manifest.d.ts +12 -5
  28. package/dist/site-manifest.js +25 -11
  29. package/dist/sync-routes.js +11 -2
  30. package/dist/topology.d.ts +131 -0
  31. package/dist/topology.js +533 -0
  32. package/package.json +3 -3
  33. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  34. package/templates/apps/api/CLAUDE.md +4 -2
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  38. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  39. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  40. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  41. package/templates/apps/api/src/lib/site.ts +37 -0
  42. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  43. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  44. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  45. package/templates/apps/api/src/middleware/auth.ts +3 -0
  46. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  47. package/templates/apps/api/src/openapi/spec.ts +38 -2
  48. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  49. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  50. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  51. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  52. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  53. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  54. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  55. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  56. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  57. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  58. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  59. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  60. package/templates/apps/mcp/src/index.ts +6 -4
  61. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  62. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  63. package/templates/apps/web/CLAUDE.md +1 -0
  64. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  67. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  68. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  69. package/templates/apps/web/app/[locale]/layout.tsx +71 -19
  70. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  71. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  72. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  74. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  75. package/templates/apps/web/instrumentation.ts +7 -4
  76. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  77. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  78. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  79. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  80. package/templates/apps/web/lib/relay.ts +206 -2
  81. package/templates/apps/web/lib/site-brand.tsx +22 -1
  82. package/templates/apps/web/lib/site-chrome.ts +3 -0
  83. package/templates/apps/web/lib/topology.generated.ts +23 -0
  84. package/templates/apps/web/package.json +1 -1
  85. package/templates/database/CHANGELOG.md +25 -0
  86. package/templates/database/__tests__/agents.db.test.ts +65 -0
  87. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  88. package/templates/database/auth/schema.prisma +23 -0
  89. package/templates/database/inbox/schema.prisma +13 -0
  90. package/templates/database/jest.config.cjs +7 -11
  91. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  92. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  93. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  94. package/templates/database/package.json +1 -1
  95. package/versions.json +34 -33
@@ -5235,8 +5235,7 @@
5235
5235
  }
5236
5236
  },
5237
5237
  "required": [
5238
- "title",
5239
- "description"
5238
+ "title"
5240
5239
  ]
5241
5240
  }
5242
5241
  },
@@ -7378,7 +7377,8 @@
7378
7377
  "type": "string",
7379
7378
  "enum": [
7380
7379
  "default",
7381
- "sm"
7380
+ "sm",
7381
+ "lg"
7382
7382
  ]
7383
7383
  },
7384
7384
  "surface": {
@@ -4,6 +4,13 @@ import { type ModulePatch } from "./module-architecture.js";
4
4
  import { type ModuleName } from "./parse-args.js";
5
5
  import type { MarkerMap, WorkspaceVersionMap } from "./substitute.js";
6
6
  import { type SyncRoutesResult } from "./sync-routes.js";
7
+ import { type Topology } from "./topology.js";
8
+ /**
9
+ * The site's topology for add-module (#7426 D13): an unreadable manifest is a
10
+ * refusal this command names (exit 2), before the first write — the same
11
+ * posture as its unreadable-roster refusal.
12
+ */
13
+ export declare function addModuleSiteTopology(targetDir: string): Topology;
7
14
  export { AddModuleRefusedError, readArchitectureHeader };
8
15
  /**
9
16
  * One report row: a bundle file written, patched or skipped, or a seed the
@@ -39,6 +39,22 @@ import { ParseError, parseModules } from "./parse-args.js";
39
39
  import { SITE_ROSTER_PATH, moduleRosterRows, planRosterAppend, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
40
40
  import { MODULE_SURFACES, NAV_CONFIG_PATH, readNavConfigStamp, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
41
41
  import { syncRoutes } from "./sync-routes.js";
42
+ import { TopologyError, isSpokeExcluded, readSiteTopology, spokeModuleRefusal, } from "./topology.js";
43
+ /**
44
+ * The site's topology for add-module (#7426 D13): an unreadable manifest is a
45
+ * refusal this command names (exit 2), before the first write — the same
46
+ * posture as its unreadable-roster refusal.
47
+ */
48
+ export function addModuleSiteTopology(targetDir) {
49
+ try {
50
+ return readSiteTopology(targetDir);
51
+ }
52
+ catch (err) {
53
+ if (err instanceof TopologyError)
54
+ throw new AddModuleRefusedError(err.message);
55
+ throw err;
56
+ }
57
+ }
42
58
  export { AddModuleRefusedError, readArchitectureHeader };
43
59
  /** `package.json` paths a `json` patch may write under the plus-config policy. */
44
60
  const PACKAGE_JSON_PATCH_KEYS = new Set([
@@ -86,6 +102,17 @@ export function checkPlusConfigPatches(patches) {
86
102
  export async function applyModuleToScaffold(opts) {
87
103
  const targetAbs = resolve(opts.targetDir);
88
104
  const archPath = join(targetAbs, "architecture.yaml");
105
+ // #7426 D13 — a spoke stays a spoke: a module it does not admit is refused
106
+ // before the first write, and an admitted one lands through the spoke
107
+ // filter. `mcp` follows whether the spoke still carries apps/mcp.
108
+ const spoke = addModuleSiteTopology(targetAbs) === "spoke"
109
+ ? { mcp: existsSync(join(targetAbs, "apps", "mcp")) }
110
+ : undefined;
111
+ if (spoke !== undefined) {
112
+ const refusal = spokeModuleRefusal(opts.moduleName);
113
+ if (refusal !== undefined)
114
+ throw new AddModuleRefusedError(refusal);
115
+ }
89
116
  const current = readArchitectureHeader(readFileSync(archPath, "utf8"));
90
117
  const modules = normalizeModules([...current.modules, opts.moduleName]);
91
118
  // Resolved before the first write: a header surface that is unknown, or
@@ -112,6 +139,9 @@ export async function applyModuleToScaffold(opts) {
112
139
  markers: opts.markers,
113
140
  existingFilePolicy: "skip-existing",
114
141
  checkPatches: checkPlusConfigPatches,
142
+ ...(spoke !== undefined
143
+ ? { skip: (destination) => isSpokeExcluded(destination, spoke) }
144
+ : {}),
115
145
  });
116
146
  const routes = syncRoutes({
117
147
  targetDir: targetAbs,
@@ -123,10 +153,12 @@ export async function applyModuleToScaffold(opts) {
123
153
  // #6107 D8 — a module that brings `apps/admin` brings its auth hard-fail;
124
154
  // the Admin app gets the web app's effective secret, and a secret already
125
155
  // held in any Next-loaded env file is never shadowed.
126
- writeAuthSecretFile(targetAbs);
156
+ // A spoke has no sign-in (#7426 D13), so it gets no secret.
157
+ if (spoke === undefined)
158
+ writeAuthSecretFile(targetAbs);
127
159
  // The same composition `syncArchitectureSync` writes — a pure function of
128
160
  // the module set, so an unchanged set rewrites identical bytes.
129
- writeFileSync(archPath, deriveArchitecture(modules, surfaces));
161
+ writeFileSync(archPath, deriveArchitecture(modules, surfaces, spoke !== undefined ? { topology: "spoke", mcp: spoke.mcp } : {}));
130
162
  // #7536 D3 — the paid-sections registry follows `apps/web/package.json`,
131
163
  // which the bundle's `patches` just edited. Generated and always
132
164
  // overwritten, like `architecture.yaml` above.
package/dist/add-route.js CHANGED
@@ -39,6 +39,7 @@ import { substituteMarkers } from "./substitute.js";
39
39
  import { GATEABLE_SURFACES, MODULE_SURFACES, parseSurfaces, resolveSurfaces, } from "./surfaces.js";
40
40
  import { syncRoutes } from "./sync-routes.js";
41
41
  import { BASE_LAYER_DIR } from "./template-manifest.js";
42
+ import { TopologyError, readSiteTopology } from "./topology.js";
42
43
  /** A refusal the CLI exits 2 on, thrown before anything is written. */
43
44
  export class AddRouteRefusedError extends Error {
44
45
  exitCode = 2;
@@ -64,6 +65,20 @@ export function addRouteToScaffold(opts) {
64
65
  if (!existsSync(archPath)) {
65
66
  throw new Error(`not a scaffolded repo: no architecture.yaml at ${targetAbs}. Run \`create-ailk <target-dir>\` to scaffold first.`);
66
67
  }
68
+ // #7426 D13 — a spoke stays a spoke. Read before the first write: an
69
+ // unreadable manifest is a refusal this command names (exit 2).
70
+ let spoke;
71
+ try {
72
+ spoke =
73
+ readSiteTopology(targetAbs) === "spoke"
74
+ ? { mcp: existsSync(join(targetAbs, "apps", "mcp")) }
75
+ : undefined;
76
+ }
77
+ catch (err) {
78
+ if (err instanceof TopologyError)
79
+ throw new AddRouteRefusedError(err.message);
80
+ throw err;
81
+ }
67
82
  const route = canonicalRoute(opts.route);
68
83
  const owner = Object.entries(MODULE_SURFACES).find(([, surfaces]) => surfaces.includes(route))?.[0];
69
84
  let current;
@@ -106,8 +121,9 @@ export function addRouteToScaffold(opts) {
106
121
  if (navRow !== undefined)
107
122
  report.push(navRow);
108
123
  // Last: the declared header is what makes a rerun a no-op, so it is
109
- // written only once every other step has landed.
110
- writeFileSync(archPath, deriveArchitecture(current.modules, surfaces));
124
+ // written only once every other step has landed. A spoke's `syncRoutes`
125
+ // call above already skipped what it lacks (#7426 D13).
126
+ writeFileSync(archPath, deriveArchitecture(current.modules, surfaces, spoke !== undefined ? { topology: "spoke", mcp: spoke.mcp } : {}));
111
127
  return { targetDir: targetAbs, route, surfaces, report, routes };
112
128
  }
113
129
  /** The canonical id for one route argument, refusing an unknown one. */
@@ -1,4 +1,5 @@
1
1
  import { type HomeSeedOutcome } from "./design-home.js";
2
+ import { type UnreadChromeKnob } from "./lib/chrome-readers.js";
2
3
  export interface ApplyDesignOptions {
3
4
  /** The design: a shipped name, or a directory holding a DESIGN.md. */
4
5
  design: string;
@@ -10,6 +11,8 @@ export interface ApplyDesignOptions {
10
11
  /** The overlay's `templates/` (#7743 D7) — where a treeless shape's home lives. Default: `templates` beside `siteTemplatesDir`. */
11
12
  templatesDir?: string;
12
13
  catalogPath: string;
14
+ /** #7781 — apply the design even when a reader file of the site ignores a chrome knob it sets; the pairs are reported instead of refused. */
15
+ allowUnreadChrome?: boolean;
13
16
  /** Where a relative design path resolves from. Default process.cwd(). */
14
17
  cwd?: string;
15
18
  /** Read for AILK_LICENSE_KEY only (D6). Default process.env. */
@@ -60,13 +63,21 @@ export interface ApplyDesignResult {
60
63
  fonts?: string[];
61
64
  /** #7720 D4.2 — faces were written, but the site's layout.tsx does not read lib/site-fonts. */
62
65
  fontsUnwired?: true;
66
+ /** #7781 — chrome knobs the design set that a reader file of the site does not read; present only when non-empty, which only `allowUnreadChrome` lets through. */
67
+ unreadChrome?: UnreadChromeKnob[];
68
+ }
69
+ /** #7781 — thrown before any write when the design sets a chrome knob that a reader file of the site does not read. */
70
+ export declare class UnreadChromeError extends Error {
71
+ readonly unread: readonly UnreadChromeKnob[];
72
+ constructor(unread: readonly UnreadChromeKnob[]);
63
73
  }
64
74
  export interface ApplyDesignArgs {
65
75
  design: string | undefined;
66
76
  target: string | undefined;
77
+ allowUnreadChrome: boolean;
67
78
  help: boolean;
68
79
  }
69
- export declare const APPLY_DESIGN_HELP = "create-ailk apply-design \u2014 make an existing site wear a design.\n\nUsage:\n create-ailk apply-design <name> [--target <site-dir>]\n create-ailk apply-design <path> [--target <site-dir>]\n\nArguments:\n <name|path> A shipped design (standard, minimalism), or a\n directory holding a DESIGN.md. A relative path\n resolves from the current directory.\n\nOptions:\n --target <site-dir> An already-scaffolded repo (contains\n architecture.yaml). Defaults to the current dir.\n --help, -h Print this message.\n\nWrites only the look: DESIGN.md (replaced whole by the design's, keeping the\nsite's Source template and Version lines), the theme: block of\ncontent/_site.mdx, the generated apps/web/lib/site-chrome.ts, the generated\napps/web/lib/site-fonts.ts, the look fields of sections the site's pages\nalready place, and the design/layout/theme record in .ailk-site-manifest.json.\nNever adds, removes or renames a route or page, never touches a content field\n(except a placeholder home's sections, below) or the brand: block, and leaves\nevery other page's structured data unchanged.\n\nA design may seed the placeholder home's sections \u2014 its home arc \u2014 while they\nare still a placeholder: the shape's own, or a design's arc of them. It never\nchanges a home whose sections were edited; it reports it and leaves it. A\nseeded home takes the design's looks; look values on the sections it replaced\nare not kept. A design that ships no arc restores the shape's own sections on\na home that still wears another design's untouched arc.\n\nA design other than standard needs a Pro or Pro Plus key: the site's own\nailk.key (put one there with create-ailk license set <key>), else\nAILK_LICENSE_KEY. A malformed package, a preset the validator refuses or a\nmissing key is refused before any file is written.\n\nA preset sets the look fields it names and never unsets one: a field it does\nnot name keeps whatever value the page has. Running it again changes nothing.\n";
80
+ export declare const APPLY_DESIGN_HELP = "create-ailk apply-design \u2014 make an existing site wear a design.\n\nUsage:\n create-ailk apply-design <name> [--target <site-dir>]\n create-ailk apply-design <path> [--target <site-dir>]\n\nArguments:\n <name|path> A shipped design (standard, minimalism), or a\n directory holding a DESIGN.md. A relative path\n resolves from the current directory.\n\nOptions:\n --target <site-dir> An already-scaffolded repo (contains\n architecture.yaml). Defaults to the current dir.\n --allow-unread-chrome Apply the design even when a file of the site does\n not read a chrome setting the design sets (a site\n scaffolded before the setting existed). Without it\n that is refused; with it each such file is reported.\n --help, -h Print this message.\n\nWrites only the look: DESIGN.md (replaced whole by the design's, keeping the\nsite's Source template and Version lines), the theme: block of\ncontent/_site.mdx, the generated apps/web/lib/site-chrome.ts, the generated\napps/web/lib/site-fonts.ts, the look fields of sections the site's pages\nalready place, and the design/layout/theme record in .ailk-site-manifest.json.\nNever adds, removes or renames a route or page, never touches a content field\n(except a placeholder home's sections, below) or the brand: block, and leaves\nevery other page's structured data unchanged.\n\nA design may seed the placeholder home's sections \u2014 its home arc \u2014 while they\nare still a placeholder: the shape's own, or a design's arc of them. It never\nchanges a home whose sections were edited; it reports it and leaves it. A\nseeded home takes the design's looks; look values on the sections it replaced\nare not kept. A design that ships no arc restores the shape's own sections on\na home that still wears another design's untouched arc.\n\nA design other than standard needs a Pro or Pro Plus key: the site's own\nailk.key (put one there with create-ailk license set <key>), else\nAILK_LICENSE_KEY. A malformed package, a preset the validator refuses or a\nmissing key is refused before any file is written.\n\nA preset sets the look fields it names and never unsets one: a field it does\nnot name keeps whatever value the page has. Running it again changes nothing.\n";
70
81
  /**
71
82
  * Parse the argv AFTER the `apply-design` word (D1). Strict `node:util`
72
83
  * parseArgs, like `parseSyncRoutesArgs`: exactly one positional, the design
@@ -22,6 +22,7 @@ import { compileDesignFonts, compileDesignTheme } from "./design-compile.js";
22
22
  import { HOME_PAGE_PATH, designHomeSeeds, planDesignHome, shapeHomePath, shippedHomeSeeds, } from "./design-home.js";
23
23
  import { applyDesignPresets, readSectionLookCatalog, shippedPageTypes, validateDesignPresets, } from "./design-presets.js";
24
24
  import { resolveDesign, spliceThemeBlock } from "./design-scaffold.js";
25
+ import { chromeKnobReaders, unreadChromeKnobs } from "./lib/chrome-readers.js";
25
26
  import { LICENSE_KEY_FILE } from "./lib/license-key-files.js";
26
27
  import { CONVERSION_HINT_URL, LicenseRequiredError, assertDesignAllowed, deriveLevel, } from "./lib/module-license-gate.js";
27
28
  import { LEVEL_LABEL } from "./lib/module-tier.js";
@@ -29,6 +30,17 @@ import { ParseError } from "./parse-args.js";
29
30
  import { SITE_CHROME_PATH, renderSiteChrome, resolveSiteChrome, } from "./site-chrome.js";
30
31
  import { SITE_FONTS_PATH, renderSiteFonts } from "./site-fonts.js";
31
32
  import { SITE_ROSTER_PATH, planHomeRecord, planSiteManifestDesign, readSiteRoster, } from "./site-roster.js";
33
+ /** #7781 — thrown before any write when the design sets a chrome knob that a reader file of the site does not read. */
34
+ export class UnreadChromeError extends Error {
35
+ unread;
36
+ constructor(unread) {
37
+ super(`design chrome would not render: the site's own copy of these files does not read the knob the design sets (update them from the current create-ailk overlay, or pass --allow-unread-chrome to apply anyway):\n${unread
38
+ .map(({ knob, file }) => ` ${knob} — ${file}`)
39
+ .join("\n")}`);
40
+ this.unread = unread;
41
+ this.name = "UnreadChromeError";
42
+ }
43
+ }
32
44
  /** The site's locale layout — the one reader of the generated site-fonts.ts. */
33
45
  const SITE_LAYOUT_PATH = "apps/web/app/[locale]/layout.tsx";
34
46
  export const APPLY_DESIGN_HELP = `create-ailk apply-design — make an existing site wear a design.
@@ -45,6 +57,10 @@ Arguments:
45
57
  Options:
46
58
  --target <site-dir> An already-scaffolded repo (contains
47
59
  architecture.yaml). Defaults to the current dir.
60
+ --allow-unread-chrome Apply the design even when a file of the site does
61
+ not read a chrome setting the design sets (a site
62
+ scaffolded before the setting existed). Without it
63
+ that is refused; with it each such file is reported.
48
64
  --help, -h Print this message.
49
65
 
50
66
  Writes only the look: DESIGN.md (replaced whole by the design's, keeping the
@@ -83,6 +99,7 @@ export function parseApplyDesignArgs(argv) {
83
99
  allowPositionals: true,
84
100
  options: {
85
101
  target: { type: "string" },
102
+ "allow-unread-chrome": { type: "boolean", default: false },
86
103
  help: { type: "boolean", default: false, short: "h" },
87
104
  },
88
105
  });
@@ -94,7 +111,12 @@ export function parseApplyDesignArgs(argv) {
94
111
  if (design === undefined && !help) {
95
112
  throw new ParseError("apply-design needs a design. Usage: create-ailk apply-design <name|path> [--target <site-dir>]");
96
113
  }
97
- return { design, target: parsed.values.target, help };
114
+ return {
115
+ design,
116
+ target: parsed.values.target,
117
+ allowUnreadChrome: parsed.values["allow-unread-chrome"] === true,
118
+ help,
119
+ };
98
120
  }
99
121
  /** `abs` relative to `root`, with `/` separators. */
100
122
  function siteRelative(root, abs) {
@@ -189,12 +211,18 @@ export async function applyDesign(opts) {
189
211
  throw new LicenseRequiredError(`design "${resolved.display}" is a Pro feature — this site's licence level is ${LEVEL_LABEL[level]}; put a Pro or Pro Plus key in the site with create-ailk license set <key>, or set AILK_LICENSE_KEY → ${CONVERSION_HINT_URL}`);
190
212
  }
191
213
  }
214
+ // #7781 — after the licence gate, before any plan: every chrome knob the design sets must be read by
215
+ // the site's own copy of each overlay file that reads it. The reader map is derived from the overlay.
216
+ const roster = readSiteRoster(targetAbs);
217
+ const chrome = resolveSiteChrome(roster?.template.layout, pkg.layout);
218
+ const templatesDir = opts.templatesDir ?? join(dirname(opts.siteTemplatesDir), "templates");
219
+ const unreadChrome = unreadChromeKnobs(chrome, chromeKnobReaders(templatesDir), targetAbs);
220
+ if (unreadChrome.length > 0 && opts.allowUnreadChrome !== true)
221
+ throw new UnreadChromeError(unreadChrome);
192
222
  // C6 / D4 — plan every write in memory.
193
223
  const theme = compileDesignTheme(pkg);
194
224
  const siteMdxCurrent = readFileSync(siteMdxPath, "utf8");
195
225
  const siteMdxNext = spliceThemeBlock(siteMdxCurrent, theme, siteMdxPath);
196
- const roster = readSiteRoster(targetAbs);
197
- const chrome = resolveSiteChrome(roster?.template.layout, pkg.layout);
198
226
  const chromePath = join(targetAbs, SITE_CHROME_PATH);
199
227
  const chromeCurrent = existsSync(chromePath) ? readFileSync(chromePath, "utf8") : undefined;
200
228
  const chromeNext = renderSiteChrome(chrome);
@@ -229,7 +257,7 @@ export async function applyDesign(opts) {
229
257
  ? ""
230
258
  : readFileSync(shapeHomePath(shape, {
231
259
  siteTemplatesDir: opts.siteTemplatesDir,
232
- templatesDir: opts.templatesDir ?? join(dirname(opts.siteTemplatesDir), "templates"),
260
+ templatesDir,
233
261
  }), "utf8"),
234
262
  shippedSeeds,
235
263
  catalog: catalog(),
@@ -328,6 +356,7 @@ export async function applyDesign(opts) {
328
356
  ...(roster !== undefined ? { rosterPath: roster.path } : {}),
329
357
  ...(fontFamilies !== undefined ? { fonts: fontFamilies } : {}),
330
358
  ...(fontsUnwired ? { fontsUnwired: true } : {}),
359
+ ...(unreadChrome.length > 0 ? { unreadChrome } : {}),
331
360
  };
332
361
  }
333
362
  /** The changed-file report (D9). */
@@ -364,6 +393,9 @@ export function formatApplyDesignReport(result) {
364
393
  if (result.fontsUnwired === true) {
365
394
  lines.push(`fonts not loaded: ${SITE_LAYOUT_PATH} predates lib/site-fonts — update the layout from the current create-ailk overlay`);
366
395
  }
396
+ for (const { knob, file } of result.unreadChrome ?? []) {
397
+ lines.push(`chrome not rendered: ${knob} — ${file} does not read it`);
398
+ }
367
399
  if (result.manifest === "legacy-roster") {
368
400
  lines.push(`manifest not recorded: ${result.rosterPath} predates the design record`);
369
401
  }
package/dist/cli.js CHANGED
@@ -203,6 +203,7 @@ async function main() {
203
203
  siteTemplatesDir: resolve(PKG_ROOT, "site-templates"),
204
204
  templatesDir: resolve(PKG_ROOT, "templates"),
205
205
  catalogPath: resolve(PKG_ROOT, "component-catalog.json"),
206
+ allowUnreadChrome: args.allowUnreadChrome,
206
207
  });
207
208
  process.stdout.write(formatApplyDesignReport(result));
208
209
  process.exit(0);
@@ -318,7 +319,8 @@ async function main() {
318
319
  // marketing-site-free` still rides the no-key case (record D11): its
319
320
  // `requires` is all-free, so it installs from the shipped
320
321
  // `site-templates/` tree with no key and no request.
321
- const { targetDir: targetAbs, surfaces, starter, modules: effectiveModules, } = await scaffold(toScaffoldOptions(opts));
322
+ const { targetDir: targetAbs, surfaces, starter, modules: effectiveModules, droppedModules, hubOrigin, } = await scaffold(toScaffoldOptions(opts));
323
+ const isSpoke = opts.topology === "spoke";
322
324
  // The starter scaffold() resolved — from --template, or from --shape (#6754 D4).
323
325
  const templateName = starter?.template ?? opts.template;
324
326
  if (opts.install) {
@@ -346,6 +348,22 @@ async function main() {
346
348
  ...(starter !== undefined
347
349
  ? [`Starter: shape ${starter.shape}, design ${starter.design}.`]
348
350
  : []),
351
+ // #7426 — a spoke says so, and names what its hosts must carry (D3).
352
+ ...(isSpoke
353
+ ? [
354
+ `Topology: spoke — no apps/api, no database, no sign-in${opts.mcp === false ? ", no apps/mcp" : "; apps/mcp is a stateless front"}.`,
355
+ ...(droppedModules !== undefined && droppedModules.length > 0
356
+ ? [
357
+ `Not carried on a spoke: ${droppedModules.join(", ")} (your licence includes ${droppedModules.length === 1 ? "it" : "them"}; a spoke does not admit ${droppedModules.length === 1 ? "it" : "them"} yet).`,
358
+ ]
359
+ : []),
360
+ "",
361
+ `Every host serving this spoke's ${opts.mcp === false ? "apps/web" : "apps/web and apps/mcp"} must carry:`,
362
+ ` AILK_API_URL=${hubOrigin ?? "<your hub origin>"} # written to apps/web/.env.local`,
363
+ " AILK_SPOKE_KEY # issued by the hub; never written here",
364
+ " AILK_VISITOR_FORWARD_KEY # issued by the hub; never written here",
365
+ ]
366
+ : []),
349
367
  "",
350
368
  ];
351
369
  // Prerequisites (#6440 AC4) — every one the scaffold enforces, and nothing
@@ -374,13 +392,15 @@ async function main() {
374
392
  lines.push("Next steps:");
375
393
  lines.push(` cd ${targetAbs}`);
376
394
  if (!opts.install) {
377
- lines.push(" pnpm install # also generates the Prisma client");
395
+ lines.push(isSpoke ? " pnpm install" : " pnpm install # also generates the Prisma client");
378
396
  }
379
397
  lines.push(" pnpm dev # http://localhost:3000");
380
398
  lines.push(" pnpm build # production build, when you are ready to deploy");
381
399
  lines.push("");
382
400
  lines.push("Open http://localhost:3000 to see the home page.");
383
- lines.push("Adding auth, saved flows, checkout or database-backed content later? README.md \u00a7 \"When you need a database\" has the one-command setup.");
401
+ if (!isSpoke) {
402
+ lines.push("Adding auth, saved flows, checkout or database-backed content later? README.md \u00a7 \"When you need a database\" has the one-command setup.");
403
+ }
384
404
  lines.push("");
385
405
  lines.push("Claude Code: the AI Launch Kit skills come from the ailk plugin. Trust this folder and Claude Code loads it, or run in a session:");
386
406
  for (const line of PLUGIN_INSTALL_LINES)
@@ -1,3 +1,11 @@
1
+ import type { Topology } from "./topology.js";
2
+ /** The topology a document is derived for (#7426 D8). */
3
+ export interface DeriveArchitectureOptions {
4
+ /** `"hub"` (the default) or `"spoke"`. */
5
+ topology?: Topology;
6
+ /** Spoke only: whether the spoke keeps `apps/mcp` (default true). */
7
+ mcp?: boolean;
8
+ }
1
9
  /**
2
10
  * Derive the `architecture.yaml` document for a module set.
3
11
  *
@@ -6,8 +14,13 @@
6
14
  * @param surfaces the enabled gateable route surfaces. Omitted ⟹ derived from
7
15
  * the module set alone (`resolveSurfaces`), so a caller that passes only
8
16
  * modules gets exactly what the modules imply.
17
+ * @param options the topology (#7426 D8). Absent or `"hub"` ⟹ the output is
18
+ * byte-identical to the two-argument form. `"spoke"` ⟹ only the spoke's own
19
+ * apps (`web`, and `mcp` unless `mcp: false`), no `namespaces`, no
20
+ * `database` root, a top-level `topology: spoke` and the hub-facing
21
+ * composition tiers.
9
22
  * @returns the complete `architecture.yaml` file content (a deterministic
10
- * function of the module SET and the surface SET).
23
+ * function of the module SET, the surface SET and the topology).
11
24
  * @throws Error on an unrecognized module (defensive; parse-args rejects first).
12
25
  */
13
- export declare function deriveArchitecture(modules?: string[], surfaces?: string[]): string;
26
+ export declare function deriveArchitecture(modules?: string[], surfaces?: string[], options?: DeriveArchitectureOptions): string;
@@ -15,6 +15,12 @@
15
15
  */
16
16
  import { ARCHITECTURE_BASE, MODULE_SLICES, normalizeModules, } from "./module-architecture.js";
17
17
  import { CORE_SURFACE_PATHS, RECOGNIZED_SURFACES, resolveSurfaces, } from "./surfaces.js";
18
+ /** A spoke's composition tiers (#7426 D8): its data and API are the hub's. */
19
+ const SPOKE_TIERS = {
20
+ data: "none — the hub's database",
21
+ api: "the hub at AILK_API_URL (apps/mcp is a stateless front)",
22
+ presentation: "apps/web",
23
+ };
18
24
  function isScalar(v) {
19
25
  return typeof v === "string" || typeof v === "number";
20
26
  }
@@ -150,11 +156,18 @@ function namespacesToYaml(ns) {
150
156
  * @param surfaces the enabled gateable route surfaces. Omitted ⟹ derived from
151
157
  * the module set alone (`resolveSurfaces`), so a caller that passes only
152
158
  * modules gets exactly what the modules imply.
159
+ * @param options the topology (#7426 D8). Absent or `"hub"` ⟹ the output is
160
+ * byte-identical to the two-argument form. `"spoke"` ⟹ only the spoke's own
161
+ * apps (`web`, and `mcp` unless `mcp: false`), no `namespaces`, no
162
+ * `database` root, a top-level `topology: spoke` and the hub-facing
163
+ * composition tiers.
153
164
  * @returns the complete `architecture.yaml` file content (a deterministic
154
- * function of the module SET and the surface SET).
165
+ * function of the module SET, the surface SET and the topology).
155
166
  * @throws Error on an unrecognized module (defensive; parse-args rejects first).
156
167
  */
157
- export function deriveArchitecture(modules = ["marketing"], surfaces) {
168
+ export function deriveArchitecture(modules = ["marketing"], surfaces, options = {}) {
169
+ const spoke = options.topology === "spoke";
170
+ const keepMcp = options.mcp !== false;
158
171
  const selected = normalizeModules(modules);
159
172
  const slices = selected.map((m) => MODULE_SLICES[m]);
160
173
  // The declared route surface (#4154). A scaffold's live-route set is a
@@ -180,12 +193,20 @@ export function deriveArchitecture(modules = ["marketing"], surfaces) {
180
193
  };
181
194
  for (const s of slices)
182
195
  Object.assign(declared, s.apps ?? {});
196
+ // A spoke carries only its own apps (#7426 D8): web, and mcp unless
197
+ // `--without-mcp` — never api, never a module's admin.
198
+ if (spoke) {
199
+ for (const key of Object.keys(declared)) {
200
+ if (key !== "web" && !(key === "mcp" && keepMcp))
201
+ delete declared[key];
202
+ }
203
+ }
183
204
  // A scaffold carries no `packages/` tree — `@working-theory/*` install from
184
205
  // npm — so an entry naming a monorepo folder is dropped rather than shipped
185
206
  // as a rule the linter can never apply (#6940). What survives names a path
186
207
  // the scaffold has: a declared app, or `database/`.
187
208
  const roots = [
188
- "database",
209
+ ...(spoke ? [] : ["database"]),
189
210
  ...Object.values(declared).map((a) => a.path),
190
211
  ];
191
212
  const inScaffold = (p) => {
@@ -209,8 +230,11 @@ export function deriveArchitecture(modules = ["marketing"], surfaces) {
209
230
  forbidden_patterns: mergeForbidden([...ARCHITECTURE_BASE.appForbiddenPatterns], a.forbidden_patterns ?? []).map(scoped),
210
231
  });
211
232
  }
212
- // namespaces — present only if a data-tier module contributes one.
213
- const nsSlice = slices.find((s) => s.namespaces)?.namespaces;
233
+ // namespaces — present only if a data-tier module contributes one, and
234
+ // never on a spoke, which has no database/ of its own.
235
+ const nsSlice = spoke
236
+ ? undefined
237
+ : slices.find((s) => s.namespaces)?.namespaces;
214
238
  // forbidden_patterns — base floor first, then the selected modules' patterns.
215
239
  const extraForbidden = [];
216
240
  for (const s of slices)
@@ -218,8 +242,11 @@ export function deriveArchitecture(modules = ["marketing"], surfaces) {
218
242
  const forbidden = mergeForbidden([...ARCHITECTURE_BASE.forbiddenPatterns], extraForbidden).map(scoped);
219
243
  const doc = {
220
244
  version: ARCHITECTURE_BASE.version,
245
+ ...(spoke ? { topology: "spoke" } : {}),
221
246
  composition_model: {
222
- tiers: { ...ARCHITECTURE_BASE.composition_model.tiers },
247
+ tiers: spoke
248
+ ? { ...SPOKE_TIERS }
249
+ : { ...ARCHITECTURE_BASE.composition_model.tiers },
223
250
  },
224
251
  surfaces: {
225
252
  core: [...CORE_SURFACE_PATHS],
@@ -240,6 +267,7 @@ export function deriveArchitecture(modules = ["marketing"], surfaces) {
240
267
  "#",
241
268
  `# Modules: ${selected.join(", ")}`,
242
269
  `# Surfaces: ${enabledSurfaces.length > 0 ? enabledSurfaces.join(", ") : "core only"}`,
270
+ ...(spoke ? ["# Topology: spoke"] : []),
243
271
  "",
244
272
  ].join("\n");
245
273
  return header + emitDocument(doc);
@@ -207,7 +207,7 @@ function parseScalar(raw, lineNo, file) {
207
207
  * The one dotted key path whose value may be a block sequence (#7719 D2) —
208
208
  * the deck's ordered ground list. Every other `- ` line is refused as before.
209
209
  */
210
- const SEQUENCE_PATHS = new Set(["layout.deckGrounds"]);
210
+ const SEQUENCE_PATHS = new Set(["layout.deckGrounds", "layout.closingFooterPaths"]);
211
211
  /**
212
212
  * Parse a block sequence of scalars whose `- ` items sit at `pos.i`'s indent
213
213
  * (#7719 D2). Each item's text is read by `parseScalar`, with every refusal it
@@ -487,7 +487,7 @@ function validateStringMap(raw, file, field) {
487
487
  const LAYOUT_KEYS = ["nav", "footer", "announcementBanner", "home"];
488
488
  /** Every key a package's `layout` may carry — #7719 D3 adds the optional `deckGrounds` (a deck home only),
489
489
  * #7721 D1 the optional `innerPages`, #7740 D1 the optional `deckPresentation` (a deck home only),
490
- * #7742 D1 the optional `colorMode` and `chromeStyle`. */
490
+ * #7742 D1 the optional `colorMode` and `chromeStyle`, #7797 D1 the optional `closingFooterPaths`. */
491
491
  const ALLOWED_LAYOUT_KEYS = new Set([
492
492
  ...LAYOUT_KEYS,
493
493
  "deckGrounds",
@@ -495,6 +495,7 @@ const ALLOWED_LAYOUT_KEYS = new Set([
495
495
  "deckPresentation",
496
496
  "colorMode",
497
497
  "chromeStyle",
498
+ "closingFooterPaths",
498
499
  ]);
499
500
  function validateLayout(raw, file) {
500
501
  const obj = requireObject(raw, file, "layout");
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export { CATALOG_TARGET_PATH, FREE_MODULES, addModule, LicenseRequiredError, PAI
9
9
  export { DEFAULT_AILK_MODULE_REGISTRY_URL, FetchModuleError, fetchModule, type FetchModuleOptions, } from "./lib/fetch-module.js";
10
10
  export { extractModuleBundle, type ExistingFilePolicy, type ExtractModuleBundleOptions, type InstallReportRow, } from "./lib/extract-module-bundle.js";
11
11
  export { ADD_MODULE_HELP, AddModuleRefusedError, applyModuleToScaffold, formatAddModuleReport, parseAddModuleArgs, type AddModuleReportRow, type AddModuleResult, type ApplyModuleOptions, } from "./add-module.js";
12
- export { APPLY_DESIGN_HELP, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, type ApplyDesignArgs, type ApplyDesignChange, type ApplyDesignOptions, type ApplyDesignResult, } from "./apply-design.js";
12
+ export { APPLY_DESIGN_HELP, UnreadChromeError, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, type ApplyDesignArgs, type ApplyDesignChange, type ApplyDesignOptions, type ApplyDesignResult, } from "./apply-design.js";
13
13
  export type { HomeSeedOutcome } from "./design-home.js";
14
14
  export { SITE_ROSTER_PATH, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
15
15
  export { SYNC_ROUTES_HELP, formatSyncRoutesReport, listTemplateRouteDirs, parseSyncRoutesArgs, syncRoutes, type RouteReport, type SyncRoutesOptions, type SyncRoutesResult, } from "./sync-routes.js";
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ export { extractModuleBundle, } from "./lib/extract-module-bundle.js";
16
16
  export { ADD_MODULE_HELP, AddModuleRefusedError, applyModuleToScaffold, formatAddModuleReport, parseAddModuleArgs, } from "./add-module.js";
17
17
  // `create-ailk apply-design` (#6819) — make an existing site wear a design,
18
18
  // changing only its look. The pure core, beside `add-module`'s.
19
- export { APPLY_DESIGN_HELP, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, } from "./apply-design.js";
19
+ export { APPLY_DESIGN_HELP, UnreadChromeError, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, } from "./apply-design.js";
20
20
  // The site's page roster of record and its install-diff check (#6682).
21
21
  export { SITE_ROSTER_PATH, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
22
22
  // `create-ailk sync-routes` (#4986) — the template-route drift check + copy.
@@ -0,0 +1,21 @@
1
+ import { type ResolvedSiteChrome } from "../site-chrome.js";
2
+ /** One knob a design sets that one of the site's reader files does not read. */
3
+ export interface UnreadChromeKnob {
4
+ knob: string;
5
+ /** Overlay-relative path of the reader file, with `/` separators. */
6
+ file: string;
7
+ }
8
+ /** The pattern that means "this text reads `knob`". */
9
+ export declare function chromeReadPattern(knob: string): RegExp;
10
+ /**
11
+ * knob → the overlay files that read it, sorted. Scans `<templatesDir>/apps/web`;
12
+ * fails closed (throws, naming the directory) when it is absent or unreadable,
13
+ * so a broken overlay can never read as "nothing reads any knob".
14
+ */
15
+ export declare function chromeKnobReaders(templatesDir: string): Map<string, string[]>;
16
+ /**
17
+ * The `(knob, file)` pairs where the resolved chrome sets a non-default `knob`,
18
+ * the shipped overlay's `file` reads it, the site has its own `file`, and the
19
+ * site's `file` does not read it. A reader file the site lacks is skipped.
20
+ */
21
+ export declare function unreadChromeKnobs(resolved: ResolvedSiteChrome, readers: ReadonlyMap<string, readonly string[]>, siteDir: string): UnreadChromeKnob[];
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Which generated app files read which chrome knob (#7781, spec
3
+ * docs/specs/apply-design-unread-chrome-spec.md).
4
+ *
5
+ * `apply-design` writes a design's chrome knobs into the site's generated
6
+ * `apps/web/lib/site-chrome.ts`; other generated files read them. A site
7
+ * scaffolded before a knob existed has a reader file that ignores it, so the
8
+ * design is half-applied. These pure functions find that gap:
9
+ *
10
+ * - `chromeKnobReaders` derives, from the shipped overlay, the files that read
11
+ * each knob — nothing is hard-coded, so a new reader file is picked up.
12
+ * - `unreadChromeKnobs` compares the site's own copy of each reader file.
13
+ *
14
+ * "Reads `knob`" means the file's text matches `(?:siteChrome|chrome)\.<knob>\b`.
15
+ * A file that reads a knob under another name (a destructured alias) is not
16
+ * recognised — accepted (spec § Clarifications, detection).
17
+ */
18
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { DEFAULT_SITE_CHROME, } from "../site-chrome.js";
21
+ /** The overlay tree the generated app files live under. */
22
+ const OVERLAY_WEB_DIR = ["apps", "web"];
23
+ /** What precedes a knob name in a read: `siteChrome.` or `chrome.` — the one definition both the overlay scan and the per-site check use. */
24
+ const READ_HEAD = "(?:siteChrome|chrome)\\.";
25
+ /** The pattern that means "this text reads `knob`". */
26
+ export function chromeReadPattern(knob) {
27
+ return new RegExp(`${READ_HEAD}${knob.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
28
+ }
29
+ /** Every `.ts`/`.tsx` file under `dir` outside a `__tests__` segment, as `/`-joined paths relative to `root`. */
30
+ function listSources(root, dir, out) {
31
+ for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
32
+ const rel = `${dir}/${entry.name}`;
33
+ if (entry.isDirectory()) {
34
+ if (entry.name !== "__tests__")
35
+ listSources(root, rel, out);
36
+ }
37
+ else if (/\.tsx?$/.test(entry.name)) {
38
+ out.push(rel);
39
+ }
40
+ }
41
+ }
42
+ /**
43
+ * knob → the overlay files that read it, sorted. Scans `<templatesDir>/apps/web`;
44
+ * fails closed (throws, naming the directory) when it is absent or unreadable,
45
+ * so a broken overlay can never read as "nothing reads any knob".
46
+ */
47
+ export function chromeKnobReaders(templatesDir) {
48
+ const webDir = join(templatesDir, ...OVERLAY_WEB_DIR);
49
+ const readers = new Map();
50
+ try {
51
+ if (!existsSync(webDir))
52
+ throw new Error("missing");
53
+ const files = [];
54
+ listSources(templatesDir, OVERLAY_WEB_DIR.join("/"), files);
55
+ for (const file of files.sort()) {
56
+ const text = readFileSync(join(templatesDir, file), "utf8");
57
+ for (const match of text.matchAll(new RegExp(`${READ_HEAD}([A-Za-z_$][\\w$]*)\\b`, "g"))) {
58
+ const knob = match[1];
59
+ const list = readers.get(knob) ?? [];
60
+ if (!list.includes(file))
61
+ list.push(file);
62
+ readers.set(knob, list);
63
+ }
64
+ }
65
+ }
66
+ catch {
67
+ throw new Error(`cannot check chrome knobs: the overlay has no readable apps/web at ${webDir}. Reinstall create-ailk.`);
68
+ }
69
+ return readers;
70
+ }
71
+ /** A knob is set when it differs from its default; an optional knob (no default) when present. */
72
+ function isSet(resolved, knob) {
73
+ const value = resolved[knob];
74
+ if (value === undefined)
75
+ return false;
76
+ if (!(knob in DEFAULT_SITE_CHROME))
77
+ return true;
78
+ return (value !== DEFAULT_SITE_CHROME[knob]);
79
+ }
80
+ /**
81
+ * The `(knob, file)` pairs where the resolved chrome sets a non-default `knob`,
82
+ * the shipped overlay's `file` reads it, the site has its own `file`, and the
83
+ * site's `file` does not read it. A reader file the site lacks is skipped.
84
+ */
85
+ export function unreadChromeKnobs(resolved, readers, siteDir) {
86
+ const out = [];
87
+ for (const knob of Object.keys(resolved).sort()) {
88
+ if (!isSet(resolved, knob))
89
+ continue;
90
+ const pattern = chromeReadPattern(knob);
91
+ for (const file of readers.get(knob) ?? []) {
92
+ const abs = join(siteDir, file);
93
+ if (!existsSync(abs))
94
+ continue;
95
+ if (!pattern.test(readFileSync(abs, "utf8")))
96
+ out.push({ knob, file });
97
+ }
98
+ }
99
+ return out;
100
+ }