create-ailk 0.20.0 → 0.21.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 (83) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +184 -6
  3. package/component-catalog.md +20 -9
  4. package/dist/apply-design.d.ts +9 -0
  5. package/dist/apply-design.js +78 -31
  6. package/dist/design-home.d.ts +54 -11
  7. package/dist/design-home.js +160 -22
  8. package/dist/lib/design-checks.d.ts +6 -1
  9. package/dist/lib/design-checks.js +23 -2
  10. package/dist/programmatic.js +44 -20
  11. package/dist/site-roster.d.ts +4 -2
  12. package/dist/site-roster.js +7 -5
  13. package/dist/starter-grid.js +16 -0
  14. package/dist/surfaces.js +11 -0
  15. package/dist/sync-routes.d.ts +28 -0
  16. package/dist/sync-routes.js +123 -1
  17. package/dist/update.d.ts +6 -1
  18. package/dist/update.js +120 -18
  19. package/package.json +3 -3
  20. package/site-templates/_base/ailk-template.json +2 -2
  21. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  22. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  23. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  24. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  25. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  26. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  27. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  28. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  29. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  30. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  31. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  32. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  33. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  34. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  35. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  36. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  37. package/templates/README.md +26 -25
  38. package/templates/apps/api/CLAUDE.md +7 -3
  39. package/templates/apps/api/jest.config.cjs +1 -0
  40. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  41. package/templates/apps/api/src/services/project-listings.ts +6 -1
  42. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  43. package/templates/apps/mcp/jest.config.cjs +1 -0
  44. package/templates/apps/web/CLAUDE.md +26 -1
  45. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  46. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  47. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  48. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  49. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  50. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
  51. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  52. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  53. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
  54. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
  55. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
  56. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  57. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  58. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +35 -3
  59. package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
  60. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  61. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  62. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  63. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  64. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  65. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  66. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  68. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  69. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  70. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  71. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  72. package/templates/apps/web/jest.config.cjs +1 -0
  73. package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
  74. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +4 -2
  75. package/templates/apps/web/lib/deck-page.ts +186 -0
  76. package/templates/apps/web/messages/en.json +16 -5
  77. package/templates/database/CHANGELOG.md +9 -0
  78. package/templates/database/__tests__/seed.test.ts +39 -2
  79. package/templates/database/jest.config.cjs +1 -0
  80. package/templates/database/package.json +1 -1
  81. package/templates/database/seed.ts +29 -0
  82. package/templates/tools/jest/pin-timezone.cjs +34 -0
  83. package/versions.json +34 -34
@@ -25,7 +25,7 @@ import { CATALOG_TARGET_PATH, writeComponentCatalog, } from "./component-catalog
25
25
  import { buildScaffoldMarkers, copyTemplate } from "./copy.js";
26
26
  import { deriveArchitecture } from "./derive-architecture.js";
27
27
  import { compileDesignFonts, compileDesignTheme } from "./design-compile.js";
28
- import { HOME_PAGE_PATH, designHomeSeeds, planDesignHome, shapeHomePath, shippedHomeSeeds, } from "./design-home.js";
28
+ import { DESIGN_SEED_PAGES, designHomeSeeds, planDesignHome, shapeHomePath, shippedHomeSeeds, siteIdentityName, } from "./design-home.js";
29
29
  import { applyDesignPresets, readSectionLookCatalog, shippedPageTypes, validateDesignPresets, } from "./design-presets.js";
30
30
  import { resolveDesign, spliceThemeBlock, } from "./design-scaffold.js";
31
31
  import { writeAuthSecretFile } from "./lib/auth-secret-file.js";
@@ -330,6 +330,10 @@ export async function scaffold(opts) {
330
330
  const designSeeds = resolvedDesign !== undefined
331
331
  ? designHomeSeeds(resolvedDesign, { designsDir: DESIGNS_DIR, catalog })
332
332
  : [];
333
+ // #8661 D11 — and its contact seeds, read and validated the same way.
334
+ const designContactSeeds = resolvedDesign !== undefined
335
+ ? designHomeSeeds(resolvedDesign, { designsDir: DESIGNS_DIR, catalog, page: "contact" })
336
+ : [];
333
337
  // #6813 D7 — the resolved design only when the CALLER chose one (`opts.design`);
334
338
  // a template's own declared design is never gated (#6778 option (c)).
335
339
  const chosenDesign = opts.design !== undefined ? resolvedDesign : undefined;
@@ -719,21 +723,35 @@ export async function scaffold(opts) {
719
723
  // every layer has landed, before the presets dress it. A home that is not
720
724
  // the shape's placeholder is never written; `--design standard` (no
721
725
  // seeds) leaves the shape's home byte-identical. The one
722
- // `planDesignHome` call site in scaffold().
723
- const homePath = join(result.targetDir, ...HOME_PAGE_PATH.split("/"));
724
- let homeSeeded = false;
726
+ // `planDesignHome` call site in scaffold(). #8661 D11 — the contact page
727
+ // is planned right after the home, its heading named from the scaffold's
728
+ // `siteIdentity.name`.
729
+ const seededPages = [];
725
730
  if (template !== undefined) {
726
- const homePlan = planDesignHome({
727
- current: existsSync(homePath) ? readFileSync(homePath, "utf8") : undefined,
728
- shape: template.shape,
729
- target: designSeeds.find((seed) => seed.shape === template.shape),
730
- shapeHome: readFileSync(shapeHomePath(template.shape, { siteTemplatesDir: SITE_TEMPLATES_DIR, templatesDir: TEMPLATES_DIR }), "utf8"),
731
- shippedSeeds: shippedHomeSeeds({ designsDir: DESIGNS_DIR, catalog }),
732
- catalog: catalog(),
733
- });
734
- if (homePlan.outcome === "seeded" && homePlan.text !== undefined) {
735
- writeFileSync(homePath, homePlan.text);
736
- homeSeeded = true;
731
+ const siteName = siteIdentityName(existsSync(siteMdxPath) ? readFileSync(siteMdxPath, "utf8") : undefined);
732
+ for (const [page, pageSeeds] of [
733
+ ["home", designSeeds],
734
+ ["contact", designContactSeeds],
735
+ ]) {
736
+ const pagePath = join(result.targetDir, ...DESIGN_SEED_PAGES[page].path.split("/"));
737
+ const plan = planDesignHome({
738
+ page,
739
+ siteName,
740
+ current: existsSync(pagePath) ? readFileSync(pagePath, "utf8") : undefined,
741
+ shape: template.shape,
742
+ target: pageSeeds.find((seed) => seed.shape === template.shape),
743
+ shapeHome: readFileSync(shapeHomePath(template.shape, {
744
+ siteTemplatesDir: SITE_TEMPLATES_DIR,
745
+ templatesDir: TEMPLATES_DIR,
746
+ page,
747
+ }), "utf8"),
748
+ shippedSeeds: shippedHomeSeeds({ designsDir: DESIGNS_DIR, catalog, page }),
749
+ catalog: catalog(),
750
+ });
751
+ if (plan.outcome === "seeded" && plan.text !== undefined) {
752
+ writeFileSync(pagePath, plan.text);
753
+ seededPages.push(page);
754
+ }
737
755
  }
738
756
  }
739
757
  // #6816 D6 step 10b — the design's section presets, written onto the
@@ -761,12 +779,18 @@ export async function scaffold(opts) {
761
779
  }
762
780
  }
763
781
  // #7743 D6 — the manifest's `/` row records the seeded home's sections,
764
- // as the presets left them.
782
+ // as the presets left them. #8661 — and the `/contact` row the seeded
783
+ // contact page's.
765
784
  const rosterPath = join(result.targetDir, SITE_ROSTER_PATH);
766
- if (homeSeeded && existsSync(rosterPath)) {
767
- const nextManifest = planHomeRecord(readFileSync(rosterPath, "utf8"), readFileSync(homePath, "utf8"), catalog());
768
- if (nextManifest !== undefined)
769
- writeFileSync(rosterPath, nextManifest);
785
+ if (seededPages.length > 0 && existsSync(rosterPath)) {
786
+ const recorded = readFileSync(rosterPath, "utf8");
787
+ let manifestText = recorded;
788
+ for (const page of seededPages) {
789
+ const pagePath = join(result.targetDir, ...DESIGN_SEED_PAGES[page].path.split("/"));
790
+ manifestText = planHomeRecord(manifestText, readFileSync(pagePath, "utf8"), catalog(), page) ?? manifestText;
791
+ }
792
+ if (manifestText !== recorded)
793
+ writeFileSync(rosterPath, manifestText);
770
794
  }
771
795
  }
772
796
  // #7838 D8 — a commerce spoke's own checkout: two routes, its config and
@@ -1,4 +1,5 @@
1
1
  import type { CompiledDesignTheme } from "./design-compile.js";
2
+ import { type DesignSeedPage } from "./design-home.js";
2
3
  import { type SectionLookCatalog } from "./design-presets.js";
3
4
  import { type PageRosterRow } from "./module-architecture.js";
4
5
  import type { ModuleName } from "./parse-args.js";
@@ -102,9 +103,10 @@ export declare const MANIFEST_OPTION_KEYS: readonly ["itemStyle", "actionsPositi
102
103
  * `MANIFEST_OPTION_KEYS` values, as the generator writes them. `undefined`
103
104
  * when the record already says so, or the manifest has no `/` row with a
104
105
  * `sections` key. The value is spliced in place at its own depth; no other
105
- * byte moves.
106
+ * byte moves. `page: "contact"` records the `/contact` row from the contact
107
+ * page instead (#8661 D11).
106
108
  */
107
- export declare function planHomeRecord(manifestText: string, homeText: string, catalog: SectionLookCatalog): string | undefined;
109
+ export declare function planHomeRecord(manifestText: string, homeText: string, catalog: SectionLookCatalog, page?: DesignSeedPage): string | undefined;
108
110
  /**
109
111
  * The generator's row for each of a module's `pageContributions`, in
110
112
  * declaration order — read from the committed manifest of the first
@@ -28,7 +28,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
28
28
  import { join, relative, sep } from "node:path";
29
29
  import { isDeepStrictEqual } from "node:util";
30
30
  import { AddModuleRefusedError, readArchitectureHeader, } from "./add-module-core.js";
31
- import { HOME_PAGE_PATH } from "./design-home.js";
31
+ import { DESIGN_SEED_PAGES } from "./design-home.js";
32
32
  import { locatePageSections } from "./design-presets.js";
33
33
  import { diffTwoStrings } from "./lib/extract-module-bundle.js";
34
34
  import { MODULE_SLICES, normalizeModules, } from "./module-architecture.js";
@@ -296,14 +296,16 @@ function scalarValue(raw) {
296
296
  * `MANIFEST_OPTION_KEYS` values, as the generator writes them. `undefined`
297
297
  * when the record already says so, or the manifest has no `/` row with a
298
298
  * `sections` key. The value is spliced in place at its own depth; no other
299
- * byte moves.
299
+ * byte moves. `page: "contact"` records the `/contact` row from the contact
300
+ * page instead (#8661 D11).
300
301
  */
301
- export function planHomeRecord(manifestText, homeText, catalog) {
302
+ export function planHomeRecord(manifestText, homeText, catalog, page = "home") {
303
+ const { route, path } = DESIGN_SEED_PAGES[page];
302
304
  const file = parseRosterFile(manifestText, SITE_ROSTER_PATH);
303
- const row = file.rows.find((r) => r.value["route"] === "/");
305
+ const row = file.rows.find((r) => r.value["route"] === route);
304
306
  if (row === undefined || !("sections" in row.value))
305
307
  return undefined;
306
- const sections = (locatePageSections(homeText, HOME_PAGE_PATH) ?? []).map((section) => {
308
+ const sections = (locatePageSections(homeText, path) ?? []).map((section) => {
307
309
  const options = {};
308
310
  for (const key of MANIFEST_OPTION_KEYS) {
309
311
  const entry = section.entries.find((e) => e.key === key);
@@ -117,6 +117,10 @@ const TEMPLATE_BASE_ROSTER = [
117
117
  { route: "/sign-up" },
118
118
  { route: "/privacy", content: ["content/en/PrivacyPage/privacy.mdx"] },
119
119
  { route: "/terms", content: ["content/en/TermsPage/terms.mdx"] },
120
+ {
121
+ route: "/working-agreement",
122
+ content: ["content/en/WorkingAgreementPage/working-agreement.mdx"],
123
+ },
120
124
  {
121
125
  route: "/purchase-policy",
122
126
  content: ["content/en/PurchasePolicyPage/purchase-policy.mdx"],
@@ -235,6 +239,10 @@ const ORGANIZATION_SERVICE_ROWS = [
235
239
  { route: "/sign-up" },
236
240
  { route: "/privacy", content: ["content/en/PrivacyPage/privacy.mdx"] },
237
241
  { route: "/terms", content: ["content/en/TermsPage/terms.mdx"] },
242
+ {
243
+ route: "/working-agreement",
244
+ content: ["content/en/WorkingAgreementPage/working-agreement.mdx"],
245
+ },
238
246
  {
239
247
  route: "/purchase-policy",
240
248
  content: ["content/en/PurchasePolicyPage/purchase-policy.mdx"],
@@ -267,6 +275,10 @@ const LOCAL_BUSINESS_SERVICE_ROWS = [
267
275
  },
268
276
  { route: "/privacy", content: ["content/en/PrivacyPage/privacy.mdx"] },
269
277
  { route: "/terms", content: ["content/en/TermsPage/terms.mdx"] },
278
+ {
279
+ route: "/working-agreement",
280
+ content: ["content/en/WorkingAgreementPage/working-agreement.mdx"],
281
+ },
270
282
  ];
271
283
  /**
272
284
  * `person-service`'s own rows (#6758 D1, D3), derived from
@@ -302,6 +314,10 @@ const PERSON_SERVICE_ROWS = [
302
314
  },
303
315
  { route: "/privacy", content: ["content/en/PrivacyPage/privacy.mdx"] },
304
316
  { route: "/terms", content: ["content/en/TermsPage/terms.mdx"] },
317
+ {
318
+ route: "/working-agreement",
319
+ content: ["content/en/WorkingAgreementPage/working-agreement.mdx"],
320
+ },
305
321
  ];
306
322
  /**
307
323
  * Each buildable shape's standard roster (#6753 D3) — the rows an alias starts
package/dist/surfaces.js CHANGED
@@ -331,6 +331,13 @@ export const GATEABLE_SURFACES = [
331
331
  routes: [`${ROUTE_ROOT}/whitepapers`],
332
332
  content: ["content/<locale>/Whitepaper"],
333
333
  },
334
+ {
335
+ // #8661 D10 — the working agreement, module-less like `privacy` and
336
+ // `terms`: every shape roster names it right after /terms.
337
+ id: "working-agreement",
338
+ routes: [`${ROUTE_ROOT}/working-agreement`],
339
+ content: ["content/<locale>/WorkingAgreementPage"],
340
+ },
334
341
  ];
335
342
  /** The recognized `--surfaces` vocabulary, sorted. */
336
343
  export const RECOGNIZED_SURFACES = GATEABLE_SURFACES.map((s) => s.id);
@@ -552,6 +559,7 @@ export function isUnservedSurfaceContent(path, enabled) {
552
559
  */
553
560
  export const ROUTE_SINCE = {
554
561
  about: "0.0.0",
562
+ "about/[slug]": "0.42.0",
555
563
  "blog/[[...slug]]": "0.0.0",
556
564
  careers: "0.0.0",
557
565
  "careers/[slug]": "0.0.0",
@@ -638,6 +646,9 @@ export const ROUTE_SINCE = {
638
646
  "vs/[slug]": "0.28.0",
639
647
  whitepapers: "0.0.0",
640
648
  "whitepapers/[slug]": "0.0.0",
649
+ // #8661 D10 — the working-agreement page. Genuinely new, so it attaches at
650
+ // the fixed group's next release line, the same rule #7430 applied.
651
+ "working-agreement": "0.42.0",
641
652
  // #5014 — the (authed) route GROUP: layout.tsx + sign-in-gate.tsx sit
642
653
  // directly under "(authed)" (a Next.js route group — its parens are not a
643
654
  // URL segment); account/ and waitlist/ are its two syncable sub-trees.
@@ -1,4 +1,6 @@
1
1
  import type { WorkspaceVersionMap } from "./substitute.js";
2
+ /** The site test that fails a route neither rendered through `renderPage` nor in its `NO_STRUCTURED_DATA_ROUTES` (#8704). */
3
+ export declare const STRUCTURED_DATA_TEST_PATH = "apps/web/lib/__tests__/structured-data.test.tsx";
2
4
  export interface SyncRoutesOptions {
3
5
  /**
4
6
  * The already-scaffolded repo (absolute or relative). Must exist, be a
@@ -65,7 +67,32 @@ export interface SyncRoutesResult {
65
67
  missingMessageNamespaces: string[];
66
68
  /** Gateable surface ids the target does not carry, sorted. */
67
69
  skippedSurfaces: string[];
70
+ /**
71
+ * When `apply` added a route the template exempts in its structured-data
72
+ * test and the site's copy of that test is site-edited (its bytes differ
73
+ * from the recorded hash), each template `NO_STRUCTURED_DATA_ROUTES` entry
74
+ * the site's test lacks, verbatim, with the added routes it covers (#8704).
75
+ * Sorted by entry; empty otherwise. The site's test is never written.
76
+ */
77
+ structuredDataEntries: Array<{
78
+ routes: string[];
79
+ entry: string;
80
+ }>;
81
+ }
82
+ /** One `NO_STRUCTURED_DATA_ROUTES` element: its source text, dedented, and the routes it exempts. */
83
+ interface StructuredDataExemption {
84
+ route: string;
85
+ subtree: boolean;
86
+ text: string;
68
87
  }
88
+ /**
89
+ * The elements of `source`'s `NO_STRUCTURED_DATA_ROUTES` array literal, by a
90
+ * brace scan that skips string literals and comments. Empty when the
91
+ * file declares no such array. It assumes what the template's array holds:
92
+ * object literals of string, boolean and identifier-array values — no regex
93
+ * literal or `${…}` interpolation.
94
+ */
95
+ export declare function parseStructuredDataExemptions(source: string): StructuredDataExemption[];
69
96
  /**
70
97
  * The template's route directories, ROUTE_ROOT-relative and sorted.
71
98
  *
@@ -99,3 +126,4 @@ export declare const SYNC_ROUTES_HELP = "create-ailk sync-routes \u2014 copy tem
99
126
  * like `parseCliArgs`; at most one positional.
100
127
  */
101
128
  export declare function parseSyncRoutesArgs(argv: string[]): SyncRoutesArgs;
129
+ export {};
@@ -21,7 +21,10 @@
21
21
  * route root is copied once into a scratch dir and compared from there.
22
22
  * - Never overwrite, never delete. A route dir the site has changed is
23
23
  * reported as modified and left alone; `--apply` only creates directories
24
- * that do not exist, so a second run is a no-op.
24
+ * that do not exist, so a second run is a no-op. When the site has edited
25
+ * its structured-data test, a route `--apply` adds that the template
26
+ * exempts there is reported with the exact `NO_STRUCTURED_DATA_ROUTES`
27
+ * entry to add — the test itself is never written (#8704).
25
28
  *
26
29
  * Pure module: no `import.meta.url`, so jest can import it directly. The
27
30
  * package's own `templates/` dir is supplied by programmatic.ts.
@@ -35,9 +38,12 @@ import { appendEnvRows } from "./lib/declared-env.js";
35
38
  import { ENV_CATALOG } from "./lib/env-catalog.js";
36
39
  import { substituteMarkers } from "./substitute.js";
37
40
  import { GATEABLE_SURFACES, NON_ROUTE_DIRS, ROUTE_DEPENDENCIES, ROUTE_MESSAGE_NAMESPACES, ROUTE_SINCE, surfaceForRouteDir, } from "./surfaces.js";
41
+ import { TEMPLATE_RECORD_PATH, parseTemplateRecord, sha256Hex } from "./template-record.js";
38
42
  import { SPOKE_SEAM_FILES, isSpokeExcluded, readSpokeShape, } from "./topology.js";
39
43
  /** Template-relative prefix every `apps/web` route directory sits under. */
40
44
  const ROUTE_ROOT = "apps/web/app/[locale]";
45
+ /** The site test that fails a route neither rendered through `renderPage` nor in its `NO_STRUCTURED_DATA_ROUTES` (#8704). */
46
+ export const STRUCTURED_DATA_TEST_PATH = "apps/web/lib/__tests__/structured-data.test.tsx";
41
47
  /**
42
48
  * `<surface id>/<entry name>` pairs that live directly under a GATEABLE
43
49
  * surface's top-level directory but belong to a DIFFERENT module (#5366).
@@ -50,6 +56,114 @@ const ROUTE_ROOT = "apps/web/app/[locale]";
50
56
  const NESTED_FOREIGN_MODULE_ENTRIES = new Set([
51
57
  "projects/apply",
52
58
  ]);
59
+ /**
60
+ * The elements of `source`'s `NO_STRUCTURED_DATA_ROUTES` array literal, by a
61
+ * brace scan that skips string literals and comments. Empty when the
62
+ * file declares no such array. It assumes what the template's array holds:
63
+ * object literals of string, boolean and identifier-array values — no regex
64
+ * literal or `${…}` interpolation.
65
+ */
66
+ export function parseStructuredDataExemptions(source) {
67
+ const decl = source.indexOf("const NO_STRUCTURED_DATA_ROUTES");
68
+ if (decl === -1)
69
+ return [];
70
+ const open = /=\s*\[/.exec(source.slice(decl));
71
+ if (open === null)
72
+ return [];
73
+ const out = [];
74
+ let depth = 0;
75
+ let start = -1;
76
+ for (let i = decl + open.index + open[0].length; i < source.length; i++) {
77
+ const ch = source[i];
78
+ if (ch === '"' || ch === "'" || ch === "`") {
79
+ for (i++; i < source.length && source[i] !== ch; i++) {
80
+ if (source[i] === "\\")
81
+ i++;
82
+ }
83
+ continue;
84
+ }
85
+ if (ch === "/" && source[i + 1] === "/") {
86
+ const eol = source.indexOf("\n", i);
87
+ i = eol === -1 ? source.length : eol;
88
+ continue;
89
+ }
90
+ if (ch === "/" && source[i + 1] === "*") {
91
+ const end = source.indexOf("*/", i + 2);
92
+ i = end === -1 ? source.length : end + 1;
93
+ continue;
94
+ }
95
+ if (ch === "{") {
96
+ if (depth === 0)
97
+ start = i;
98
+ depth++;
99
+ }
100
+ else if (ch === "}") {
101
+ depth--;
102
+ if (depth === 0 && start !== -1) {
103
+ const raw = source.slice(start, i + 1) + (source[i + 1] === "," ? "," : "");
104
+ const route = /\broute:\s*["']([^"']+)["']/.exec(raw)?.[1];
105
+ if (route !== undefined) {
106
+ const indent = /[ \t]*$/.exec(source.slice(source.lastIndexOf("\n", start) + 1, start))?.[0] ?? "";
107
+ const text = raw
108
+ .split("\n")
109
+ .map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line))
110
+ .join("\n");
111
+ out.push({ route, subtree: /\bsubtree:\s*true\b/.test(raw), text });
112
+ }
113
+ start = -1;
114
+ }
115
+ }
116
+ else if (ch === "]" && depth === 0) {
117
+ break;
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ /**
123
+ * The first exemption covering `route` by the structured-data test's route
124
+ * rule (`route` itself, or under a `subtree` entry). An entry's `requires`
125
+ * strings are not checked: they key only the generated spoke checkout pages,
126
+ * which no template route dir — so no `--apply` — ever adds.
127
+ */
128
+ function exemptionFor(exemptions, route) {
129
+ return exemptions.find((e) => route === e.route || (e.subtree && route.startsWith(`${e.route}/`)));
130
+ }
131
+ /**
132
+ * The template `NO_STRUCTURED_DATA_ROUTES` entries the site's structured-data
133
+ * test must gain for `added` routes (#8704). Empty unless that test is
134
+ * recorded and site-edited — an untouched copy is `create-ailk update`'s to
135
+ * bring forward, and an unrecorded one cannot be told apart from an edit.
136
+ */
137
+ function structuredDataEntriesFor(targetAbs, templatesDir, added) {
138
+ const sitePath = join(targetAbs, STRUCTURED_DATA_TEST_PATH);
139
+ const templatePath = join(templatesDir, STRUCTURED_DATA_TEST_PATH);
140
+ const recordPath = join(targetAbs, TEMPLATE_RECORD_PATH);
141
+ if (added.length === 0 || !existsSync(sitePath) || !existsSync(templatePath) || !existsSync(recordPath)) {
142
+ return [];
143
+ }
144
+ let recordedHash;
145
+ try {
146
+ recordedHash = parseTemplateRecord(readFileSync(recordPath, "utf8")).files[STRUCTURED_DATA_TEST_PATH]?.sha256;
147
+ }
148
+ catch {
149
+ return [];
150
+ }
151
+ const siteBytes = readFileSync(sitePath);
152
+ if (recordedHash === undefined || sha256Hex(siteBytes) === recordedHash)
153
+ return [];
154
+ const templateExemptions = parseStructuredDataExemptions(readFileSync(templatePath, "utf8"));
155
+ const siteExemptions = parseStructuredDataExemptions(siteBytes.toString("utf8"));
156
+ const byEntry = new Map();
157
+ for (const route of [...added].sort()) {
158
+ const needed = exemptionFor(templateExemptions, route);
159
+ if (needed === undefined || exemptionFor(siteExemptions, route) !== undefined)
160
+ continue;
161
+ byEntry.set(needed.text, [...(byEntry.get(needed.text) ?? []), route]);
162
+ }
163
+ return [...byEntry.entries()]
164
+ .sort(([a], [b]) => a.localeCompare(b))
165
+ .map(([entry, routes]) => ({ routes, entry }));
166
+ }
53
167
  /**
54
168
  * The template's route directories, ROUTE_ROOT-relative and sorted.
55
169
  *
@@ -276,6 +390,7 @@ export function syncRoutes(opts) {
276
390
  appliedDependencies,
277
391
  missingMessageNamespaces,
278
392
  skippedSurfaces,
393
+ structuredDataEntries: structuredDataEntriesFor(targetAbs, opts.templatesDir, applied),
279
394
  };
280
395
  }
281
396
  /**
@@ -355,6 +470,13 @@ export function formatSyncRoutesReport(result, apply) {
355
470
  lines.push(`[create-ailk] Also required — message namespace(s) not found in apps/web/messages/en.json (add manually, not auto-merged):`);
356
471
  lines.push(...result.missingMessageNamespaces.map((ns) => ` ${ns}`));
357
472
  }
473
+ if (result.structuredDataEntries.length > 0) {
474
+ lines.push(`[create-ailk] Also required — ${STRUCTURED_DATA_TEST_PATH} is edited here, so add these NO_STRUCTURED_DATA_ROUTES entries to it by hand (not written):`);
475
+ for (const { routes, entry } of result.structuredDataEntries) {
476
+ lines.push(` for ${routes.join(", ")}:`);
477
+ lines.push(...entry.split("\n").map((line) => ` ${line}`));
478
+ }
479
+ }
358
480
  if (result.missing.length > 0 && !apply) {
359
481
  lines.push("Run `create-ailk sync-routes <target> --apply` to copy the missing routes.");
360
482
  }
package/dist/update.d.ts CHANGED
@@ -31,6 +31,11 @@ export interface UpdateResult {
31
31
  path: string;
32
32
  reason: string;
33
33
  }>;
34
+ /** Per file left under review or skipped, the template tests importing it that stay at the site's version (#8704). */
35
+ heldBack: Array<{
36
+ file: string;
37
+ tests: string[];
38
+ }>;
34
39
  backfilled?: {
35
40
  version: string;
36
41
  files: number;
@@ -43,6 +48,6 @@ export interface UpdateArgs {
43
48
  target: string | undefined;
44
49
  help: boolean;
45
50
  }
46
- export declare const UPDATE_HELP = "create-ailk update \u2014 bring template fixes into a site's copied app code.\n\nUsage:\n create-ailk update [<target-dir>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --help, -h Print this message.\n\nReads .ailk-template-files.json (written beside .ailk-site-manifest.json by\nevery template scaffold) to tell an untouched copy from an edit. An\nuntouched apps/web, apps/api or apps/mcp file whose template copy changed is\noverwritten. An edited file whose template copy also changed is three-way\nmerged with `git` (git must be on PATH); a conflict leaves the file alone\nand is listed under \"Needs review\" \u2014 nothing else in the run is held back.\nA site with no record gets one on its first run, from the version its\n.ailk-site-manifest.json already records. Commit before running, and review\nthe diff after.\n";
51
+ export declare const UPDATE_HELP = "create-ailk update \u2014 bring template fixes into a site's copied app code.\n\nUsage:\n create-ailk update [<target-dir>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --help, -h Print this message.\n\nReads .ailk-template-files.json (written beside .ailk-site-manifest.json by\nevery template scaffold) to tell an untouched copy from an edit. An\nuntouched apps/web, apps/api or apps/mcp file whose template copy changed is\noverwritten. An edited file whose template copy also changed is three-way\nmerged with `git` (git must be on PATH); a conflict leaves the file alone\nand is listed under \"Needs review\". Every template test that imports a file\nleft under \"Needs review\" or \"Skipped\" (by a relative import) is held at the\nsite's version with it, its record entry is not advanced, and it is listed\nunder \"Held back with <file>\" \u2014 reconcile the file, then run update again to\nbring its tests forward. Every other file in the run still updates.\nA site with no record gets one on its first run, from the version its\n.ailk-site-manifest.json already records. Commit before running, and review\nthe diff after.\n";
47
52
  /** Parse the argv AFTER the `update` word. Strict `node:util` parseArgs, like `parseAdoptArgs`. */
48
53
  export declare function parseUpdateArgs(argv: string[]): UpdateArgs;
package/dist/update.js CHANGED
@@ -9,10 +9,12 @@
9
9
  * version's copy — read from this package's own `templates/` when that is
10
10
  * the running version, else re-materialised from the published npm tarball
11
11
  * (`lib/fetch-template-snapshot.ts`) — and the running version's copy. A
12
- * conflict leaves the file untouched and is listed under "Needs review". A
13
- * site with no record gets one backfilled from `.ailk-site-manifest.json`'s
14
- * `patternVersion` on its first run, then updates normally. Every refusal
15
- * throws before the first write.
12
+ * conflict leaves the file untouched and is listed under "Needs review". Every
13
+ * template test that imports a file left under review or skipped is held at
14
+ * the site's version with it, so a run never leaves a test newer than the
15
+ * code it exercises (#8704). A site with no record gets one backfilled from
16
+ * `.ailk-site-manifest.json`'s `patternVersion` on its first run, then
17
+ * updates normally. Every refusal throws before the first write.
16
18
  *
17
19
  * Pure module: no `import.meta.url` — the CLI supplies `templatesDir`, the
18
20
  * `adopt.ts` / `add-route.ts` split.
@@ -132,6 +134,50 @@ function containsKnownPath(prefix, knownPaths) {
132
134
  }
133
135
  return false;
134
136
  }
137
+ /** A template test file: a `*.test.*` / `*.spec.*` module, or anything under `__tests__/`. */
138
+ const TEST_FILE = /(^|\/)__tests__\/|\.(test|spec)\.[cm]?[jt]sx?$/;
139
+ /** Module specifiers `import`/`export … from`, `import()`, `require()` and `jest.mock()` name. */
140
+ const IMPORT_SPECIFIER = /\bfrom\s*["']([^"']+)["']|\bimport\s*["']([^"']+)["']|\b(?:import|require|jest\.mock|jest\.requireActual)\s*\(\s*["']([^"']+)["']/g;
141
+ const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
142
+ /**
143
+ * The record-style paths a relative specifier in `fromPath` can resolve to:
144
+ * the path as written, with each source extension, a `.js`-family suffix
145
+ * swapped for its TypeScript source, and as a directory's `index.*`.
146
+ */
147
+ function relativeImportCandidates(fromPath, specifier) {
148
+ const parts = fromPath.split("/").slice(0, -1);
149
+ for (const segment of specifier.split("/")) {
150
+ if (segment === "" || segment === ".")
151
+ continue;
152
+ if (segment === "..")
153
+ parts.pop();
154
+ else
155
+ parts.push(segment);
156
+ }
157
+ const target = parts.join("/");
158
+ const out = [target, ...RESOLVE_EXTENSIONS.map((ext) => `${target}${ext}`)];
159
+ const jsSuffix = /\.([cm]?)jsx?$/.exec(target);
160
+ if (jsSuffix !== null) {
161
+ const stem = target.slice(0, -jsSuffix[0].length);
162
+ out.push(`${stem}.${jsSuffix[1]}ts`, `${stem}.tsx`);
163
+ }
164
+ out.push(...RESOLVE_EXTENSIONS.map((ext) => `${target}/index${ext}`));
165
+ return out;
166
+ }
167
+ /** The files of `held` that `text`, the source of `fromPath`, imports by relative specifier. */
168
+ function importedHeldFiles(fromPath, text, held) {
169
+ const out = new Set();
170
+ for (const match of text.matchAll(IMPORT_SPECIFIER)) {
171
+ const specifier = match[1] ?? match[2] ?? match[3];
172
+ if (specifier === undefined || !specifier.startsWith("."))
173
+ continue;
174
+ for (const candidate of relativeImportCandidates(fromPath, specifier)) {
175
+ if (held.has(candidate))
176
+ out.add(candidate);
177
+ }
178
+ }
179
+ return [...out];
180
+ }
135
181
  export async function updateSite(opts) {
136
182
  const targetDir = resolve(opts.targetDir);
137
183
  const version = opts.version;
@@ -391,37 +437,85 @@ export async function updateSite(opts) {
391
437
  nextFiles[path] = { sha256: sha256Hex(newBytes), version };
392
438
  added.push(path);
393
439
  }
440
+ // ── Hold back the template tests of every file left alone (#8704) ────
441
+ // A test this run would move forward (update, merge, add, remove, or
442
+ // advance as current) that imports a file under review or skipped stays
443
+ // exactly as the site has it, and its row keeps its recorded version — so
444
+ // the tests on disk always match the code they exercise. The imports are
445
+ // read from both the site's copy and the running template's: either one
446
+ // exercising the held file is enough. A held test-support file
447
+ // (`__tests__/fixtures.ts`) holds the tests importing it in turn, until no
448
+ // further test is held.
449
+ const heldFiles = new Set([...review, ...skipped].map((row) => row.path));
450
+ const heldBackByFile = new Map();
451
+ const heldTests = new Set();
452
+ const moving = [...updated, ...merged, ...added, ...removed, ...current]
453
+ .filter((path) => TEST_FILE.test(path))
454
+ .sort();
455
+ for (let grew = heldFiles.size > 0; grew;) {
456
+ grew = false;
457
+ for (const path of moving) {
458
+ if (heldTests.has(path))
459
+ continue;
460
+ const sources = [];
461
+ const abs = join(targetDir, ...path.split("/"));
462
+ if (existsSync(abs))
463
+ sources.push(readFileSync(abs, "utf8"));
464
+ const newBytes = newMap.get(path);
465
+ if (newBytes !== undefined)
466
+ sources.push(Buffer.from(newBytes).toString("utf8"));
467
+ const imported = new Set(sources.flatMap((text) => importedHeldFiles(path, text, heldFiles)));
468
+ if (imported.size === 0)
469
+ continue;
470
+ heldTests.add(path);
471
+ heldFiles.add(path);
472
+ grew = true;
473
+ for (const file of imported) {
474
+ heldBackByFile.set(file, [...(heldBackByFile.get(file) ?? []), path]);
475
+ }
476
+ }
477
+ }
478
+ for (const path of heldTests) {
479
+ const recorded = record.files[path];
480
+ if (recorded !== undefined)
481
+ nextFiles[path] = recorded;
482
+ else
483
+ delete nextFiles[path];
484
+ }
485
+ const notHeld = (path) => !heldTests.has(path);
486
+ const heldBack = [...heldBackByFile.entries()]
487
+ .sort(([a], [b]) => a.localeCompare(b))
488
+ .map(([file, tests]) => ({ file, tests }));
394
489
  // ── Apply: writes and removals, then the record (D4 Order) ───────────
395
- for (const write of writes) {
490
+ for (const write of writes.filter((w) => notHeld(w.path))) {
396
491
  const abs = join(targetDir, ...write.path.split("/"));
397
492
  mkdirSync(dirname(abs), { recursive: true });
398
493
  writeFileSync(abs, write.bytes);
399
494
  }
400
- for (const path of deletes) {
495
+ for (const path of deletes.filter(notHeld)) {
401
496
  rmSync(join(targetDir, ...path.split("/")));
402
497
  }
403
498
  const backfilled = backfillPatternVersion !== undefined
404
499
  ? { version: backfillPatternVersion, files: Object.keys(record.files).length }
405
500
  : undefined;
406
- const recordChanged = backfilled !== undefined ||
407
- updated.length > 0 ||
408
- merged.length > 0 ||
409
- added.length > 0 ||
410
- removed.length > 0 ||
411
- current.length > 0;
501
+ const moved = {
502
+ updated: updated.filter(notHeld),
503
+ merged: merged.filter(notHeld),
504
+ added: added.filter(notHeld),
505
+ removed: removed.filter(notHeld),
506
+ current: current.filter(notHeld),
507
+ };
508
+ const recordChanged = backfilled !== undefined || Object.values(moved).some((paths) => paths.length > 0);
412
509
  if (recordChanged) {
413
510
  writeFileSync(recordPath, serializeTemplateRecord({ schema: 1, projectName: record.projectName, files: nextFiles }));
414
511
  }
415
512
  return {
416
513
  targetDir,
417
514
  version,
418
- updated,
419
- merged,
420
- added,
421
- removed,
422
- current,
515
+ ...moved,
423
516
  review,
424
517
  skipped,
518
+ heldBack,
425
519
  ...(backfilled !== undefined ? { backfilled } : {}),
426
520
  };
427
521
  }
@@ -448,6 +542,10 @@ export function formatUpdateReport(result) {
448
542
  lines.push(`Needs review (${result.review.length}):`);
449
543
  lines.push(...result.review.map((row) => ` ${row.path} — ${row.reason}`));
450
544
  }
545
+ for (const { file, tests } of result.heldBack) {
546
+ lines.push(`Held back with ${file} (${tests.length}):`);
547
+ lines.push(...tests.map((path) => ` ${path}`));
548
+ }
451
549
  if (result.skipped.length > 0) {
452
550
  lines.push(`Skipped (${result.skipped.length}):`);
453
551
  lines.push(...result.skipped.map((row) => ` ${row.path} — ${row.reason}`));
@@ -481,7 +579,11 @@ every template scaffold) to tell an untouched copy from an edit. An
481
579
  untouched apps/web, apps/api or apps/mcp file whose template copy changed is
482
580
  overwritten. An edited file whose template copy also changed is three-way
483
581
  merged with \`git\` (git must be on PATH); a conflict leaves the file alone
484
- and is listed under "Needs review" — nothing else in the run is held back.
582
+ and is listed under "Needs review". Every template test that imports a file
583
+ left under "Needs review" or "Skipped" (by a relative import) is held at the
584
+ site's version with it, its record entry is not advanced, and it is listed
585
+ under "Held back with <file>" — reconcile the file, then run update again to
586
+ bring its tests forward. Every other file in the run still updates.
485
587
  A site with no record gets one on its first run, from the version its
486
588
  .ailk-site-manifest.json already records. Commit before running, and review
487
589
  the diff after.