create-ailk 0.8.0 → 0.10.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 (238) hide show
  1. package/README.md +71 -3
  2. package/component-catalog.json +279 -45
  3. package/dist/add-module.d.ts +20 -0
  4. package/dist/add-module.js +43 -7
  5. package/dist/add-route.d.ts +56 -0
  6. package/dist/add-route.js +286 -0
  7. package/dist/apply-design-md.d.ts +7 -0
  8. package/dist/apply-design-md.js +50 -0
  9. package/dist/apply-design.d.ts +64 -0
  10. package/dist/apply-design.js +273 -0
  11. package/dist/claude-md.d.ts +111 -0
  12. package/dist/claude-md.js +334 -0
  13. package/dist/cli.js +104 -5
  14. package/dist/derive-architecture.js +41 -36
  15. package/dist/design-compile.d.ts +71 -0
  16. package/dist/design-compile.js +98 -0
  17. package/dist/design-package.d.ts +83 -0
  18. package/dist/design-package.js +468 -0
  19. package/dist/design-presets.d.ts +86 -0
  20. package/dist/design-presets.js +642 -0
  21. package/dist/design-scaffold.d.ts +76 -0
  22. package/dist/design-scaffold.js +220 -0
  23. package/dist/index.d.ts +2 -1
  24. package/dist/index.js +4 -1
  25. package/dist/legacy-nav-renderers.d.ts +45 -0
  26. package/dist/legacy-nav-renderers.js +545 -0
  27. package/dist/lib/auth-secret-file.d.ts +19 -0
  28. package/dist/lib/auth-secret-file.js +112 -0
  29. package/dist/lib/design-checks.d.ts +78 -0
  30. package/dist/lib/design-checks.js +174 -0
  31. package/dist/lib/license-key-files.d.ts +9 -0
  32. package/dist/lib/license-key-files.js +2 -2
  33. package/dist/lib/module-license-gate.d.ts +48 -32
  34. package/dist/lib/module-license-gate.js +117 -8
  35. package/dist/lib/render-verdict.d.ts +52 -0
  36. package/dist/lib/render-verdict.js +77 -0
  37. package/dist/lib/scaffold-render-sweep.d.ts +105 -0
  38. package/dist/lib/scaffold-render-sweep.js +236 -0
  39. package/dist/module-architecture.d.ts +19 -16
  40. package/dist/module-architecture.js +67 -23
  41. package/dist/parse-args.d.ts +12 -2
  42. package/dist/parse-args.js +74 -13
  43. package/dist/programmatic.d.ts +30 -6
  44. package/dist/programmatic.js +216 -19
  45. package/dist/site-chrome.d.ts +19 -9
  46. package/dist/site-chrome.js +23 -13
  47. package/dist/site-manifest.d.ts +25 -0
  48. package/dist/site-manifest.js +254 -0
  49. package/dist/site-roster.d.ts +41 -0
  50. package/dist/site-roster.js +141 -8
  51. package/dist/starter-grid.d.ts +16 -63
  52. package/dist/starter-grid.js +102 -84
  53. package/dist/surfaces.d.ts +41 -9
  54. package/dist/surfaces.js +200 -83
  55. package/dist/template-manifest.d.ts +30 -13
  56. package/dist/template-manifest.js +53 -28
  57. package/package.json +6 -3
  58. package/release-scope.json +25 -0
  59. package/site-templates/_base/content/en/UseCase/persona-1.mdx +7 -6
  60. package/site-templates/_base/content/en/UseCase/persona-2.mdx +7 -6
  61. package/site-templates/_base/content/en/UseCase/persona-3.mdx +7 -6
  62. package/site-templates/designs/minimalism/DESIGN.md +98 -6
  63. package/site-templates/designs/minimalism/sample/README.md +48 -0
  64. package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +1 -1
  65. package/site-templates/designs/standard/DESIGN.md +98 -6
  66. package/site-templates/marketing-site/DESIGN.md +98 -6
  67. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +60 -27
  68. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +50 -30
  69. package/site-templates/marketing-site-free/DESIGN.md +98 -6
  70. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +60 -24
  71. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +44 -27
  72. package/site-templates/marketing-site-free/ailk-template.json +1 -1
  73. package/site-templates/saas-product/DESIGN.md +98 -6
  74. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +127 -18
  75. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +74 -20
  76. package/site-templates/shapes/local-business-service/ailk-template.json +1 -1
  77. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +127 -18
  78. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +74 -20
  79. package/site-templates/shapes/organization-service/ailk-template.json +1 -1
  80. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +127 -18
  81. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +75 -21
  82. package/site-templates/shapes/person-service/ailk-template.json +1 -1
  83. package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +1 -1
  84. package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +2 -2
  85. package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +4 -2
  86. package/site-templates/shapes/person-service/content/en/ServicesList/services.mdx +1 -1
  87. package/templates/.claude/agents/web.md +10 -8
  88. package/templates/.claude/rules/architecture.md +17 -7
  89. package/templates/.claude/skills/README.md +3 -2
  90. package/templates/.claude/skills/add-auth/SKILL.md +142 -90
  91. package/templates/.claude/skills/add-auth/references/env-vars.md +32 -17
  92. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +17 -8
  93. package/templates/.claude/skills/add-page/SKILL.md +241 -238
  94. package/templates/.claude/skills/add-page/references/page-types.md +94 -86
  95. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
  96. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
  97. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
  98. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
  99. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
  100. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
  101. package/templates/.claude/skills/check-site/SKILL.md +51 -18
  102. package/templates/.claude/skills/check-site/references/common-errors.md +12 -9
  103. package/templates/.claude/skills/check-site/references/findings-glossary.md +4 -4
  104. package/templates/.claude/skills/check-site/references/severity-levels.md +3 -3
  105. package/templates/.claude/skills/fill-page/SKILL.md +26 -7
  106. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
  107. package/templates/.claude/skills/setup-deploy/SKILL.md +75 -38
  108. package/templates/.claude/skills/update-page/SKILL.md +117 -0
  109. package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
  110. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
  111. package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
  112. package/templates/.env.example +23 -2
  113. package/templates/.github/workflows/ci.yml +4 -0
  114. package/templates/.prettierignore +1 -0
  115. package/templates/CLAUDE.md +146 -103
  116. package/templates/DESIGN.md +98 -6
  117. package/templates/README.md +50 -38
  118. package/templates/apps/api/.env.example +2 -1
  119. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
  120. package/templates/apps/api/src/__tests__/server.test.ts +116 -0
  121. package/templates/apps/api/src/lib/stripe.ts +9 -0
  122. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
  123. package/templates/apps/api/src/middleware/auth.ts +54 -0
  124. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
  125. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
  126. package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
  127. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
  128. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
  129. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
  130. package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
  131. package/templates/apps/api/src/server.ts +28 -0
  132. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
  133. package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
  134. package/templates/apps/api/src/services/revalidation.ts +60 -15
  135. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
  136. package/templates/apps/mcp/src/tools/purchase.ts +4 -2
  137. package/templates/apps/web/.env.example +13 -0
  138. package/templates/apps/web/CLAUDE.md +22 -28
  139. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
  140. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
  141. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
  142. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
  143. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
  144. package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
  145. package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
  146. package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
  147. package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +4 -3
  148. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
  149. package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
  150. package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
  151. package/templates/apps/web/app/[locale]/layout.tsx +35 -19
  152. package/templates/apps/web/app/[locale]/newsletter/page.tsx +1 -1
  153. package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +1 -1
  154. package/templates/apps/web/app/[locale]/portfolio/page.tsx +1 -1
  155. package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
  156. package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
  157. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
  158. package/templates/apps/web/app/api/revalidate/route.ts +78 -36
  159. package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
  160. package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
  161. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
  162. package/templates/apps/web/lib/locale-guard.ts +18 -0
  163. package/templates/apps/web/lib/meet-placeholder.ts +5 -0
  164. package/templates/apps/web/lib/nav-config.ts +14 -13
  165. package/templates/apps/web/lib/site-brand.tsx +13 -9
  166. package/templates/apps/web/lib/site-logo.tsx +36 -0
  167. package/templates/apps/web/lib/site-theme.ts +46 -26
  168. package/templates/apps/web/messages/en.json +23 -9
  169. package/templates/apps/web/middleware.ts +28 -4
  170. package/templates/content/en/BookCall/book-a-call.mdx +80 -0
  171. package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
  172. package/templates/content/en/BookCall/onsite-service.mdx +56 -0
  173. package/templates/content/en/BookCall/start-project.mdx +56 -0
  174. package/templates/content/en/ContactPage/contact.mdx +40 -1
  175. package/templates/content/en/HomePage/home.mdx +2 -6
  176. package/templates/content/en/LandingPage/meet.mdx +59 -0
  177. package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
  178. package/templates/database/CHANGELOG.md +7 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/package.json +4 -1
  181. package/templates/tools/check-claude-md.ts +128 -0
  182. package/versions.json +33 -33
  183. package/dist/modules/commerce/0.6.0.tar.gz +0 -0
  184. package/dist/modules/commerce/0.6.1.tar.gz +0 -0
  185. package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
  186. package/dist/modules/docs-blog/0.6.1.tar.gz +0 -0
  187. package/dist/modules/marketing-site/0.6.1.tar.gz +0 -0
  188. package/dist/modules/minimalism-site/0.6.1.tar.gz +0 -0
  189. package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
  190. package/dist/modules/multi-tenant/0.6.1.tar.gz +0 -0
  191. package/dist/modules/saas-product/0.6.1.tar.gz +0 -0
  192. package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
  193. package/dist/modules/scheduling/0.6.1.tar.gz +0 -0
  194. package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
  195. package/dist/modules/signal-collectors/0.6.1.tar.gz +0 -0
  196. package/dist/modules/simple-service/0.6.1.tar.gz +0 -0
  197. package/dist/modules/workspace/0.6.0.tar.gz +0 -0
  198. package/dist/modules/workspace/0.6.1.tar.gz +0 -0
  199. package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -52
  200. package/site-templates/minimalism-site/DESIGN.md +0 -162
  201. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +0 -506
  202. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -458
  203. package/site-templates/minimalism-site/ailk-template.json +0 -23
  204. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
  205. package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -52
  206. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
  207. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
  208. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
  209. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
  210. package/site-templates/minimalism-site/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -123
  211. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
  212. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
  213. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
  214. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
  215. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +0 -38
  216. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +0 -38
  217. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +0 -38
  218. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +0 -73
  219. package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
  220. package/site-templates/minimalism-site/provenance.json +0 -4
  221. package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
  222. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +0 -175
  223. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +0 -8
  224. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +0 -12
  225. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +0 -12
  226. package/templates/apps/web/app/[locale]/book-a-call/page.tsx +0 -50
  227. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
  228. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
  229. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
  230. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
  231. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
  232. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
  233. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
  234. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
  235. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
  236. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
  237. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
  238. /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
@@ -0,0 +1,78 @@
1
+ /** One row of theme's contrast audit, as the audit script prints it. */
2
+ export interface ContrastRow {
3
+ name: string;
4
+ bg: string;
5
+ fg: string;
6
+ ratio: number | null;
7
+ threshold: number;
8
+ status: "pass" | "fail" | "unmeasurable";
9
+ }
10
+ export type DesignContrastVerdict = {
11
+ ok: true;
12
+ pairs: number;
13
+ } | {
14
+ ok: false;
15
+ pairs: number;
16
+ failing: ContrastRow[];
17
+ reason: string;
18
+ };
19
+ /**
20
+ * Decide whether `design`'s derived token set passes WCAG AA. Every row whose
21
+ * status is not `pass` fails it; an audit of no pair never passes.
22
+ */
23
+ export declare function assessDesignContrast(design: string, results: ContrastRow[]): DesignContrastVerdict;
24
+ /** `__tests__/fixtures/page-jsonld.ts`'s stdout: page path → its composed JSON-LD. */
25
+ export type ComposedPages = Record<string, {
26
+ jsonld: string;
27
+ sectionsValid: boolean;
28
+ }>;
29
+ /** One changed node: the page and its JSON-pointer path (or a `(page …)` pseudo-path). */
30
+ export interface DriftNode {
31
+ page: string;
32
+ path: string;
33
+ }
34
+ /**
35
+ * Every node that differs between two compositions, sorted by page then path.
36
+ * A removed or added page, and a page whose sections no longer validate after,
37
+ * are one pseudo-path node each; a `logo` subtree is never compared.
38
+ */
39
+ export declare function jsonLdDrift(before: ComposedPages, after: ComposedPages): DriftNode[];
40
+ export type JsonLdDriftVerdict = {
41
+ ok: true;
42
+ pages: number;
43
+ } | {
44
+ ok: false;
45
+ pages: number;
46
+ changed: DriftNode[];
47
+ reason: string;
48
+ };
49
+ /** Decide whether `apply-design <design>` left every page's JSON-LD unchanged (logo excluded). */
50
+ export declare function assessJsonLdDrift(design: string, before: ComposedPages, after: ComposedPages): JsonLdDriftVerdict;
51
+ export interface MeasureApplyDesignDriftOptions {
52
+ /** create-ailk's built `dist/cli.js`. */
53
+ cliPath: string;
54
+ /** The monorepo's `tsx` binary. */
55
+ tsxPath: string;
56
+ /** `__tests__/fixtures/page-jsonld.ts` — the one production composer, run from source. */
57
+ composerPath: string;
58
+ /** Its tsconfig, passed as `TSX_TSCONFIG_PATH`. */
59
+ composerTsconfig: string;
60
+ /** The design `apply-design` applies (a name or a package path). */
61
+ design: string;
62
+ /** An existing empty directory; the drift site is scaffolded at `<workDir>/site`. */
63
+ workDir: string;
64
+ /** The loopback registry the CLI resolves modules and templates from. */
65
+ registryUrl?: string;
66
+ /** Written to the site's own `ailk.key`, which `apply-design` gates a non-Free design on. */
67
+ licenseKey?: string;
68
+ }
69
+ /**
70
+ * Scaffold a keyless `organization-product` site on `standard`, compose its
71
+ * JSON-LD, run `apply-design <design>` on it, and compose again. A non-zero
72
+ * exit at any step throws naming the step and the child's stderr.
73
+ */
74
+ export declare function measureApplyDesignDrift(opts: MeasureApplyDesignDriftOptions): {
75
+ before: ComposedPages;
76
+ after: ComposedPages;
77
+ applyStdout: string;
78
+ };
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The two design checks a design-wearing run of `scripts/verify-scaffold.mjs`
3
+ * runs before install (#6823, spec
4
+ * docs/specs/6823-the-matrix-runs-the-derived-spec.md D3).
5
+ *
6
+ * CONTRAST — `assessDesignContrast` turns the rows
7
+ * `packages/theme/scripts/audit-site-theme-contrast.ts` prints (theme's
8
+ * `auditTokenSetContrast` over the design's derived token set) into a verdict
9
+ * naming every non-passing `{bg, fg}` pair and the design. The WCAG math and
10
+ * the pair list stay theme's; this file only decides pass/fail.
11
+ *
12
+ * DRIFT — `jsonLdDrift` / `assessJsonLdDrift` compare every page's composed
13
+ * JSON-LD before and after `apply-design`, excluding the logo image (the PRD
14
+ * metric "structural drift after apply"), and `measureApplyDesignDrift`
15
+ * gathers the two snapshots (the `renderRoute` precedent, #6821 D4): it
16
+ * touches the filesystem and processes only through the paths it is handed,
17
+ * so the driver and `__tests__/verify-scaffold-checks.test.ts` run the same
18
+ * procedure. Nothing here imports `@working-theory/theme`.
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+ import { writeFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ function describePair(row) {
24
+ const measured = row.status === "unmeasurable" || row.ratio === null
25
+ ? "unmeasurable"
26
+ : `${row.ratio.toFixed(2)}:1 < ${row.threshold}:1`;
27
+ return `${row.name} {bg ${row.bg}, fg ${row.fg}} ${measured}`;
28
+ }
29
+ /**
30
+ * Decide whether `design`'s derived token set passes WCAG AA. Every row whose
31
+ * status is not `pass` fails it; an audit of no pair never passes.
32
+ */
33
+ export function assessDesignContrast(design, results) {
34
+ const pairs = results.length;
35
+ if (pairs === 0) {
36
+ return { ok: false, pairs, failing: [], reason: `design ${design}: no contrast pair was audited` };
37
+ }
38
+ const failing = results.filter((row) => row.status !== "pass");
39
+ if (failing.length === 0)
40
+ return { ok: true, pairs };
41
+ return {
42
+ ok: false,
43
+ pairs,
44
+ failing,
45
+ reason: `design ${design}: ${failing.length} of ${pairs} contrast pair(s) below WCAG AA — ${failing
46
+ .map(describePair)
47
+ .join("; ")}`,
48
+ };
49
+ }
50
+ /** schema.org's logo property — the one subtree the PRD's "excluding the logo image" skips. */
51
+ const LOGO_KEY = "logo";
52
+ const kindOf = (value) => value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
53
+ function walk(before, after, path, out) {
54
+ const kind = kindOf(before);
55
+ if (kind !== kindOf(after)) {
56
+ out.push(path);
57
+ return;
58
+ }
59
+ if (kind === "array") {
60
+ const a = before;
61
+ const b = after;
62
+ if (a.length !== b.length)
63
+ out.push(path);
64
+ for (let i = 0; i < Math.min(a.length, b.length); i++)
65
+ walk(a[i], b[i], `${path}/${i}`, out);
66
+ return;
67
+ }
68
+ if (kind === "object") {
69
+ const a = before;
70
+ const b = after;
71
+ for (const key of [...new Set([...Object.keys(a), ...Object.keys(b)])].sort()) {
72
+ if (key === LOGO_KEY)
73
+ continue;
74
+ walk(a[key], b[key], `${path}/${key}`, out);
75
+ }
76
+ return;
77
+ }
78
+ if (before !== after)
79
+ out.push(path);
80
+ }
81
+ /**
82
+ * Every node that differs between two compositions, sorted by page then path.
83
+ * A removed or added page, and a page whose sections no longer validate after,
84
+ * are one pseudo-path node each; a `logo` subtree is never compared.
85
+ */
86
+ export function jsonLdDrift(before, after) {
87
+ const nodes = [];
88
+ for (const page of [...new Set([...Object.keys(before), ...Object.keys(after)])]) {
89
+ const b = before[page];
90
+ const a = after[page];
91
+ if (a === undefined) {
92
+ nodes.push({ page, path: "(page removed)" });
93
+ }
94
+ else if (b === undefined) {
95
+ nodes.push({ page, path: "(page added)" });
96
+ }
97
+ else if (!a.sectionsValid) {
98
+ nodes.push({ page, path: "(sections invalid)" });
99
+ }
100
+ else {
101
+ const paths = [];
102
+ walk(JSON.parse(b.jsonld), JSON.parse(a.jsonld), "", paths);
103
+ for (const path of paths)
104
+ nodes.push({ page, path: path === "" ? "/" : path });
105
+ }
106
+ }
107
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
108
+ return nodes.sort((x, y) => cmp(x.page, y.page) || cmp(x.path, y.path));
109
+ }
110
+ /** How many changed nodes a drift reason lists before it stops. */
111
+ const REASON_NODE_LIMIT = 10;
112
+ /** Decide whether `apply-design <design>` left every page's JSON-LD unchanged (logo excluded). */
113
+ export function assessJsonLdDrift(design, before, after) {
114
+ const pages = Object.keys(before).length;
115
+ if (pages === 0) {
116
+ return { ok: false, pages, changed: [], reason: `design ${design}: no page was composed` };
117
+ }
118
+ const changed = jsonLdDrift(before, after);
119
+ if (changed.length === 0)
120
+ return { ok: true, pages };
121
+ const listed = changed
122
+ .slice(0, REASON_NODE_LIMIT)
123
+ .map((n) => `${n.page} ${n.path}`)
124
+ .join("; ");
125
+ return {
126
+ ok: false,
127
+ pages,
128
+ changed,
129
+ reason: `design ${design}: ${changed.length} JSON-LD node(s) changed by apply-design — ${listed}${changed.length > REASON_NODE_LIMIT ? "; …" : ""}`,
130
+ };
131
+ }
132
+ /**
133
+ * Scaffold a keyless `organization-product` site on `standard`, compose its
134
+ * JSON-LD, run `apply-design <design>` on it, and compose again. A non-zero
135
+ * exit at any step throws naming the step and the child's stderr.
136
+ */
137
+ export function measureApplyDesignDrift(opts) {
138
+ const baseEnv = { ...process.env };
139
+ delete baseEnv["AILK_LICENSE_KEY"];
140
+ const registryEnv = opts.registryUrl === undefined
141
+ ? {}
142
+ : { AILK_MODULE_REGISTRY_URL: opts.registryUrl, AILK_TEMPLATE_REGISTRY_URL: opts.registryUrl };
143
+ const site = join(opts.workDir, "site");
144
+ const exec = (label, cmd, args, env) => {
145
+ try {
146
+ return execFileSync(cmd, args, {
147
+ encoding: "utf8",
148
+ env: { ...baseEnv, ...env },
149
+ stdio: ["ignore", "pipe", "pipe"],
150
+ maxBuffer: 64 * 1024 * 1024,
151
+ });
152
+ }
153
+ catch (err) {
154
+ const { stderr, message } = err;
155
+ throw new Error(`drift: ${label} failed: ${(stderr || message || "").trim()}`, { cause: err });
156
+ }
157
+ };
158
+ const compose = (label) => {
159
+ const stdout = exec(label, opts.tsxPath, [opts.composerPath, site], { TSX_TSCONFIG_PATH: opts.composerTsconfig });
160
+ try {
161
+ return JSON.parse(stdout);
162
+ }
163
+ catch (err) {
164
+ throw new Error(`drift: ${label} printed no JSON: ${err.message}`, { cause: err });
165
+ }
166
+ };
167
+ exec("scaffold organization-product on standard", process.execPath, [opts.cliPath, site, "--shape", "organization-product", "--design", "standard"], registryEnv);
168
+ if (opts.licenseKey !== undefined)
169
+ writeFileSync(join(site, "ailk.key"), `${opts.licenseKey}\n`);
170
+ const before = compose("compose JSON-LD before apply-design");
171
+ const applyStdout = exec(`apply-design ${opts.design}`, process.execPath, [opts.cliPath, "apply-design", opts.design, "--target", site], opts.registryUrl === undefined ? {} : { AILK_MODULE_REGISTRY_URL: opts.registryUrl });
172
+ const after = compose("compose JSON-LD after apply-design");
173
+ return { before, after, applyStdout };
174
+ }
@@ -6,6 +6,15 @@ export declare const LICENSE_ENV_VAR = "AILK_LICENSE_KEY";
6
6
  * describes the rule, never the value.
7
7
  */
8
8
  export declare function assertLicenseKeyShape(key: string): void;
9
+ /**
10
+ * Write `content` only when it differs, with mode 0600 either way. An existing
11
+ * file is tightened BEFORE the write — `mode` applies only on creation, so a
12
+ * looser pre-existing `.env` would otherwise hold the key for a moment at its
13
+ * old permissions.
14
+ */
15
+ export declare function writeOwnerOnly(path: string, content: string): void;
16
+ /** `text` plus `line`, starting on a fresh line. */
17
+ export declare function appendLine(text: string, line: string): string;
9
18
  /**
10
19
  * Persist `key` into the scaffold at `dir`: `ailk.key` (0600), the
11
20
  * `apps/api/.env` line (0600, other lines kept), and a `.gitignore` entry for
@@ -48,7 +48,7 @@ export function assertLicenseKeyShape(key) {
48
48
  * looser pre-existing `.env` would otherwise hold the key for a moment at its
49
49
  * old permissions.
50
50
  */
51
- function writeOwnerOnly(path, content) {
51
+ export function writeOwnerOnly(path, content) {
52
52
  mkdirSync(dirname(path), { recursive: true });
53
53
  if (existsSync(path))
54
54
  chmodSync(path, 0o600);
@@ -58,7 +58,7 @@ function writeOwnerOnly(path, content) {
58
58
  chmodSync(path, 0o600);
59
59
  }
60
60
  /** `text` plus `line`, starting on a fresh line. */
61
- function appendLine(text, line) {
61
+ export function appendLine(text, line) {
62
62
  const sep = text.length === 0 || text.endsWith("\n") ? "" : "\n";
63
63
  return `${text}${sep}${line}\n`;
64
64
  }
@@ -1,28 +1,4 @@
1
- /**
2
- * Paid-module license gate (K1, #5089 — Flavor 3). Orchestrates
3
- * `module-tier.ts`'s classification + `fetch-module.ts`'s authenticated
4
- * fetch into the three honest states the CLI must surface:
5
- *
6
- * (a) no key + a paid module requested → conversion hint, non-zero, ZERO
7
- * network (mirrors `install-skill.ts`'s free-tier short-circuit).
8
- * (b) a lapsed/invalid key + a paid module → "your licence does not cover
9
- * this", non-zero (the module distribution's own 401 — see
10
- * `fetch-module.ts`'s file doc for why there is no local JWT
11
- * pre-check here).
12
- * (c) a valid key → every requested paid module's bundle bytes, ready for
13
- * `extract-module-bundle.ts` to merge into the scaffold.
14
- *
15
- * A Free-only request (no paid modules) never reaches the key check at all —
16
- * `resolvePaidModules` returns immediately, before touching `process.env` or
17
- * the network, so a bare/all-Free scaffold is provably unaffected by this
18
- * gate (K1's regression floor).
19
- *
20
- * Also carries the design gate (#6760): `assertDesignAllowed` refuses a
21
- * non-`standard` design at the `free` level, naming the design and the
22
- * run's level, before `scaffold()`'s first disk write.
23
- */
24
1
  import type { ModuleName } from "../parse-args.js";
25
- import type { StarterDesign } from "../template-manifest.js";
26
2
  import { type Level } from "./module-tier.js";
27
3
  /**
28
4
  * Where a refused run sends the buyer. `/pricing`, not `/pro`: there is no
@@ -39,18 +15,58 @@ export declare const CONVERSION_HINT_URL = "https://ailaunchkit.ai/pricing";
39
15
  export declare class LicenseRequiredError extends Error {
40
16
  constructor(message: string);
41
17
  }
42
- /** Designs every level renders (#6760 D1). Anything else in `STARTER_DESIGNS` is Pro and up. */
43
- export declare const FREE_DESIGNS: readonly StarterDesign[];
18
+ /** The five axes `release-scope.json` declares (spec 6977 D3). */
19
+ export type ReleaseAxis = "templates" | "shapes" | "designs" | "modules" | "levels";
20
+ /** `gated` and `deferred` are both refused; only a `gated` one gets negative checks. */
21
+ export type ReleaseStatus = "released" | "gated" | "deferred";
22
+ export type ReleaseScope = Readonly<Record<ReleaseAxis, Readonly<Record<string, ReleaseStatus>>>>;
23
+ /**
24
+ * The package-root `release-scope.json` (shipped in `files`). A static JSON
25
+ * import, not an `import.meta.url` read, for `fetch-module.ts`'s reason: jest
26
+ * loads this module directly. `release-scope.test.ts` pins every registry
27
+ * name to an entry here (R5).
28
+ */
29
+ export declare const RELEASE_SCOPE: ReleaseScope;
30
+ /** Thrown for a surface outside the release scope — a refusal, not a licence state. */
31
+ export declare class ReleaseScopeError extends Error {
32
+ constructor(message: string);
33
+ }
34
+ /**
35
+ * Refuse any of `names` that `scope[axis]` marks `gated` or `deferred`, with
36
+ * the not-available message `parse-args.ts` gives a reserved shape. A name
37
+ * the scope does not list (a customer's own design, say) is not refused.
38
+ * Bypassed when `AILK_RELEASE_SCOPE=ignore` — this package's jest setup and
39
+ * an operator's own run; a published CLI never sets it.
40
+ *
41
+ * Called on the scaffold path only (`scaffold()`, `cli.ts`, and the level
42
+ * check in `resolveModuleSet`) — never from `parseModules`, so `add-module`
43
+ * and `--sync-architecture` keep working on a site that already carries a
44
+ * gated module.
45
+ */
46
+ export declare function assertReleased(axis: ReleaseAxis, names: readonly (string | undefined)[], scope?: ReleaseScope): void;
47
+ /**
48
+ * Design names every level renders (#6760 D1). Anything else in
49
+ * `STARTER_DESIGNS` is Pro and up. A path design's Free-ness is decided by
50
+ * `resolveDesign`'s identity rule (#6813 D2), never by this list directly —
51
+ * `assertDesignAllowed`'s `free` parameter carries that verdict in.
52
+ */
53
+ export declare const FREE_DESIGNS: readonly string[];
44
54
  /** Levels that render any design (#6760 D1) — an allow-list: an unknown level is not in it. */
45
55
  export declare const DESIGN_LEVELS: readonly Level[];
46
56
  /**
47
- * Refuse a non-`standard` design at a level below Pro (#6760 D1, D6). Pure
48
- * and synchronous — takes the level `deriveLevel`/`resolveModuleSet` already
49
- * resolved; never reads a key or derives a level itself (D2, fail-closed:
50
- * both checks below are allow-lists, so an unrecognized `level` refuses
51
- * rather than granting it).
57
+ * Refuse a non-Free design at a level below Pro (#6760 D1, D6; widened to a
58
+ * path design #6813 D7). Pure and synchronous — takes the level
59
+ * `deriveLevel`/`resolveModuleSet` already resolved; never reads a key or
60
+ * derives a level itself (D2, fail-closed: both checks below are allow-lists,
61
+ * so an unrecognized `level` refuses rather than granting it).
62
+ *
63
+ * @param free Whether `design` is Free — defaults to `FREE_DESIGNS.includes(design)`
64
+ * (a design NAME's own answer), so a caller passing a name never changes
65
+ * behaviour. A path design's caller (`programmatic.ts`, off `resolveDesign`'s
66
+ * identity rule) passes it explicitly: a byte-identical copy of a shipped
67
+ * Free design is Free; any other path is not, whatever it is named.
52
68
  */
53
- export declare function assertDesignAllowed(design: StarterDesign | undefined, level: Level): void;
69
+ export declare function assertDesignAllowed(design: string | undefined, level: Level, free?: boolean): void;
54
70
  export interface ResolvePaidModulesOptions {
55
71
  /** The requested module set's paid subset (see `module-tier.ts`). */
56
72
  paidModules: ModuleName[];
@@ -1,3 +1,31 @@
1
+ /**
2
+ * Paid-module license gate (K1, #5089 — Flavor 3). Orchestrates
3
+ * `module-tier.ts`'s classification + `fetch-module.ts`'s authenticated
4
+ * fetch into the three honest states the CLI must surface:
5
+ *
6
+ * (a) no key + a paid module requested → conversion hint, non-zero, ZERO
7
+ * network (mirrors `install-skill.ts`'s free-tier short-circuit).
8
+ * (b) a lapsed/invalid key + a paid module → "your licence does not cover
9
+ * this", non-zero (the module distribution's own 401 — see
10
+ * `fetch-module.ts`'s file doc for why there is no local JWT
11
+ * pre-check here).
12
+ * (c) a valid key → every requested paid module's bundle bytes, ready for
13
+ * `extract-module-bundle.ts` to merge into the scaffold.
14
+ *
15
+ * A Free-only request (no paid modules) never reaches the key check at all —
16
+ * `resolvePaidModules` returns immediately, before touching `process.env` or
17
+ * the network, so a bare/all-Free scaffold is provably unaffected by this
18
+ * gate (K1's regression floor).
19
+ *
20
+ * Also carries the design gate (#6760): `assertDesignAllowed` refuses a
21
+ * non-`standard` design at the `free` level, naming the design and the
22
+ * run's level, before `scaffold()`'s first disk write.
23
+ *
24
+ * And the release-scope gate (#6984, spec 6977 D3): `assertReleased` refuses
25
+ * a template, shape, design, module or level that `release-scope.json` marks
26
+ * `gated` or `deferred`, on the scaffold path only.
27
+ */
28
+ import releaseScopeJson from "../../release-scope.json" with { type: "json" };
1
29
  import { FetchModuleError, fetchModule } from "./fetch-module.js";
2
30
  import { assertLicenseKeyShape } from "./license-key-files.js";
3
31
  import { ADD_ONS, LEVEL_LABEL, LEVEL_MODULES, OPT_IN_MODULES, PAID_MODULES, classifyModules, composeModuleSet, levelOf, } from "./module-tier.js";
@@ -19,19 +47,98 @@ export class LicenseRequiredError extends Error {
19
47
  this.name = "LicenseRequiredError";
20
48
  }
21
49
  }
22
- /** Designs every level renders (#6760 D1). Anything else in `STARTER_DESIGNS` is Pro and up. */
50
+ /**
51
+ * The package-root `release-scope.json` (shipped in `files`). A static JSON
52
+ * import, not an `import.meta.url` read, for `fetch-module.ts`'s reason: jest
53
+ * loads this module directly. `release-scope.test.ts` pins every registry
54
+ * name to an entry here (R5).
55
+ */
56
+ export const RELEASE_SCOPE = releaseScopeJson;
57
+ const AXIS_NOUN = {
58
+ templates: "template",
59
+ shapes: "shape",
60
+ designs: "design",
61
+ modules: "module",
62
+ levels: "level",
63
+ };
64
+ /** Thrown for a surface outside the release scope — a refusal, not a licence state. */
65
+ export class ReleaseScopeError extends Error {
66
+ constructor(message) {
67
+ super(message);
68
+ this.name = "ReleaseScopeError";
69
+ }
70
+ }
71
+ const RELEASE_STATUSES = ["released", "gated", "deferred"];
72
+ /**
73
+ * Refuse a scope carrying a status other than `released`, `gated` or
74
+ * `deferred` — fail closed: a misspelled status never reads as released.
75
+ */
76
+ function assertValidScope(scope) {
77
+ for (const [axis, statuses] of Object.entries(scope)) {
78
+ for (const [name, status] of Object.entries(statuses)) {
79
+ if (!RELEASE_STATUSES.includes(status)) {
80
+ throw new ReleaseScopeError(`release-scope.json ${axis}."${name}" is "${String(status)}" — expected one of: ${RELEASE_STATUSES.join(", ")}`);
81
+ }
82
+ }
83
+ }
84
+ }
85
+ /**
86
+ * Refuse any of `names` that `scope[axis]` marks `gated` or `deferred`, with
87
+ * the not-available message `parse-args.ts` gives a reserved shape. A name
88
+ * the scope does not list (a customer's own design, say) is not refused.
89
+ * Bypassed when `AILK_RELEASE_SCOPE=ignore` — this package's jest setup and
90
+ * an operator's own run; a published CLI never sets it.
91
+ *
92
+ * Called on the scaffold path only (`scaffold()`, `cli.ts`, and the level
93
+ * check in `resolveModuleSet`) — never from `parseModules`, so `add-module`
94
+ * and `--sync-architecture` keep working on a site that already carries a
95
+ * gated module.
96
+ */
97
+ export function assertReleased(axis, names, scope = RELEASE_SCOPE) {
98
+ if (process.env["AILK_RELEASE_SCOPE"] === "ignore")
99
+ return;
100
+ assertValidScope(scope);
101
+ const statuses = scope[axis];
102
+ for (const name of names) {
103
+ if (name === undefined)
104
+ continue;
105
+ const status = statuses[name];
106
+ if (status !== "gated" && status !== "deferred")
107
+ continue;
108
+ const released = Object.keys(statuses).filter((n) => statuses[n] === "released");
109
+ // A level comes from the key, never a flag — so it points at what the run CAN choose.
110
+ const choose = axis === "levels"
111
+ ? `Your licence key's level is ${name}; name the modules you want with --modules (one of: ${Object.keys(scope.modules)
112
+ .filter((m) => scope.modules[m] === "released")
113
+ .join(", ")}).`
114
+ : `Choose one of: ${released.join(", ")}.`;
115
+ throw new ReleaseScopeError(`${AXIS_NOUN[axis]} "${name}" is not available yet. ${choose}`);
116
+ }
117
+ }
118
+ /**
119
+ * Design names every level renders (#6760 D1). Anything else in
120
+ * `STARTER_DESIGNS` is Pro and up. A path design's Free-ness is decided by
121
+ * `resolveDesign`'s identity rule (#6813 D2), never by this list directly —
122
+ * `assertDesignAllowed`'s `free` parameter carries that verdict in.
123
+ */
23
124
  export const FREE_DESIGNS = ["standard"];
24
125
  /** Levels that render any design (#6760 D1) — an allow-list: an unknown level is not in it. */
25
126
  export const DESIGN_LEVELS = ["pro", "pro-plus"];
26
127
  /**
27
- * Refuse a non-`standard` design at a level below Pro (#6760 D1, D6). Pure
28
- * and synchronous — takes the level `deriveLevel`/`resolveModuleSet` already
29
- * resolved; never reads a key or derives a level itself (D2, fail-closed:
30
- * both checks below are allow-lists, so an unrecognized `level` refuses
31
- * rather than granting it).
128
+ * Refuse a non-Free design at a level below Pro (#6760 D1, D6; widened to a
129
+ * path design #6813 D7). Pure and synchronous — takes the level
130
+ * `deriveLevel`/`resolveModuleSet` already resolved; never reads a key or
131
+ * derives a level itself (D2, fail-closed: both checks below are allow-lists,
132
+ * so an unrecognized `level` refuses rather than granting it).
133
+ *
134
+ * @param free Whether `design` is Free — defaults to `FREE_DESIGNS.includes(design)`
135
+ * (a design NAME's own answer), so a caller passing a name never changes
136
+ * behaviour. A path design's caller (`programmatic.ts`, off `resolveDesign`'s
137
+ * identity rule) passes it explicitly: a byte-identical copy of a shipped
138
+ * Free design is Free; any other path is not, whatever it is named.
32
139
  */
33
- export function assertDesignAllowed(design, level) {
34
- if (design === undefined || FREE_DESIGNS.includes(design))
140
+ export function assertDesignAllowed(design, level, free = design !== undefined && FREE_DESIGNS.includes(design)) {
141
+ if (design === undefined || free)
35
142
  return;
36
143
  if (DESIGN_LEVELS.includes(level))
37
144
  return;
@@ -212,6 +319,8 @@ export async function resolveModuleSet(options) {
212
319
  return { modules, bundles };
213
320
  }
214
321
  const { level, bundles } = await deriveLevel(options);
322
+ // #6984 — a gated level (Pro Plus) is refused before its defaults compose.
323
+ assertReleased("levels", [level]);
215
324
  const modules = composeModuleSet(namedList, level);
216
325
  const { paid } = classifyModules(modules);
217
326
  const missing = paid.filter((m) => !bundles.has(m));
@@ -0,0 +1,52 @@
1
+ /** What the driver observed of one route's render. */
2
+ export interface RenderObservation {
3
+ /** The requested route, e.g. `/` or `/about`. */
4
+ route: string;
5
+ /** The message `page.goto` threw with, when it threw. */
6
+ gotoError?: string;
7
+ /** The status of the response `page.goto` resolved; undefined when it resolved none. */
8
+ status: number | undefined;
9
+ /** `page.url()` after navigation settled. */
10
+ finalUrl: string;
11
+ /** The screenshot's size on disk; undefined when none was written. */
12
+ pngBytes: number | undefined;
13
+ }
14
+ export type RenderVerdict = {
15
+ ok: true;
16
+ } | {
17
+ ok: false;
18
+ reason: string;
19
+ };
20
+ /**
21
+ * Decide whether `route` rendered. Checked in order: a navigation error (a
22
+ * net error — `ERR_TOO_MANY_REDIRECTS` included — or a timeout), a final
23
+ * status other than 200, a final pathname other than the requested route (a
24
+ * redirect elsewhere is not a render of this route), and a missing or empty
25
+ * PNG. Pathnames are compared, never whole URLs, so a query or hash is fine.
26
+ */
27
+ export declare function assessRender(observation: RenderObservation): RenderVerdict;
28
+ /** The slice of a Playwright `Page` that `renderRoute` uses. */
29
+ export interface RenderPage {
30
+ goto(url: string, options: {
31
+ waitUntil: "load";
32
+ timeout: number;
33
+ }): Promise<{
34
+ status(): number;
35
+ } | null>;
36
+ url(): string;
37
+ screenshot(options: {
38
+ path: string;
39
+ fullPage: boolean;
40
+ }): Promise<unknown>;
41
+ }
42
+ /** How long one navigation may take before it counts as a failed render. */
43
+ export declare const RENDER_TIMEOUT_MS = 60000;
44
+ /**
45
+ * Navigate `page` to `base + route`, screenshot it to `pngPath`, and return
46
+ * `assessRender`'s verdict on what happened. `status` comes from the response
47
+ * `page.goto` itself resolved and `finalUrl` from `page.url()` — never the
48
+ * requested URL, never a side `fetch` — so a redirect cannot pass as a render.
49
+ * A failed navigation takes no screenshot; a screenshot that throws counts
50
+ * as no PNG written — never a stale one left at `pngPath` by an earlier run.
51
+ */
52
+ export declare function renderRoute(page: RenderPage, base: string, route: string, pngPath: string): Promise<RenderVerdict>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The verdict on one browser render of a scaffold route (#6821, spec
3
+ * docs/specs/6821-the-shape-design-render-matrix-spec.md D4).
4
+ *
5
+ * A design leg of `scripts/verify-scaffold.mjs` renders its scaffold's home
6
+ * route and one inner route in Chromium and stores a screenshot of each — the
7
+ * shape × design render matrix. A render that did not happen must fail the
8
+ * leg, and proving that rule must not cost a scaffold build, so it lives here
9
+ * as a pure function (`assessRender`, the `api-boot-assertion.ts` precedent)
10
+ * plus the one function that gathers its facts (`renderRoute`), which touches
11
+ * Playwright only through the `page` it is handed. The driver loads both from
12
+ * `dist/`; `__tests__/verify-scaffold-render.test.ts` drives them with a fake
13
+ * page. Nothing here imports `@playwright/test`.
14
+ */
15
+ import { existsSync, rmSync, statSync } from "node:fs";
16
+ /**
17
+ * Decide whether `route` rendered. Checked in order: a navigation error (a
18
+ * net error — `ERR_TOO_MANY_REDIRECTS` included — or a timeout), a final
19
+ * status other than 200, a final pathname other than the requested route (a
20
+ * redirect elsewhere is not a render of this route), and a missing or empty
21
+ * PNG. Pathnames are compared, never whole URLs, so a query or hash is fine.
22
+ */
23
+ export function assessRender(observation) {
24
+ const { route, gotoError, status, finalUrl, pngBytes } = observation;
25
+ if (gotoError !== undefined) {
26
+ return { ok: false, reason: `navigation failed: ${gotoError}` };
27
+ }
28
+ if (status !== 200) {
29
+ return {
30
+ ok: false,
31
+ reason: status === undefined ? "navigation resolved no response" : `final status ${status}, expected 200`,
32
+ };
33
+ }
34
+ const finalPath = new URL(finalUrl).pathname;
35
+ if (finalPath !== route) {
36
+ return { ok: false, reason: `requested "${route}" but the page settled on "${finalPath}"` };
37
+ }
38
+ if (typeof pngBytes !== "number" || pngBytes <= 0) {
39
+ return { ok: false, reason: pngBytes === 0 ? "the screenshot PNG is empty" : "no screenshot PNG was written" };
40
+ }
41
+ return { ok: true };
42
+ }
43
+ /** How long one navigation may take before it counts as a failed render. */
44
+ export const RENDER_TIMEOUT_MS = 60_000;
45
+ /**
46
+ * Navigate `page` to `base + route`, screenshot it to `pngPath`, and return
47
+ * `assessRender`'s verdict on what happened. `status` comes from the response
48
+ * `page.goto` itself resolved and `finalUrl` from `page.url()` — never the
49
+ * requested URL, never a side `fetch` — so a redirect cannot pass as a render.
50
+ * A failed navigation takes no screenshot; a screenshot that throws counts
51
+ * as no PNG written — never a stale one left at `pngPath` by an earlier run.
52
+ */
53
+ export async function renderRoute(page, base, route, pngPath) {
54
+ let status;
55
+ try {
56
+ const response = await page.goto(base + route, { waitUntil: "load", timeout: RENDER_TIMEOUT_MS });
57
+ status = response === null ? undefined : response.status();
58
+ }
59
+ catch (err) {
60
+ return assessRender({
61
+ route,
62
+ gotoError: err instanceof Error ? err.message : String(err),
63
+ status: undefined,
64
+ finalUrl: page.url(),
65
+ pngBytes: undefined,
66
+ });
67
+ }
68
+ rmSync(pngPath, { force: true });
69
+ try {
70
+ await page.screenshot({ path: pngPath, fullPage: true });
71
+ }
72
+ catch {
73
+ // Falls through to the missing-PNG check below.
74
+ }
75
+ const pngBytes = existsSync(pngPath) ? statSync(pngPath).size : undefined;
76
+ return assessRender({ route, status, finalUrl: page.url(), pngBytes });
77
+ }