create-ailk 0.28.0 → 0.29.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 (37) hide show
  1. package/README.md +124 -1
  2. package/dist/cli.js +92 -2
  3. package/dist/lib/chrome-readers.d.ts +21 -5
  4. package/dist/lib/chrome-readers.js +128 -20
  5. package/dist/lib/declared-env.d.ts +6 -0
  6. package/dist/lib/declared-env.js +2 -0
  7. package/dist/lib/import-graph.d.ts +16 -0
  8. package/dist/lib/import-graph.js +53 -0
  9. package/dist/lib/import-mdx.d.ts +59 -0
  10. package/dist/lib/import-mdx.js +452 -0
  11. package/dist/migrate-content.d.ts +45 -0
  12. package/dist/migrate-content.js +215 -0
  13. package/dist/seed-workspace.d.ts +71 -0
  14. package/dist/seed-workspace.js +242 -0
  15. package/dist/template-record.d.ts +13 -4
  16. package/dist/template-record.js +8 -4
  17. package/dist/update.d.ts +32 -2
  18. package/dist/update.js +357 -44
  19. package/package.json +3 -3
  20. package/templates/apps/api/CLAUDE.md +1 -1
  21. package/templates/apps/mcp/CLAUDE.md +16 -12
  22. package/templates/apps/web/CLAUDE.md +11 -1
  23. package/templates/apps/web/app/[locale]/[slug]/page.tsx +4 -0
  24. package/templates/apps/web/app/[locale]/alternatives/page.tsx +9 -1
  25. package/templates/apps/web/app/[locale]/careers/page.tsx +23 -15
  26. package/templates/apps/web/app/[locale]/clients/page.tsx +13 -1
  27. package/templates/apps/web/app/[locale]/faq/page.tsx +9 -0
  28. package/templates/apps/web/app/[locale]/glossary/page.tsx +3 -0
  29. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +1 -0
  30. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +1 -0
  31. package/templates/apps/web/app/[locale]/templates/page.tsx +6 -0
  32. package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
  33. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +1 -0
  34. package/templates/apps/web/lib/hub-children.tsx +38 -9
  35. package/templates/apps/web/messages/en.json +22 -2
  36. package/templates/tools/declared-env.ts +7 -0
  37. package/versions.json +48 -48
package/README.md CHANGED
@@ -552,6 +552,128 @@ deliverables, pricing, registers, listings — so a flow that needs one is
552
552
  authored in the hub's Admin; once it carries one, the hub refuses a seed of
553
553
  that flow (`409 slug_owned`) rather than delete the block.
554
554
 
555
+ ### Seeding the workspace
556
+
557
+ On a site with an Admin (the `workspace` or `multi-tenant` module), the
558
+ starter's pages are also rows in the site's workspace, so the Admin's Docs
559
+ lists Home, About, Contact and every other roster page on first open.
560
+ `seed-workspace` writes them there: each `content/en/<Type>/<slug>.mdx` page
561
+ becomes one draft `text/page` Artifact.
562
+
563
+ ```bash
564
+ AILK_AGENT_KEY=<agent key> create-ailk seed-workspace ./my-site --api-url <api origin> --workspace <wksp_id>
565
+ ```
566
+
567
+ - The API origin is `--api-url`, else `AILK_API_URL`; it must be `https:` (or
568
+ `http:` on a loopback host). The workspace id is `--workspace`, else
569
+ `AILK_WORKSPACE_ID`. The first line printed names the workspace, the origin
570
+ and the origin's source, before any request.
571
+ - The agent key is read from `AILK_AGENT_KEY` only — never a flag or a file —
572
+ and never printed. An owner issues it in the Admin with the `content-edit`
573
+ grant.
574
+ - Each page prints `created`, `updated`, `unchanged`, `skipped` or `failed`,
575
+ then a count line. A re-run creates nothing and reports every page
576
+ `unchanged`. An agent writes ten times a minute, so a first seed of about
577
+ thirty pages waits out the rate limit a few times.
578
+ - The site's dependencies must be installed (`pnpm install`): the pages are
579
+ read through the site's own `@working-theory/content-adapters`.
580
+ - The API must run with `content-write` and a tenancy module (`workspace` or
581
+ `multi-tenant`) in `AILK_ENABLED_MODULES`. Without them the content routes
582
+ are not served and every page fails `404`.
583
+ - Exit 0 when every page was seeded, 1 when any failed, 2 on a refusal (no
584
+ `.ailk-site-manifest.json`, a spoke, a missing key, workspace id or origin,
585
+ the adapter not installed), which sends nothing.
586
+
587
+ `pnpm create ailk` runs the same seed as its last step, after `--install`,
588
+ when the site has an Admin and `AILK_API_URL`, `AILK_WORKSPACE_ID` and
589
+ `AILK_AGENT_KEY` are all set. When the site has an Admin but one is missing,
590
+ or `--install` was not passed, it prints that seeding was skipped and the
591
+ command above. A site with no Admin, or a spoke, prints nothing about it. A
592
+ seed that fails or is refused at scaffold time prints why and never fails the
593
+ scaffold; `seed-workspace` is where a seed's result is an exit code.
594
+
595
+ On a site carrying `signal-collectors`, the two `ThankYouPage` seeds
596
+ `contact` and `newsletter` share an address (locale and slug) with the
597
+ `ContactPage` and `NewsletterSignup` pages, so those two are reported
598
+ `failed` and the seed exits 1 until one side's slug changes.
599
+
600
+ To check a seed:
601
+
602
+ ```bash
603
+ cd my-site
604
+ # 1. Re-run the seed: nothing may be created, updated or fail.
605
+ AILK_AGENT_KEY=<agent key> create-ailk seed-workspace . --api-url <api origin> --workspace <wksp_id>
606
+ # expect the count line: created 0 · updated 0 · unchanged <N> · skipped <S> · failed 0
607
+ # 2. Read the workspace's pages back: at least <N> rows.
608
+ curl -sS -H "authorization: Bearer $AILK_AGENT_KEY" "<api origin>/v1/workspaces/<wksp_id>/content?locale=en" | jq '.data | length'
609
+ ```
610
+
611
+ ### Migrating an existing site's content
612
+
613
+ `migrate-content` imports the pages of a site that was not seeded at
614
+ scaffold time — a site with no `.ailk-site-manifest.json`, or a spoke — into
615
+ a workspace: each `content/en/<Type>/<slug>.mdx` page, and each legacy
616
+ `content/<Type>/<slug>.mdx` page, becomes one draft `text/page` Artifact.
617
+
618
+ ```bash
619
+ AILK_AGENT_KEY=<agent key> create-ailk migrate-content ./my-site --api-url <api origin> --workspace <wksp_id>
620
+ AILK_AGENT_KEY=<agent key> create-ailk migrate-content ./my-site --apply --api-url <api origin> --workspace <wksp_id>
621
+ ```
622
+
623
+ - It is a dry run by default and writes nothing: it reads the workspace,
624
+ prints what `--apply` would do to each page — `create`, `update`,
625
+ `unchanged`, `skipped` or `failed` — then a count line. A dry run never sends
626
+ a write: its transport answers a create or an update itself.
627
+ - `--apply` writes. Each page prints `created`, `updated`, `unchanged`,
628
+ `skipped` or `failed`, then a count line; a re-run creates nothing and
629
+ reports every page `unchanged`.
630
+ - The API origin is `--api-url`, else `AILK_API_URL`; it must be `https:` (or
631
+ `http:` on a loopback host). The workspace id is `--workspace`, else
632
+ `AILK_WORKSPACE_ID`. The agent key is read from `AILK_AGENT_KEY` only —
633
+ never a flag or a file — and never printed; it needs the `content-edit`
634
+ grant. The first line printed names the workspace, the origin and the
635
+ origin's source, before any request.
636
+ - On a spoke, the workspace is its hub's: pass the hub's API origin and the
637
+ spoke's workspace on the hub.
638
+ - Pages land as drafts and nothing is published, so the live site keeps
639
+ serving its MDX files and does not change.
640
+ - The plan cannot see a write the API would refuse at `--apply` (a conflict
641
+ with an edit made in between, or a server error): such a page is planned
642
+ `create` or `update`. A page whose content fails its page type is planned
643
+ and then saved with the errors the editor shows, not `failed`.
644
+ - The site's dependencies must be installed (`pnpm install`): the pages are
645
+ read through the site's own `@working-theory/content-adapters`.
646
+ - Exit 0 when no page failed, 1 when any did (in a dry run, when any would), 2
647
+ on a refusal (a missing key, workspace id or origin, no `content/`, the
648
+ adapter not installed), which sends nothing.
649
+
650
+ To migrate a live site and prove a visitor sees no change (samhenry.org's
651
+ sequence; the placeholders are yours to fill):
652
+
653
+ ```bash
654
+ cd <the samhenry repo> # dependencies installed (pnpm install)
655
+ export AILK_AGENT_KEY=<agent key> # content-edit, issued in the hub's Admin for the Sam Henry workspace
656
+ API=<hub api origin>; WS=<wksp_id>
657
+ # 0. Snapshot the live site before anything is written.
658
+ curl -sS https://samhenry.org/sitemap.xml | grep -o '<loc>[^<]*</loc>' | sed -e 's/<loc>//' -e 's/<\/loc>//' | sort -u > urls.txt
659
+ mkdir -p before after
660
+ n=0; while read -r u; do n=$((n+1)); curl -sS -o "before/$n.html" -w '%{http_code} %{url_effective}\n' "$u"; done < urls.txt > before/status.txt
661
+ # 1. Dry run: the plan; nothing is written. Expect failed 0 and exit 0.
662
+ npx create-ailk@<version> migrate-content . --api-url "$API" --workspace "$WS"
663
+ # 2. Apply. Expect: created <N> · updated 0 · unchanged 0 · skipped <S> · failed 0
664
+ npx create-ailk@<version> migrate-content . --apply --api-url "$API" --workspace "$WS"
665
+ # 3. Re-run: nothing changes. Expect: created 0 · updated 0 · unchanged <N> · skipped <S> · failed 0
666
+ npx create-ailk@<version> migrate-content . --apply --api-url "$API" --workspace "$WS"
667
+ # 4. Docs lists them: at least <N> rows (exactly <N> on a workspace that had no pages).
668
+ printf 'authorization: Bearer %s\n' "$AILK_AGENT_KEY" | curl -sS -H @- "$API/v1/workspaces/$WS/content?locale=en" | jq '.data | length'
669
+ # 5. The live diff: re-fetch the same URLs and compare. Expect no output from diff.
670
+ n=0; while read -r u; do n=$((n+1)); curl -sS -o "after/$n.html" -w '%{http_code} %{url_effective}\n' "$u"; done < urls.txt > after/status.txt
671
+ diff -r before after && echo "live site unchanged"
672
+ ```
673
+
674
+ A non-empty diff is a finding: stop and report it before migrating another
675
+ site.
676
+
555
677
  ### A spoke's declared environment
556
678
 
557
679
  A spoke scaffold's `pnpm preflight` and CI run `pnpm env:declared`: every
@@ -638,7 +760,8 @@ The network invariant (#6759 D6), in full:
638
760
  3. Substitutes `workspace:*` references in every `package.json` with the matching `@working-theory/*` semver from npm — versions are pinned at npm-publish time.
639
761
  4. Substitutes `{{PROJECT_NAME}}`, `{{COMPANY_NAME}}`, `{{COMPANY_DOMAIN}}` markers in `templates/README.md` from the target directory name (and CLI flags / interactive prompts in future versions).
640
762
  5. Derives and writes `architecture.yaml` (the site's code-boundary config) from the selected module set — see below.
641
- 6. Prints next steps: `cd <dir> && pnpm install && pnpm dev`.
763
+ 6. On a site with an Admin, seeds the starter's pages into its workspace when `--install` ran and `AILK_API_URL`, `AILK_WORKSPACE_ID` and `AILK_AGENT_KEY` are set, else prints why not and the `seed-workspace` command — see [Seeding the workspace](#seeding-the-workspace).
764
+ 7. Prints next steps: `cd <dir> && pnpm install && pnpm dev`.
642
765
 
643
766
  ## `architecture.yaml` is derived from the module set
644
767
 
package/dist/cli.js CHANGED
@@ -14,6 +14,15 @@
14
14
  * on a `seed-flows` subcommand: send a spoke's declared flows/*.json to
15
15
  * its hub with the key from AILK_SPOKE_KEY, print one line per flow,
16
16
  * exit 1 if any failed (2 on a refusal it names, nothing sent) (#7876)
17
+ * on a `seed-workspace` subcommand: import a starter's content/ pages
18
+ * into its workspace with the agent key from AILK_AGENT_KEY, print one
19
+ * line per page and the count line, exit 1 if any failed (2 on a
20
+ * refusal it names, nothing sent) (#10204)
21
+ * on a `migrate-content` subcommand: plan an existing site's content/
22
+ * pages against its workspace — or, with --apply, write them — with the
23
+ * agent key from AILK_AGENT_KEY, print one line per page and the count
24
+ * line, exit 1 if any failed (2 on a refusal it names, nothing sent)
25
+ * (#10207)
17
26
  * on a `sync-routes` subcommand: parse its own argv, run syncRoutesSync(),
18
27
  * print the report, exit (1 iff routes are missing in check mode)
19
28
  * on a `license set` subcommand: write the key into the scaffold, push it
@@ -31,7 +40,11 @@
31
40
  * template's own bundle (#5481), then validates + copies the local
32
41
  * template, then merges each fetched bundle in, the site template last
33
42
  * 5. when --install: spawn `pnpm install` (cwd = target, hard-coded argv)
34
- * 6. print next-steps message
43
+ * 6. on a hub with an Admin: seed the starter's pages into the workspace
44
+ * when --install ran and AILK_API_URL, AILK_WORKSPACE_ID and
45
+ * AILK_AGENT_KEY are set, else print why not and the command that seeds
46
+ * it later; never fails the scaffold (#10204)
47
+ * 7. print next-steps message
35
48
  *
36
49
  * Any thrown error (parse failure, validation failure, copy failure,
37
50
  * `LicenseRequiredError`) is caught at the top level: stderr
@@ -55,14 +68,17 @@ import { ADOPT_HELP, AdoptRefusedError, adoptSite, formatAdoptReport, parseAdopt
55
68
  import { APPLY_DESIGN_HELP, applyDesign, formatApplyDesignReport, parseApplyDesignArgs, } from "./apply-design.js";
56
69
  import { CLAUDE_MD_CHECK_PATH } from "./claude-md.js";
57
70
  import { buildScaffoldMarkers } from "./copy.js";
71
+ import { ImportMdxRefusedError } from "./lib/import-mdx.js";
58
72
  import { assertKeyedOrFreeReleased, assertReleased } from "./lib/module-license-gate.js";
59
73
  import { withNpmTokenUserConfig } from "./lib/npm-auth.js";
60
74
  import { FREE_PREREQUISITES_NOTE, LICENSED_PREREQUISITES_NOTE, isLicensedInstall, proCallout, } from "./lib/pro-callout.js";
61
75
  import { LICENSE_SET_HELP, runLicenseSet } from "./license-set.js";
62
- import { PLUGIN_INSTALL_LINES, helpText, parseCliArgs, parseModules, resolveCliShape, toScaffoldOptions, } from "./parse-args.js";
76
+ import { MIGRATE_CONTENT_HELP, MigrateContentRefusedError, migrateContent, parseMigrateContentArgs, } from "./migrate-content.js";
77
+ import { PLUGIN_INSTALL_LINES, ParseError, helpText, parseCliArgs, parseModules, resolveCliShape, toScaffoldOptions, } from "./parse-args.js";
63
78
  import { addModule, removeModule, formatSurfaceDrift, scaffold, syncArchitectureSync, syncCatalogSync, syncClaudeMdSync, syncRoutesSync, } from "./programmatic.js";
64
79
  import { REMOVE_MODULE_HELP, RemoveModuleRefusedError, formatRemoveModuleReport, parseRemoveModuleArgs, } from "./remove-module.js";
65
80
  import { SEED_FLOWS_HELP, SeedFlowsRefusedError, parseSeedFlowsArgs, seedFlows, } from "./seed-flows.js";
81
+ import { SEED_WORKSPACE_HELP, SeedWorkspaceRefusedError, parseSeedWorkspaceArgs, runScaffoldSeed, seedWorkspace, } from "./seed-workspace.js";
66
82
  import { CORE_SURFACE_PATHS, SURFACE_ALIASES } from "./surfaces.js";
67
83
  import { SYNC_ROUTES_HELP, formatSyncRoutesReport, parseSyncRoutesArgs, } from "./sync-routes.js";
68
84
  import { UPDATE_HELP, UpdateRefusedError, formatUpdateReport, parseUpdateArgs, updateSite, } from "./update.js";
@@ -271,6 +287,67 @@ async function main() {
271
287
  process.exit(err.exitCode);
272
288
  }
273
289
  }
290
+ // `seed-workspace [dir]` (#10204): import a starter's content/ pages into
291
+ // its workspace with the agent key from AILK_AGENT_KEY. Exit 1 when any
292
+ // page failed; a refusal it names (a bad argv, no roster of record, a
293
+ // spoke, a missing credential, no installed adapter) exits 2 before
294
+ // anything is sent.
295
+ if (argv[0] === "seed-workspace") {
296
+ try {
297
+ const args = parseSeedWorkspaceArgs(argv.slice(1));
298
+ if (args.help) {
299
+ process.stdout.write(SEED_WORKSPACE_HELP);
300
+ process.exit(0);
301
+ }
302
+ const { exitCode } = await seedWorkspace({
303
+ targetDir: args.target ?? process.cwd(),
304
+ apiUrl: args.apiUrl,
305
+ workspaceId: args.workspaceId,
306
+ onLine: (line) => process.stdout.write(`${line}\n`),
307
+ });
308
+ process.exit(exitCode);
309
+ }
310
+ catch (err) {
311
+ if (!(err instanceof ParseError) &&
312
+ !(err instanceof SeedWorkspaceRefusedError) &&
313
+ !(err instanceof ImportMdxRefusedError)) {
314
+ throw err;
315
+ }
316
+ process.stderr.write(`[create-ailk] ${err.message}\n`);
317
+ process.exit(2);
318
+ }
319
+ }
320
+ // `migrate-content [dir]` (#10207): import an existing site's content/
321
+ // pages into its workspace — a dry run that writes nothing unless --apply.
322
+ // Takes a site with no manifest and a spoke. Exit 1 when any page failed; a
323
+ // refusal it names (a bad argv, a missing credential, no installed adapter)
324
+ // exits 2 before anything is sent.
325
+ if (argv[0] === "migrate-content") {
326
+ try {
327
+ const args = parseMigrateContentArgs(argv.slice(1));
328
+ if (args.help) {
329
+ process.stdout.write(MIGRATE_CONTENT_HELP);
330
+ process.exit(0);
331
+ }
332
+ const { exitCode } = await migrateContent({
333
+ targetDir: args.target ?? process.cwd(),
334
+ apply: args.apply,
335
+ apiUrl: args.apiUrl,
336
+ workspaceId: args.workspaceId,
337
+ onLine: (line) => process.stdout.write(`${line}\n`),
338
+ });
339
+ process.exit(exitCode);
340
+ }
341
+ catch (err) {
342
+ if (!(err instanceof ParseError) &&
343
+ !(err instanceof MigrateContentRefusedError) &&
344
+ !(err instanceof ImportMdxRefusedError)) {
345
+ throw err;
346
+ }
347
+ process.stderr.write(`[create-ailk] ${err.message}\n`);
348
+ process.exit(2);
349
+ }
350
+ }
274
351
  // Subcommand dispatch, ahead of the flag parser: `sync-routes` has its own
275
352
  // argv shape (#4986). Check mode exits 1 iff a template route is missing;
276
353
  // locally modified routes are reported but do not fail; --apply exits 0.
@@ -393,6 +470,8 @@ async function main() {
393
470
  "\n" +
394
471
  SEED_FLOWS_HELP +
395
472
  "\n" +
473
+ SEED_WORKSPACE_HELP +
474
+ "\n" +
396
475
  APPLY_DESIGN_HELP +
397
476
  "\n" +
398
477
  UPDATE_HELP +
@@ -486,6 +565,17 @@ async function main() {
486
565
  throw new Error(`pnpm install failed in ${targetAbs} (exit ${result.status}). Run it manually after fixing the issue.`);
487
566
  }
488
567
  }
568
+ // #10204 — on a hub with an Admin, seed the starter's pages into the
569
+ // workspace, or say why not and how to later. A refusal is printed, never
570
+ // thrown: the site was written and is valid either way (D6).
571
+ await runScaffoldSeed({
572
+ targetDir: targetAbs,
573
+ topology: opts.topology,
574
+ modules: effectiveModules,
575
+ installed: opts.install === true,
576
+ env: process.env,
577
+ onLine: (line) => process.stdout.write(`${line}\n`),
578
+ });
489
579
  // Next-steps message. A licensed install (#6480) is not told it needs no
490
580
  // licence key, and is not pitched the Pro tier it already owns. Reads the
491
581
  // EFFECTIVE module set (#6759 D6) — result.modules already carries any
@@ -5,17 +5,33 @@ export interface UnreadChromeKnob {
5
5
  /** Overlay-relative path of the reader file, with `/` separators. */
6
6
  file: string;
7
7
  }
8
+ /** One overlay file that reads a knob. */
9
+ export interface ChromeReader {
10
+ /** Overlay-relative path, with `/` separators. */
11
+ file: string;
12
+ /** Present when `file` reads the knob only through its imports: the files it reaches that read it directly. */
13
+ via?: string[];
14
+ }
8
15
  /** The pattern that means "this text reads `knob`". */
9
16
  export declare function chromeReadPattern(knob: string): RegExp;
10
17
  /**
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".
18
+ * knob → the overlay files that read it, sorted by file; one that reads it
19
+ * only through relative imports carries `via`. Scans `<templatesDir>/apps/web`; fails closed (throws, naming the directory)
20
+ * when it is absent or unreadable, so a broken overlay can never read as
21
+ * "nothing reads any knob".
14
22
  */
15
- export declare function chromeKnobReaders(templatesDir: string): Map<string, string[]>;
23
+ export declare function chromeKnobReaders(templatesDir: string): Map<string, ChromeReader[]>;
16
24
  /**
17
25
  * The `(knob, file)` pairs where the resolved chrome sets a non-default `knob`,
18
26
  * the shipped overlay's `file` reads it, the site has its own `file`, and the
19
27
  * site's `file` does not read it. A reader file the site lacks is skipped.
28
+ *
29
+ * A pair the overlay credits only through an import is also satisfied when
30
+ * some site file outside its `via` reads the knob directly: a site from before
31
+ * the read moved into the helper renders it in its own place (#10156). So the
32
+ * import credit adds a pair only for a knob the site reads nowhere but its
33
+ * helpers. It is per file, not per export: a site whose home page drops the
34
+ * helper import is named for every knob that helper reads — a refusal the
35
+ * operator passes with `--allow-unread-chrome`, never a silent miss.
20
36
  */
21
- export declare function unreadChromeKnobs(resolved: ResolvedSiteChrome, readers: ReadonlyMap<string, readonly string[]>, siteDir: string): UnreadChromeKnob[];
37
+ export declare function unreadChromeKnobs(resolved: ResolvedSiteChrome, readers: ReadonlyMap<string, readonly ChromeReader[]>, siteDir: string): UnreadChromeKnob[];
@@ -11,12 +11,16 @@
11
11
  * each knob — nothing is hard-coded, so a new reader file is picked up.
12
12
  * - `unreadChromeKnobs` compares the site's own copy of each reader file.
13
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).
14
+ * A file "reads `knob` directly" when its text matches
15
+ * `(?:siteChrome|chrome)\.<knob>\b`, and "reads" it when it or a file it
16
+ * imports by a relative path, at any depth, reads it directly — so the layout
17
+ * stays a reader of `innerPages` after #10106 moved that read into
18
+ * `lib/page-chrome.ts` (#10156). A file that reads a knob under another name
19
+ * (a destructured alias) is not recognised — accepted (spec § Clarifications,
20
+ * detection).
17
21
  */
18
22
  import { existsSync, readFileSync, readdirSync } from "node:fs";
19
- import { join } from "node:path";
23
+ import { join, posix } from "node:path";
20
24
  import { DEFAULT_SITE_CHROME, } from "../site-chrome.js";
21
25
  /** The overlay tree the generated app files live under. */
22
26
  const OVERLAY_WEB_DIR = ["apps", "web"];
@@ -31,41 +35,126 @@ function listSources(root, dir, out) {
31
35
  for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
32
36
  const rel = `${dir}/${entry.name}`;
33
37
  if (entry.isDirectory()) {
34
- if (entry.name !== "__tests__")
38
+ if (entry.name !== "__tests__" &&
39
+ entry.name !== "node_modules" &&
40
+ !entry.name.startsWith(".")) {
35
41
  listSources(root, rel, out);
42
+ }
36
43
  }
37
44
  else if (/\.tsx?$/.test(entry.name)) {
38
45
  out.push(rel);
39
46
  }
40
47
  }
41
48
  }
49
+ /** A relative module specifier in an import, export-from or dynamic import. */
50
+ const RELATIVE_IMPORT = /(?:\bfrom|\bimport)\s*\(?\s*["'](\.{1,2}\/[^"']+)["']/g;
51
+ function sourceTree(root) {
52
+ const texts = new Map();
53
+ const closures = new Map();
54
+ const importLists = new Map();
55
+ const text = (file) => {
56
+ if (!texts.has(file)) {
57
+ const abs = join(root, file);
58
+ texts.set(file, existsSync(abs) ? readFileSync(abs, "utf8") : null);
59
+ }
60
+ return texts.get(file);
61
+ };
62
+ /** The files `file` imports by a relative path that exist under the root; `./x.js` may name `x.ts`. */
63
+ const imports = (file) => {
64
+ const cached = importLists.get(file);
65
+ if (cached !== undefined)
66
+ return cached;
67
+ const out = [];
68
+ for (const match of (text(file) ?? "").matchAll(RELATIVE_IMPORT)) {
69
+ const base = posix
70
+ .normalize(posix.join(posix.dirname(file), match[1]))
71
+ .replace(/\/$/, "");
72
+ if (base.startsWith(".."))
73
+ continue;
74
+ const stem = base.replace(/\.(?:js|jsx)$/, "");
75
+ const hit = [
76
+ base,
77
+ `${stem}.ts`,
78
+ `${stem}.tsx`,
79
+ `${stem}/index.ts`,
80
+ `${stem}/index.tsx`,
81
+ ].find((candidate) => /\.tsx?$/.test(candidate) && text(candidate) !== null);
82
+ if (hit !== undefined)
83
+ out.push(hit);
84
+ }
85
+ importLists.set(file, out);
86
+ return out;
87
+ };
88
+ const reached = (file) => {
89
+ let closure = closures.get(file);
90
+ if (closure === undefined) {
91
+ closure = new Set();
92
+ const stack = [...imports(file)];
93
+ while (stack.length > 0) {
94
+ const next = stack.pop();
95
+ if (next === file || closure.has(next))
96
+ continue;
97
+ closure.add(next);
98
+ stack.push(...imports(next));
99
+ }
100
+ closures.set(file, closure);
101
+ }
102
+ return closure;
103
+ };
104
+ const readsDirectly = (file, pattern) => pattern.test(text(file) ?? "");
105
+ return {
106
+ text,
107
+ reached,
108
+ readsDirectly,
109
+ reads: (file, pattern) => readsDirectly(file, pattern) ||
110
+ [...reached(file)].some((dep) => readsDirectly(dep, pattern)),
111
+ };
112
+ }
42
113
  /**
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".
114
+ * knob → the overlay files that read it, sorted by file; one that reads it
115
+ * only through relative imports carries `via`. Scans `<templatesDir>/apps/web`; fails closed (throws, naming the directory)
116
+ * when it is absent or unreadable, so a broken overlay can never read as
117
+ * "nothing reads any knob".
46
118
  */
47
119
  export function chromeKnobReaders(templatesDir) {
48
120
  const webDir = join(templatesDir, ...OVERLAY_WEB_DIR);
49
- const readers = new Map();
121
+ const direct = new Map();
122
+ const files = [];
123
+ const tree = sourceTree(templatesDir);
50
124
  try {
51
125
  if (!existsSync(webDir))
52
126
  throw new Error("missing");
53
- const files = [];
54
127
  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"))) {
128
+ files.sort();
129
+ for (const file of files) {
130
+ for (const match of tree
131
+ .text(file)
132
+ .matchAll(new RegExp(`${READ_HEAD}([A-Za-z_$][\\w$]*)\\b`, "g"))) {
58
133
  const knob = match[1];
59
- const list = readers.get(knob) ?? [];
134
+ const list = direct.get(knob) ?? [];
60
135
  if (!list.includes(file))
61
136
  list.push(file);
62
- readers.set(knob, list);
137
+ direct.set(knob, list);
63
138
  }
64
139
  }
65
140
  }
66
141
  catch {
67
142
  throw new Error(`cannot check chrome knobs: the overlay has no readable apps/web at ${webDir}. Reinstall create-ailk.`);
68
143
  }
144
+ const readers = new Map();
145
+ for (const [knob, directFiles] of direct) {
146
+ const list = [];
147
+ for (const file of files) {
148
+ if (directFiles.includes(file)) {
149
+ list.push({ file });
150
+ continue;
151
+ }
152
+ const via = directFiles.filter((reader) => tree.reached(file).has(reader));
153
+ if (via.length > 0)
154
+ list.push({ file, via });
155
+ }
156
+ readers.set(knob, list);
157
+ }
69
158
  return readers;
70
159
  }
71
160
  /** A knob is set when it differs from its default; an optional knob (no default) when present. */
@@ -81,19 +170,38 @@ function isSet(resolved, knob) {
81
170
  * The `(knob, file)` pairs where the resolved chrome sets a non-default `knob`,
82
171
  * the shipped overlay's `file` reads it, the site has its own `file`, and the
83
172
  * site's `file` does not read it. A reader file the site lacks is skipped.
173
+ *
174
+ * A pair the overlay credits only through an import is also satisfied when
175
+ * some site file outside its `via` reads the knob directly: a site from before
176
+ * the read moved into the helper renders it in its own place (#10156). So the
177
+ * import credit adds a pair only for a knob the site reads nowhere but its
178
+ * helpers. It is per file, not per export: a site whose home page drops the
179
+ * helper import is named for every knob that helper reads — a refusal the
180
+ * operator passes with `--allow-unread-chrome`, never a silent miss.
84
181
  */
85
182
  export function unreadChromeKnobs(resolved, readers, siteDir) {
86
183
  const out = [];
184
+ const site = sourceTree(siteDir);
185
+ let siteFiles;
87
186
  for (const knob of Object.keys(resolved).sort()) {
88
187
  if (!isSet(resolved, knob))
89
188
  continue;
90
189
  const pattern = chromeReadPattern(knob);
91
- for (const file of readers.get(knob) ?? []) {
92
- const abs = join(siteDir, file);
93
- if (!existsSync(abs))
190
+ for (const { file, via } of readers.get(knob) ?? []) {
191
+ if (site.text(file) === null || site.reads(file, pattern))
94
192
  continue;
95
- if (!pattern.test(readFileSync(abs, "utf8")))
96
- out.push({ knob, file });
193
+ if (via !== undefined) {
194
+ if (siteFiles === undefined) {
195
+ siteFiles = [];
196
+ if (existsSync(join(siteDir, ...OVERLAY_WEB_DIR))) {
197
+ listSources(siteDir, OVERLAY_WEB_DIR.join("/"), siteFiles);
198
+ }
199
+ }
200
+ const elsewhere = siteFiles.some((other) => !via.includes(other) && site.readsDirectly(other, pattern));
201
+ if (elsewhere)
202
+ continue;
203
+ }
204
+ out.push({ knob, file });
97
205
  }
98
206
  }
99
207
  return out;
@@ -109,6 +109,12 @@ export interface PlanEnvRowsOptions {
109
109
  * with no reader and so no `scope`; a platform or refused name is skipped.
110
110
  */
111
111
  declare?: readonly string[];
112
+ /**
113
+ * When set, only reads in these site-relative files get a row (#10173):
114
+ * `update` declares what the files it wrote read, never a name only the
115
+ * site's own code reads.
116
+ */
117
+ onlyFiles?: ReadonlySet<string>;
112
118
  }
113
119
  /**
114
120
  * `projectYamlText` with a row appended for every name the site at `root`
@@ -904,6 +904,8 @@ export function planEnvRows(root, projectYamlText, opts) {
904
904
  const refused = new Set(opts.topology === "spoke" ? SPOKE_REFUSED_ENV : []);
905
905
  const files = new Map();
906
906
  for (const read of collectSiteReads(root).reads) {
907
+ if (opts.onlyFiles !== undefined && !opts.onlyFiles.has(read.file))
908
+ continue;
907
909
  if (isPlatformEnvName(read.name) || refused.has(read.name))
908
910
  continue;
909
911
  if (declared.has(read.name))
@@ -27,3 +27,19 @@ export declare function resolveRelativeImport(fromFile: string, spec: string, ca
27
27
  * @param seeds repo-relative, "/"-separated files the closure starts from.
28
28
  */
29
29
  export declare function importClosure(repoRoot: string, roots: readonly string[], seeds: Iterable<string>): Set<string>;
30
+ /**
31
+ * The names a module imports from each specifier, textually (#10173): the
32
+ * members of a `{ … }` list (an `a as b` member names `a`, a `type a` member
33
+ * `a`) and `default` for a default import. A namespace import (`* as x`) and
34
+ * a side-effect import name nothing.
35
+ */
36
+ export declare function namedImportsOf(text: string): Array<{
37
+ specifier: string;
38
+ names: string[];
39
+ }>;
40
+ /**
41
+ * Whether module `text` exports `name`, textually. Fails toward `true` — an
42
+ * `export *`, or a destructuring export, could export anything — so a caller
43
+ * that holds a file back on `false` never holds one that does export it.
44
+ */
45
+ export declare function exportsName(text: string, name: string): boolean;
@@ -138,3 +138,56 @@ export function importClosure(repoRoot, roots, seeds) {
138
138
  }
139
139
  return closed;
140
140
  }
141
+ const IDENT = "[A-Za-z_$][\\w$]*";
142
+ /** `import { a, b as c }` / `export { a } from` / `import X from` — the bound names per specifier. */
143
+ const NAMED_IMPORT = new RegExp(`\\b(?:import|export)\\s+(?:type\\s+)?(?:(${IDENT})\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*["']([^"']+)["']` +
144
+ `|\\bimport\\s+(?:type\\s+)?(${IDENT})\\s+from\\s*["']([^"']+)["']`, "g");
145
+ /**
146
+ * The names a module imports from each specifier, textually (#10173): the
147
+ * members of a `{ … }` list (an `a as b` member names `a`, a `type a` member
148
+ * `a`) and `default` for a default import. A namespace import (`* as x`) and
149
+ * a side-effect import name nothing.
150
+ */
151
+ export function namedImportsOf(text) {
152
+ const out = [];
153
+ for (const m of text.matchAll(NAMED_IMPORT)) {
154
+ if (m[3] !== undefined) {
155
+ const names = (m[2] ?? "")
156
+ .split(",")
157
+ .map((member) => member.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim())
158
+ .filter((name) => new RegExp(`^${IDENT}$`).test(name));
159
+ if (m[1] !== undefined)
160
+ names.push("default");
161
+ out.push({ specifier: m[3], names });
162
+ }
163
+ else if (m[5] !== undefined) {
164
+ out.push({ specifier: m[5], names: ["default"] });
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+ /**
170
+ * Whether module `text` exports `name`, textually. Fails toward `true` — an
171
+ * `export *`, or a destructuring export, could export anything — so a caller
172
+ * that holds a file back on `false` never holds one that does export it.
173
+ */
174
+ export function exportsName(text, name) {
175
+ if (/\bexport\s*\*/.test(text))
176
+ return true;
177
+ if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(text))
178
+ return true;
179
+ if (name === "default" && /\bexport\s+default\b/.test(text))
180
+ return true;
181
+ const declared = new RegExp(`\\bexport\\s+(?:declare\\s+)?(?:async\\s+)?(?:abstract\\s+)?` +
182
+ `(?:const\\s+enum|const|let|var|function\\s*\\*?|class|type|interface|enum|namespace)\\s*${name.replace(/\$/g, "\\$")}\\b`);
183
+ if (declared.test(text))
184
+ return true;
185
+ for (const m of text.matchAll(/\bexport\s*(?:type\s*)?\{([^}]*)\}/g)) {
186
+ for (const member of (m[1] ?? "").split(",")) {
187
+ const parts = member.trim().replace(/^type\s+/, "").split(/\s+as\s+/);
188
+ if ((parts[1] ?? parts[0])?.trim() === name)
189
+ return true;
190
+ }
191
+ }
192
+ return false;
193
+ }