create-ailk 0.31.0 → 0.32.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.
- package/README.md +41 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cutover.d.ts +6 -1
- package/dist/cutover.js +23 -6
- package/dist/language-import.d.ts +16 -3
- package/dist/language-import.js +27 -11
- package/dist/lib/import-mdx.js +57 -24
- package/dist/lib/render-comparison.d.ts +51 -0
- package/dist/lib/render-comparison.js +205 -6
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +1 -1
- package/dist/migrate-content.js +4 -2
- package/dist/programmatic.js +2 -8
- package/dist/site-manifest.js +6 -38
- package/dist/site-roster.d.ts +46 -3
- package/dist/site-roster.js +124 -4
- package/dist/surfaces.d.ts +8 -0
- package/dist/surfaces.js +16 -0
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -0
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +3 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +3 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +134 -10
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/package.json +2 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- package/versions.json +47 -47
package/dist/site-roster.js
CHANGED
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
* - `planRosterAppend` — the next `rows`, spliced into the file's text so
|
|
20
20
|
* every untouched byte stays prettier's;
|
|
21
21
|
* - `siteRosterProblems` — the scaffold-grained twin of the generator's
|
|
22
|
-
* `--check`, which cannot target a site
|
|
22
|
+
* `--check`, which cannot target a site;
|
|
23
|
+
* - `planServedRoster` — the roster without the code-route rows no page
|
|
24
|
+
* file serves (#10774), which `update` writes.
|
|
23
25
|
*
|
|
24
26
|
* Pure module: no `import.meta.url`. Package-rooted directories are supplied
|
|
25
27
|
* by the caller, the same split as `add-module.ts` / `programmatic.ts`.
|
|
@@ -46,6 +48,9 @@ export const SITE_ROSTER_PATH = ".ailk-site-manifest.json";
|
|
|
46
48
|
export const LEGACY_SITE_ROSTER_PATH = "TEMPLATE-MANIFEST.json";
|
|
47
49
|
/** Prettier's default print width — the generator formats the manifest with it. */
|
|
48
50
|
const PRINT_WIDTH = 80;
|
|
51
|
+
/** Where the site's App Router pages live. */
|
|
52
|
+
const ROUTE_ROOT = join("apps", "web", "app", "[locale]");
|
|
53
|
+
const PAGE_FILE = /^page\.(tsx|ts|jsx|js|mdx)$/;
|
|
49
54
|
// ── A string-aware JSON scanner, for the text splice ──────────────────────
|
|
50
55
|
function skipWhitespace(text, i) {
|
|
51
56
|
while (i < text.length && /\s/.test(text[i]))
|
|
@@ -120,7 +125,7 @@ function arrayElements(text, open) {
|
|
|
120
125
|
* spans are cross-checked against `JSON.parse`, so a splice can only ever
|
|
121
126
|
* move bytes the parser agrees are those rows.
|
|
122
127
|
*/
|
|
123
|
-
function parseRosterFile(text, label) {
|
|
128
|
+
export function parseRosterFile(text, label) {
|
|
124
129
|
const refuse = (why) => new AddModuleRefusedError(`${label} ${why}, so the page roster is unknown`);
|
|
125
130
|
let doc;
|
|
126
131
|
try {
|
|
@@ -538,6 +543,7 @@ export function planRosterAppend(opts) {
|
|
|
538
543
|
const refs = opts.references.filter((ref) => inSet.has(ref.module));
|
|
539
544
|
const contributed = new Set(refs.map((ref) => ref.contribution.route));
|
|
540
545
|
const shipped = readShippedManifest(opts.siteTemplatesDir, roster.template.name);
|
|
546
|
+
const unserved = unservedRouteRow(targetDir);
|
|
541
547
|
const templateRows = [];
|
|
542
548
|
const moduleRows = new Map();
|
|
543
549
|
const superseded = [];
|
|
@@ -567,7 +573,12 @@ export function planRosterAppend(opts) {
|
|
|
567
573
|
next.push(...kept);
|
|
568
574
|
}
|
|
569
575
|
else {
|
|
570
|
-
|
|
576
|
+
const row = expectedRow(ref, targetDir);
|
|
577
|
+
// #10774 D4.1 — a contribution whose page file the site lacks (a
|
|
578
|
+
// spoke's `/appointments`) is not the site's to declare.
|
|
579
|
+
if (unserved?.(row.value) === true)
|
|
580
|
+
continue;
|
|
581
|
+
next.push(row);
|
|
571
582
|
appended.push(route);
|
|
572
583
|
}
|
|
573
584
|
}
|
|
@@ -616,6 +627,104 @@ function spliceRows(file, rows) {
|
|
|
616
627
|
: `${lead}${rows.map((r) => r.text).join(`,${lead}`)}${trail}`;
|
|
617
628
|
return text.slice(0, open + 1) + body + text.slice(close);
|
|
618
629
|
}
|
|
630
|
+
// ── #10774 D4: a code-route row names a route the site serves ─────────────
|
|
631
|
+
/**
|
|
632
|
+
* Every route the site's App Router serves from a page file, sorted: route
|
|
633
|
+
* groups `(x)` dropped, a trailing optional catch-all `[[...x]]` folded into
|
|
634
|
+
* its parent (docs-blog's `/blog/[[...slug]]` serves `/blog`). The root
|
|
635
|
+
* content renderers (`/[slug]`, `/[slug]/[detail]`) are not routes of their
|
|
636
|
+
* own — they are how the content rows render — so they are left out.
|
|
637
|
+
*/
|
|
638
|
+
export function servedRoutes(targetDir) {
|
|
639
|
+
const root = join(targetDir, ROUTE_ROOT);
|
|
640
|
+
const routes = new Set();
|
|
641
|
+
const stack = existsSync(root) ? [root] : [];
|
|
642
|
+
for (let cur = stack.pop(); cur !== undefined; cur = stack.pop()) {
|
|
643
|
+
const entries = readdirSync(cur, { withFileTypes: true });
|
|
644
|
+
for (const entry of entries) {
|
|
645
|
+
if (entry.isDirectory())
|
|
646
|
+
stack.push(join(cur, entry.name));
|
|
647
|
+
}
|
|
648
|
+
if (!entries.some((entry) => entry.isFile() && PAGE_FILE.test(entry.name))) {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const segments = relative(root, cur)
|
|
652
|
+
.split(sep)
|
|
653
|
+
.filter((segment) => segment !== "" && !/^\(.+\)$/.test(segment));
|
|
654
|
+
const last = segments[segments.length - 1];
|
|
655
|
+
if (last !== undefined && /^\[\[\.\.\..+\]\]$/.test(last))
|
|
656
|
+
segments.pop();
|
|
657
|
+
if (segments[0]?.startsWith("["))
|
|
658
|
+
continue;
|
|
659
|
+
routes.add(`/${segments.join("/")}`);
|
|
660
|
+
}
|
|
661
|
+
return [...routes].sort();
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Whether a row is a code-route row no page file of the site serves — or
|
|
665
|
+
* `undefined` when the site has no route tree (a roster-only fixture, a web
|
|
666
|
+
* app elsewhere), where no row is judged. A route whose first segment is
|
|
667
|
+
* dynamic is never judged either: the root renderers are not in
|
|
668
|
+
* `servedRoutes`.
|
|
669
|
+
*/
|
|
670
|
+
function unservedRouteRow(targetDir) {
|
|
671
|
+
if (!existsSync(join(targetDir, ROUTE_ROOT)))
|
|
672
|
+
return undefined;
|
|
673
|
+
const served = new Set(servedRoutes(targetDir));
|
|
674
|
+
return (row) => {
|
|
675
|
+
const route = row["route"];
|
|
676
|
+
return (row["kind"] === "route" &&
|
|
677
|
+
typeof route === "string" &&
|
|
678
|
+
!route.split("/").filter(Boolean)[0]?.startsWith("[") &&
|
|
679
|
+
!served.has(route));
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* `file`'s next text with its unserved code-route rows removed — every other
|
|
684
|
+
* byte verbatim — and the dropped routes in file order; `next` is undefined
|
|
685
|
+
* when none is dropped.
|
|
686
|
+
*/
|
|
687
|
+
export function withoutUnservedRouteRows(file, targetDir) {
|
|
688
|
+
const unserved = unservedRouteRow(targetDir);
|
|
689
|
+
const dropped = file.rows.filter((row) => unserved?.(row.value) === true);
|
|
690
|
+
if (dropped.length === 0)
|
|
691
|
+
return { next: undefined, dropped: [] };
|
|
692
|
+
return {
|
|
693
|
+
next: spliceRows(file, file.rows.filter((row) => !dropped.includes(row))),
|
|
694
|
+
dropped: dropped.map((row) => row.value["route"]),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
/** The site's roster file, read without resolving its template, or undefined when it has none. */
|
|
698
|
+
function readRosterFile(targetDir) {
|
|
699
|
+
const path = [SITE_ROSTER_PATH, LEGACY_SITE_ROSTER_PATH].find((name) => existsSync(join(targetDir, name)));
|
|
700
|
+
if (path === undefined)
|
|
701
|
+
return undefined;
|
|
702
|
+
return { ...parseRosterFile(readFileSync(join(targetDir, path), "utf8"), path), path };
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* The routes of the site roster's page rows — the rows that carry content —
|
|
706
|
+
* or undefined when it has none. A code-route row is left out: it records a
|
|
707
|
+
* route already on disk (signal-collectors' `/careers/apply` walk under the
|
|
708
|
+
* unrelated careers segment), never a surface the site serves. The template
|
|
709
|
+
* is not resolved, so a retired template name reads as any other.
|
|
710
|
+
* @throws AddModuleRefusedError when the file's `rows` cannot be located.
|
|
711
|
+
*/
|
|
712
|
+
export function readRosterPageRoutes(targetDir) {
|
|
713
|
+
return readRosterFile(targetDir)
|
|
714
|
+
?.rows.filter((row) => row.value["kind"] === "page")
|
|
715
|
+
.map((row) => row.value["route"]);
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* The site roster with its unserved code-route rows removed, or undefined
|
|
719
|
+
* when the site has no roster.
|
|
720
|
+
* @throws AddModuleRefusedError when the file's `rows` cannot be located.
|
|
721
|
+
*/
|
|
722
|
+
export function planServedRoster(targetDir) {
|
|
723
|
+
const file = readRosterFile(targetDir);
|
|
724
|
+
if (file === undefined)
|
|
725
|
+
return undefined;
|
|
726
|
+
return { path: file.path, ...withoutUnservedRouteRows(file, targetDir) };
|
|
727
|
+
}
|
|
619
728
|
/** Every `content/en/**.mdx` some layer installs into a site of this template and module set. */
|
|
620
729
|
function layerInstalledSeeds(template, modules, opts, surfaces) {
|
|
621
730
|
const out = new Set();
|
|
@@ -670,7 +779,9 @@ function layerInstalledSeeds(template, modules, opts, surfaces) {
|
|
|
670
779
|
* the shipped reference row;
|
|
671
780
|
* 3. an installed seed — one some layer lands, present in the site — that
|
|
672
781
|
* the effective roster does not declare. A page the customer wrote is
|
|
673
|
-
* installed by no layer and is never reported
|
|
782
|
+
* installed by no layer and is never reported;
|
|
783
|
+
* 4. a code-route row no page file serves (#10774). A module contribution
|
|
784
|
+
* whose page file the site lacks is not expected under 2.
|
|
674
785
|
*
|
|
675
786
|
* A site with no roster (a template-less scaffold) has no problems.
|
|
676
787
|
*/
|
|
@@ -695,6 +806,13 @@ export function siteRosterProblems(opts) {
|
|
|
695
806
|
problems.push(`${route}: appears on more than one row`);
|
|
696
807
|
seen.add(route);
|
|
697
808
|
}
|
|
809
|
+
// #10774 D4.4 — a code-route row no page file serves.
|
|
810
|
+
const unserved = unservedRouteRow(opts.targetDir);
|
|
811
|
+
for (const row of roster.rows) {
|
|
812
|
+
if (unserved?.(row.value) === true) {
|
|
813
|
+
problems.push(`${row.value["route"]}: a route row, but no page file serves it`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
698
816
|
const contributions = new Map();
|
|
699
817
|
// #10040 D3 — a site that dropped `marketing` carries no public route tree,
|
|
700
818
|
// so no module's public pages are expected on its roster either.
|
|
@@ -711,6 +829,8 @@ export function siteRosterProblems(opts) {
|
|
|
711
829
|
const route = ref.contribution.route;
|
|
712
830
|
contributions.set(route, ref.contribution);
|
|
713
831
|
const wanted = expectedRow(ref, opts.targetDir).value;
|
|
832
|
+
if (unserved?.(wanted) === true)
|
|
833
|
+
continue;
|
|
714
834
|
const rows = roster.rows.filter((r) => r.value["route"] === route);
|
|
715
835
|
if (rows.length === 0) {
|
|
716
836
|
problems.push(`${route}: no row, but module "${moduleName}" contributes it`);
|
package/dist/surfaces.d.ts
CHANGED
|
@@ -136,6 +136,14 @@ export declare function resolveSurfaces(modules?: string[], explicit?: string[])
|
|
|
136
136
|
* on Windows too.
|
|
137
137
|
*/
|
|
138
138
|
export declare function excludedRoutePaths(enabled: string[]): string[];
|
|
139
|
+
/**
|
|
140
|
+
* The module-less gateable surfaces a roster's `routes` name (#5469 D10,
|
|
141
|
+
* #10774 D2), in `GATEABLE_SURFACES` order. A route names a surface when its
|
|
142
|
+
* first path segment is one of the surface's top-level route directories
|
|
143
|
+
* (`/pricing` → `apps/web/app/[locale]/pricing`). A module-owned surface is
|
|
144
|
+
* never named: it comes only with its module (#5365 FR5).
|
|
145
|
+
*/
|
|
146
|
+
export declare function surfacesNamedByRoutes(routes: readonly string[]): string[];
|
|
139
147
|
/**
|
|
140
148
|
* The content directories belonging to gateable surfaces that are NOT enabled,
|
|
141
149
|
* with `<locale>` left in place for the caller to expand.
|
package/dist/surfaces.js
CHANGED
|
@@ -540,6 +540,22 @@ export function excludedRoutePaths(enabled) {
|
|
|
540
540
|
}
|
|
541
541
|
return out.sort();
|
|
542
542
|
}
|
|
543
|
+
/**
|
|
544
|
+
* The module-less gateable surfaces a roster's `routes` name (#5469 D10,
|
|
545
|
+
* #10774 D2), in `GATEABLE_SURFACES` order. A route names a surface when its
|
|
546
|
+
* first path segment is one of the surface's top-level route directories
|
|
547
|
+
* (`/pricing` → `apps/web/app/[locale]/pricing`). A module-owned surface is
|
|
548
|
+
* never named: it comes only with its module (#5365 FR5).
|
|
549
|
+
*/
|
|
550
|
+
export function surfacesNamedByRoutes(routes) {
|
|
551
|
+
const routeRoot = "apps/web/app/[locale]";
|
|
552
|
+
const moduleOwned = new Set(Object.values(MODULE_SURFACES).flat());
|
|
553
|
+
const topSegments = new Set(routes
|
|
554
|
+
.map((route) => route.split("/").filter(Boolean)[0])
|
|
555
|
+
.filter((segment) => segment !== undefined));
|
|
556
|
+
return GATEABLE_SURFACES.filter((surface) => !moduleOwned.has(surface.id) &&
|
|
557
|
+
surface.routes.some((dir) => topSegments.has(dir.slice(routeRoot.length + 1)))).map((surface) => surface.id);
|
|
558
|
+
}
|
|
543
559
|
/**
|
|
544
560
|
* The content directories belonging to gateable surfaces that are NOT enabled,
|
|
545
561
|
* with `<locale>` left in place for the caller to expand.
|
package/dist/update.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type MdxParse } from "./mdx-braces.js";
|
|
1
2
|
import type { WorkspaceVersionMap } from "./substitute.js";
|
|
2
3
|
/** A refusal the CLI exits 2 on, thrown before anything is written. */
|
|
3
4
|
export declare class UpdateRefusedError extends Error {
|
|
@@ -23,6 +24,11 @@ export interface UpdateSiteOptions {
|
|
|
23
24
|
* at. A site already on both boot sets, or a spoke, never reads it.
|
|
24
25
|
*/
|
|
25
26
|
versions?: WorkspaceVersionMap;
|
|
27
|
+
/**
|
|
28
|
+
* The MDX parser the brace pass verifies an escape with (#10773 D5);
|
|
29
|
+
* `null` acts as "not installed". Absent: `loadSiteMdxParse(targetDir)`.
|
|
30
|
+
*/
|
|
31
|
+
mdxParse?: MdxParse | null;
|
|
26
32
|
}
|
|
27
33
|
export interface UpdateResult {
|
|
28
34
|
targetDir: string;
|
|
@@ -58,8 +64,14 @@ export interface UpdateResult {
|
|
|
58
64
|
envRowsAdded?: string[];
|
|
59
65
|
/** Record rows dropped because the path is no longer recordable (#10173). */
|
|
60
66
|
droppedRows?: string[];
|
|
67
|
+
/** The module-less surfaces the roster names that `architecture.yaml` now declares (#10774 D3). */
|
|
68
|
+
surfacesDeclared?: string[];
|
|
69
|
+
/** The roster's code-route rows dropped because no page file serves them, in roster order (#10774 D4). */
|
|
70
|
+
rosterRowsDropped?: string[];
|
|
61
71
|
/** The mdx-body pages under `content/` given a `# ` heading this run (#7495). */
|
|
62
72
|
headingsAdded: string[];
|
|
73
|
+
/** The mdx-body pages under `content/` whose bare `{` were escaped this run (#10773). */
|
|
74
|
+
bracesEscaped: string[];
|
|
63
75
|
backfilled?: {
|
|
64
76
|
version: string;
|
|
65
77
|
files: number;
|
|
@@ -83,12 +95,20 @@ export declare function mergeMessagesAdditively(ours: string, theirs: string): {
|
|
|
83
95
|
reason: string;
|
|
84
96
|
};
|
|
85
97
|
export declare function updateSite(opts: UpdateSiteOptions): Promise<UpdateResult>;
|
|
98
|
+
/**
|
|
99
|
+
* The site's own MDX parser (#10773 D5): `@mdx-js/mdx` resolved through the
|
|
100
|
+
* `next-mdx-remote` its `apps/web` renders with — never create-ailk's own
|
|
101
|
+
* copy, and never fetched. `undefined` when it is not installed or fails to
|
|
102
|
+
* load; never throws.
|
|
103
|
+
*/
|
|
104
|
+
export declare function loadSiteMdxParse(targetDir: string): Promise<MdxParse | undefined>;
|
|
105
|
+
export type { MdxParse } from "./mdx-braces.js";
|
|
86
106
|
/** The printed report (D9): one section per non-empty list, in order. */
|
|
87
107
|
export declare function formatUpdateReport(result: UpdateResult): string;
|
|
88
108
|
export interface UpdateArgs {
|
|
89
109
|
target: string | undefined;
|
|
90
110
|
help: boolean;
|
|
91
111
|
}
|
|
92
|
-
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, or a root tools/jest file the\napp jest configs require, whose template copy changed is overwritten. An\nedited file whose template copy also changed is three-way merged with\n`git` (git must be on PATH); a conflict leaves the file alone and is\nlisted under \"Needs review\"; an apps/web/messages catalog instead gains the\ntemplate's new keys and keeps every value it has, and is reviewed only when a\nkey is an object on one side and a value on the other. Every template file,\ntest or not, that imports a file left under \"Needs review\" or \"Skipped\" (by a\nrelative or @/ import, transitively) is held at the site's version with it,\nits record entry is not advanced, and it is listed under \"Held back with\n<file>\" \u2014 reconcile the file, then run update again to bring it forward. A\nwritten file whose import names a file the site will not have is held the\nsame way, under \"Held back with <file> (not in this site)\". Every other file\nin the run still updates. When project.yaml has an env_contract block, each\nenv var the written files read that has no row gains one; no row is edited.\nA record row at a path create-ailk no longer records is dropped and listed.\nA site with no record gets one on its first run, from the version its\n.ailk-site-manifest.json already records. A site scaffolded before\n@working-theory/mcp-server has its copied apps/mcp moved to the boot set:\nthe server and each module's paid MCP pack join apps/mcp/package.json, the\ncopied server files go, and apps/mcp/src/lib/paid-tools.generated.ts is\nrewritten on every run. A site scaffolded before @working-theory/api has its\ncopied apps/api moved to the boot file: the app becomes @working-theory/app-api,\nthe package and the six @working-theory/api-<module> packages join\napps/api/package.json, its bins and build:vercel delegate to the package, and\nthe copied server files go. Run `pnpm install` after either move, since the\napp's package.json gained dependencies. Each mdx-body page in content/ with no\n'# ' heading gets its title and description written into its body as its\nheading and intro, listed under \"Page heading added\"; a page with one heading\nis left as it is, and one with several, or one whose frontmatter it cannot\nread, is listed under \"Needs review\". Commit before running, and review the
|
|
112
|
+
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, or a root tools/jest file the\napp jest configs require, whose template copy changed is overwritten. An\nedited file whose template copy also changed is three-way merged with\n`git` (git must be on PATH); a conflict leaves the file alone and is\nlisted under \"Needs review\"; an apps/web/messages catalog instead gains the\ntemplate's new keys and keeps every value it has, and is reviewed only when a\nkey is an object on one side and a value on the other. Every template file,\ntest or not, that imports a file left under \"Needs review\" or \"Skipped\" (by a\nrelative or @/ import, transitively) is held at the site's version with it,\nits record entry is not advanced, and it is listed under \"Held back with\n<file>\" \u2014 reconcile the file, then run update again to bring it forward. A\nwritten file whose import names a file the site will not have is held the\nsame way, under \"Held back with <file> (not in this site)\". Every other file\nin the run still updates. When project.yaml has an env_contract block, each\nenv var the written files read that has no row gains one; no row is edited.\nA record row at a path create-ailk no longer records is dropped and listed.\nA surface the site's .ailk-site-manifest.json names a page on but\narchitecture.yaml does not declare (one no module owns, such as projects)\ngets its route files added and is declared, listed under \"Surfaces declared\nfrom the roster\". A code-route row of that roster no page file serves is\ndropped, listed under \"Roster rows dropped, no route serves them\".\nA site with no record gets one on its first run, from the version its\n.ailk-site-manifest.json already records. A site scaffolded before\n@working-theory/mcp-server has its copied apps/mcp moved to the boot set:\nthe server and each module's paid MCP pack join apps/mcp/package.json, the\ncopied server files go, and apps/mcp/src/lib/paid-tools.generated.ts is\nrewritten on every run. A site scaffolded before @working-theory/api has its\ncopied apps/api moved to the boot file: the app becomes @working-theory/app-api,\nthe package and the six @working-theory/api-<module> packages join\napps/api/package.json, its bins and build:vercel delegate to the package, and\nthe copied server files go. Run `pnpm install` after either move, since the\napp's package.json gained dependencies. Each mdx-body page in content/ with no\n'# ' heading gets its title and description written into its body as its\nheading and intro, listed under \"Page heading added\"; a page with one heading\nis left as it is, and one with several, or one whose frontmatter it cannot\nread, is listed under \"Needs review\". Each bare { on an mdx-body page is\nescaped as \\{ when the site's own MDX parser then reads the page clean, listed\nunder \"Placeholder brace escaped\"; a page it cannot escape safely, or any page\nwith one while the site's dependencies are not installed, is listed under\n\"Needs review\". Commit before running, and review the diff after.\n";
|
|
93
113
|
/** Parse the argv AFTER the `update` word. Strict `node:util` parseArgs, like `parseAdoptArgs`. */
|
|
94
114
|
export declare function parseUpdateArgs(argv: string[]): UpdateArgs;
|
package/dist/update.js
CHANGED
|
@@ -36,29 +36,35 @@
|
|
|
36
36
|
* before the first write. Last, every mdx-body page in the site's `content/`
|
|
37
37
|
* with no `# ` heading gets its title and description written into its body
|
|
38
38
|
* (#7495, `body-heading.ts`); a page with one or more headings is never
|
|
39
|
-
* touched, and `content/` is never recorded.
|
|
39
|
+
* touched, and `content/` is never recorded. Then every bare `{` on an
|
|
40
|
+
* mdx-body page is escaped as `\{`, kept only when the site's own MDX parser
|
|
41
|
+
* reads the page clean (#10773, `mdx-braces.ts`).
|
|
40
42
|
*
|
|
41
43
|
* Pure module: no `import.meta.url` — the CLI supplies `templatesDir`, the
|
|
42
44
|
* `adopt.ts` / `add-route.ts` split.
|
|
43
45
|
*/
|
|
44
46
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, writeFileSync, } from "node:fs";
|
|
47
|
+
import { createRequire } from "node:module";
|
|
45
48
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
49
|
+
import { pathToFileURL } from "node:url";
|
|
46
50
|
import { parseArgs } from "node:util";
|
|
47
51
|
import { AddModuleRefusedError, readArchitectureHeader } from "./add-module-core.js";
|
|
48
|
-
import { headingViolations, mdxBodyRule, migrateBodyHeadings } from "./body-heading.js";
|
|
52
|
+
import { headingViolations, mdxBodyPages, mdxBodyRule, migrateBodyHeadings } from "./body-heading.js";
|
|
49
53
|
import { buildScaffoldMarkers, isExcluded, isTextFile, renderTemplateFile, templateDestination } from "./copy.js";
|
|
54
|
+
import { deriveArchitecture } from "./derive-architecture.js";
|
|
50
55
|
import { EnvContractError, planEnvRows, readEnvContract, readManifestTopology, } from "./lib/declared-env.js";
|
|
51
56
|
import { ENV_CATALOG } from "./lib/env-catalog.js";
|
|
52
57
|
import { fetchTemplateSnapshot } from "./lib/fetch-template-snapshot.js";
|
|
53
58
|
import { exportsName, listFilesUnder, namedImportsOf } from "./lib/import-graph.js";
|
|
54
59
|
import { mergeThreeWay } from "./lib/merge-three-way.js";
|
|
55
60
|
import { publicTreeRemoved } from "./lib/site-modules.js";
|
|
61
|
+
import { escapeBareBraces, findBareBraces } from "./mdx-braces.js";
|
|
56
62
|
import { MODULE_SLICES } from "./module-architecture.js";
|
|
57
63
|
import { MODULE_FLAGS_PATH, PAID_MCP_TOOLS_PATH, writeModuleFlags, writePaidMcpTools, } from "./paid-sections.js";
|
|
58
64
|
import { ParseError } from "./parse-args.js";
|
|
59
|
-
import { SITE_ROSTER_PATH } from "./site-roster.js";
|
|
65
|
+
import { SITE_ROSTER_PATH, planServedRoster, readRosterPageRoutes } from "./site-roster.js";
|
|
60
66
|
import { SPOKE_CHECKOUT_TEMPLATE_PATHS } from "./spoke-checkout.js";
|
|
61
|
-
import { excludedRoutePaths, isRemovedPublicRoutePath } from "./surfaces.js";
|
|
67
|
+
import { GATEABLE_SURFACES, excludedRoutePaths, isRemovedPublicRoutePath, resolveSurfaces, surfacesNamedByRoutes, } from "./surfaces.js";
|
|
62
68
|
import { DATABASE_BOOT_FILES, ROOT_TOOL_FILES, TEMPLATE_RECORD_PATH, isRecordable, parseTemplateRecord, serializeTemplateRecord, sha256Hex, } from "./template-record.js";
|
|
63
69
|
import { MCP_SEAM_PATH, MCP_SERVER_HUB, TopologyError, isSpokeExcluded, readSpokeShape, spokeExclusions, } from "./topology.js";
|
|
64
70
|
/** A refusal the CLI exits 2 on, thrown before anything is written. */
|
|
@@ -930,6 +936,35 @@ export async function updateSite(opts) {
|
|
|
930
936
|
throw err;
|
|
931
937
|
}
|
|
932
938
|
const excludedRoutes = excludedRoutePaths(header.surfaces);
|
|
939
|
+
// #10774 D3 — a module-less surface the roster's page rows name but the header omits
|
|
940
|
+
// (the /projects pair a 0.19.0 scaffold rostered without its route) is in
|
|
941
|
+
// scope for the add loop only, and declared after the writes (even when an
|
|
942
|
+
// add is held back, #10173: the next run adds it). The
|
|
943
|
+
// roster is read even when it brings nothing, so an unreadable one is
|
|
944
|
+
// refused here, before the first write, not at the drop after it.
|
|
945
|
+
let rosterRoutes;
|
|
946
|
+
try {
|
|
947
|
+
rosterRoutes = readRosterPageRoutes(targetDir) ?? [];
|
|
948
|
+
}
|
|
949
|
+
catch (err) {
|
|
950
|
+
if (err instanceof AddModuleRefusedError)
|
|
951
|
+
throw new UpdateRefusedError(err.message);
|
|
952
|
+
throw err;
|
|
953
|
+
}
|
|
954
|
+
const declaredByRoster = publicTreeRemoved(targetDir)
|
|
955
|
+
? []
|
|
956
|
+
: surfacesNamedByRoutes(rosterRoutes).filter((surface) => !header.surfaces.includes(surface));
|
|
957
|
+
const addExcludedRoutes = excludedRoutePaths([...header.surfaces, ...declaredByRoster]);
|
|
958
|
+
const declaredRouteDirs = GATEABLE_SURFACES.filter((surface) => declaredByRoster.includes(surface.id)).flatMap((surface) => surface.routes);
|
|
959
|
+
let declaredArchitecture;
|
|
960
|
+
if (declaredByRoster.length > 0) {
|
|
961
|
+
try {
|
|
962
|
+
declaredArchitecture = deriveArchitecture(header.modules, resolveSurfaces(header.modules, [...header.surfaces, ...declaredByRoster]), spoke !== undefined ? { topology: "spoke", mcp: existsSync(join(targetDir, "apps", "mcp")) } : {});
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
throw new UpdateRefusedError(`architecture.yaml's \`# Surfaces:\` header cannot declare ${declaredByRoster.join(", ")}: ${err.message}`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
933
968
|
const spokeExclusionList = spoke !== undefined ? spokeExclusions(opts.templatesDir, spoke) : undefined;
|
|
934
969
|
// #8748 D3 — the standard spoke checkout is a commerce spoke's alone.
|
|
935
970
|
const sellsAsSpoke = spoke !== undefined && header.modules.includes("commerce");
|
|
@@ -937,10 +972,10 @@ export async function updateSite(opts) {
|
|
|
937
972
|
// never touched: its boot paths are out of scope, reported under the guard.
|
|
938
973
|
const databaseIsPackage = existsSync(join(targetDir, "database", "package.json"));
|
|
939
974
|
const bootFileGuarded = (destRel) => databaseIsPackage && DATABASE_BOOT_FILES.includes(destRel);
|
|
940
|
-
const outOfScope = (destRel) => {
|
|
975
|
+
const outOfScope = (destRel, excluded = excludedRoutes) => {
|
|
941
976
|
if (bootFileGuarded(destRel))
|
|
942
977
|
return true;
|
|
943
|
-
if (isExcluded(destRel,
|
|
978
|
+
if (isExcluded(destRel, excluded))
|
|
944
979
|
return true;
|
|
945
980
|
if (!sellsAsSpoke && isExcluded(destRel, SPOKE_CHECKOUT_TEMPLATE_PATHS))
|
|
946
981
|
return true;
|
|
@@ -1232,9 +1267,11 @@ export async function updateSite(opts) {
|
|
|
1232
1267
|
}
|
|
1233
1268
|
continue;
|
|
1234
1269
|
}
|
|
1235
|
-
if (outOfScope(path))
|
|
1270
|
+
if (outOfScope(path, addExcludedRoutes))
|
|
1236
1271
|
continue; // rule i/ii
|
|
1237
|
-
|
|
1272
|
+
// #10774 D3 — a surface this run declares was never copied, so its
|
|
1273
|
+
// missing directory is no owner's deletion: rule iii does not apply.
|
|
1274
|
+
const highest = isExcluded(path, declaredRouteDirs) ? undefined : highestMissingAncestor(path, targetDir);
|
|
1238
1275
|
if (highest !== undefined && containsKnownPath(highest, knownPaths))
|
|
1239
1276
|
continue; // rule iii
|
|
1240
1277
|
writes.push({ path, bytes: Buffer.from(newBytes) });
|
|
@@ -1484,6 +1521,15 @@ export async function updateSite(opts) {
|
|
|
1484
1521
|
if (recordChanged) {
|
|
1485
1522
|
writeFileSync(recordPath, serializeTemplateRecord({ schema: 1, projectName: record.projectName, files: nextFiles }));
|
|
1486
1523
|
}
|
|
1524
|
+
// #10774 D3 — the surfaces the roster named are declared, as add-route
|
|
1525
|
+
// declares one: the whole generated file, re-derived.
|
|
1526
|
+
if (declaredArchitecture !== undefined)
|
|
1527
|
+
writeFileSync(archPath, declaredArchitecture);
|
|
1528
|
+
// #10774 D4.3 — the roster's code-route rows no page file serves, read
|
|
1529
|
+
// after the writes so a route this run added counts as served.
|
|
1530
|
+
const servedRoster = planServedRoster(targetDir);
|
|
1531
|
+
if (servedRoster?.next !== undefined)
|
|
1532
|
+
writeFileSync(join(targetDir, servedRoster.path), servedRoster.next);
|
|
1487
1533
|
// #8843 D10 — the module-presence flags are generated, never copied
|
|
1488
1534
|
// (REGENERATED_APP_PATHS): render them from this site's own
|
|
1489
1535
|
// apps/web/package.json on every run, so a site updated onto a layout
|
|
@@ -1570,6 +1616,34 @@ export async function updateSite(opts) {
|
|
|
1570
1616
|
for (const { path, reason } of headings.refused) {
|
|
1571
1617
|
review.push({ path: sitePath(path), reason: `no '# ' heading added: ${reason}` });
|
|
1572
1618
|
}
|
|
1619
|
+
// #10773 D6 — a bare `{` on an mdx-body page is MDX code: the page fails or
|
|
1620
|
+
// drops it. Escape every one, after the heading pass so its writes are read
|
|
1621
|
+
// too, keeping a page's write only when the site's own parser reads it clean.
|
|
1622
|
+
const parse = opts.mdxParse === undefined ? await loadSiteMdxParse(targetDir) : opts.mdxParse ?? undefined;
|
|
1623
|
+
const bracesEscaped = [];
|
|
1624
|
+
for (const { path, raw, bodyStart } of mdxBodyPages(contentDir, rule)) {
|
|
1625
|
+
// Blank lines in place of the frontmatter, so every line named is the file's.
|
|
1626
|
+
const pad = "\n".repeat(raw.slice(0, bodyStart).split("\n").length - 1);
|
|
1627
|
+
const body = pad + raw.slice(bodyStart);
|
|
1628
|
+
if (parse === undefined) {
|
|
1629
|
+
const lines = findBareBraces(body).map((brace) => brace.line);
|
|
1630
|
+
if (lines.length === 0)
|
|
1631
|
+
continue;
|
|
1632
|
+
review.push({
|
|
1633
|
+
path: sitePath(path),
|
|
1634
|
+
reason: `${lines.length} bare {…} at line ${[...new Set(lines)].join(", ")}; not escaped: the site's MDX parser is not installed — run pnpm install, then update again`,
|
|
1635
|
+
});
|
|
1636
|
+
continue;
|
|
1637
|
+
}
|
|
1638
|
+
const escape = escapeBareBraces(body, parse);
|
|
1639
|
+
if (escape.kind === "escaped") {
|
|
1640
|
+
writeFileSync(path, raw.slice(0, bodyStart) + escape.text.slice(pad.length));
|
|
1641
|
+
bracesEscaped.push(sitePath(path));
|
|
1642
|
+
}
|
|
1643
|
+
else if (escape.kind === "refused") {
|
|
1644
|
+
review.push({ path: sitePath(path), reason: `bare {…} not escaped: ${escape.reason}` });
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1573
1647
|
return {
|
|
1574
1648
|
targetDir,
|
|
1575
1649
|
version,
|
|
@@ -1578,11 +1652,48 @@ export async function updateSite(opts) {
|
|
|
1578
1652
|
skipped,
|
|
1579
1653
|
heldBack,
|
|
1580
1654
|
headingsAdded: headings.written.map(sitePath),
|
|
1655
|
+
bracesEscaped,
|
|
1581
1656
|
...(backfilled !== undefined ? { backfilled } : {}),
|
|
1582
1657
|
...(envRowsAdded.length > 0 ? { envRowsAdded } : {}),
|
|
1583
1658
|
...(droppedRows.length > 0 ? { droppedRows: droppedRows.sort() } : {}),
|
|
1659
|
+
...(declaredByRoster.length > 0 ? { surfacesDeclared: declaredByRoster } : {}),
|
|
1660
|
+
...(servedRoster !== undefined && servedRoster.dropped.length > 0
|
|
1661
|
+
? { rosterRowsDropped: servedRoster.dropped }
|
|
1662
|
+
: {}),
|
|
1584
1663
|
};
|
|
1585
1664
|
}
|
|
1665
|
+
/** Whether a `node_modules` at or above `dir` holds `pkg` — so `resolve` never falls back to NODE_PATH. */
|
|
1666
|
+
function installedAbove(dir, pkg) {
|
|
1667
|
+
for (let at = dir;; at = dirname(at)) {
|
|
1668
|
+
if (existsSync(join(at, "node_modules", pkg, "package.json")))
|
|
1669
|
+
return true;
|
|
1670
|
+
if (dirname(at) === at)
|
|
1671
|
+
return false;
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* The site's own MDX parser (#10773 D5): `@mdx-js/mdx` resolved through the
|
|
1676
|
+
* `next-mdx-remote` its `apps/web` renders with — never create-ailk's own
|
|
1677
|
+
* copy, and never fetched. `undefined` when it is not installed or fails to
|
|
1678
|
+
* load; never throws.
|
|
1679
|
+
*/
|
|
1680
|
+
export async function loadSiteMdxParse(targetDir) {
|
|
1681
|
+
try {
|
|
1682
|
+
const web = join(resolve(targetDir), "apps", "web");
|
|
1683
|
+
if (!installedAbove(web, "next-mdx-remote"))
|
|
1684
|
+
return undefined;
|
|
1685
|
+
const remote = createRequire(join(web, "package.json")).resolve("next-mdx-remote/package.json");
|
|
1686
|
+
const entry = createRequire(remote).resolve("@mdx-js/mdx");
|
|
1687
|
+
const mdx = (await import(pathToFileURL(entry).href));
|
|
1688
|
+
const createProcessor = mdx.createProcessor;
|
|
1689
|
+
if (typeof createProcessor !== "function")
|
|
1690
|
+
return undefined;
|
|
1691
|
+
return (body) => createProcessor().parse(body);
|
|
1692
|
+
}
|
|
1693
|
+
catch {
|
|
1694
|
+
return undefined;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1586
1697
|
/** The printed report (D9): one section per non-empty list, in order. */
|
|
1587
1698
|
export function formatUpdateReport(result) {
|
|
1588
1699
|
const lines = [`[create-ailk] update → ${result.targetDir}`];
|
|
@@ -1596,6 +1707,7 @@ export function formatUpdateReport(result) {
|
|
|
1596
1707
|
["Removed", result.removed],
|
|
1597
1708
|
["Already current", result.current],
|
|
1598
1709
|
["Page heading added", result.headingsAdded],
|
|
1710
|
+
["Placeholder brace escaped", result.bracesEscaped],
|
|
1599
1711
|
];
|
|
1600
1712
|
for (const [label, paths] of sections) {
|
|
1601
1713
|
if (paths.length === 0)
|
|
@@ -1620,6 +1732,14 @@ export function formatUpdateReport(result) {
|
|
|
1620
1732
|
lines.push(`Dropped from ${TEMPLATE_RECORD_PATH}, no longer recorded (${result.droppedRows.length}):`);
|
|
1621
1733
|
lines.push(...result.droppedRows.map((path) => ` ${path}`));
|
|
1622
1734
|
}
|
|
1735
|
+
if (result.surfacesDeclared !== undefined) {
|
|
1736
|
+
lines.push(`Surfaces declared from the roster (${result.surfacesDeclared.length}):`);
|
|
1737
|
+
lines.push(...result.surfacesDeclared.map((surface) => ` ${surface}`));
|
|
1738
|
+
}
|
|
1739
|
+
if (result.rosterRowsDropped !== undefined) {
|
|
1740
|
+
lines.push(`Roster rows dropped, no route serves them (${result.rosterRowsDropped.length}):`);
|
|
1741
|
+
lines.push(...result.rosterRowsDropped.map((route) => ` ${route}`));
|
|
1742
|
+
}
|
|
1623
1743
|
if (result.skipped.length > 0) {
|
|
1624
1744
|
lines.push(`Skipped (${result.skipped.length}):`);
|
|
1625
1745
|
lines.push(...result.skipped.map((row) => ` ${row.path} — ${row.reason}`));
|
|
@@ -1631,10 +1751,13 @@ export function formatUpdateReport(result) {
|
|
|
1631
1751
|
result.removed.length === 0 &&
|
|
1632
1752
|
result.current.length === 0 &&
|
|
1633
1753
|
result.headingsAdded.length === 0 &&
|
|
1754
|
+
result.bracesEscaped.length === 0 &&
|
|
1634
1755
|
result.review.length === 0 &&
|
|
1635
1756
|
result.skipped.length === 0 &&
|
|
1636
1757
|
result.envRowsAdded === undefined &&
|
|
1637
|
-
result.droppedRows === undefined
|
|
1758
|
+
result.droppedRows === undefined &&
|
|
1759
|
+
result.surfacesDeclared === undefined &&
|
|
1760
|
+
result.rosterRowsDropped === undefined;
|
|
1638
1761
|
if (nothingHappened) {
|
|
1639
1762
|
return `[create-ailk] Already up to date (create-ailk ${result.version}).\n`;
|
|
1640
1763
|
}
|
|
@@ -1677,6 +1800,11 @@ same way, under "Held back with <file> (not in this site)". Every other file
|
|
|
1677
1800
|
in the run still updates. When project.yaml has an env_contract block, each
|
|
1678
1801
|
env var the written files read that has no row gains one; no row is edited.
|
|
1679
1802
|
A record row at a path create-ailk no longer records is dropped and listed.
|
|
1803
|
+
A surface the site's .ailk-site-manifest.json names a page on but
|
|
1804
|
+
architecture.yaml does not declare (one no module owns, such as projects)
|
|
1805
|
+
gets its route files added and is declared, listed under "Surfaces declared
|
|
1806
|
+
from the roster". A code-route row of that roster no page file serves is
|
|
1807
|
+
dropped, listed under "Roster rows dropped, no route serves them".
|
|
1680
1808
|
A site with no record gets one on its first run, from the version its
|
|
1681
1809
|
.ailk-site-manifest.json already records. A site scaffolded before
|
|
1682
1810
|
@working-theory/mcp-server has its copied apps/mcp moved to the boot set:
|
|
@@ -1691,8 +1819,11 @@ app's package.json gained dependencies. Each mdx-body page in content/ with no
|
|
|
1691
1819
|
'# ' heading gets its title and description written into its body as its
|
|
1692
1820
|
heading and intro, listed under "Page heading added"; a page with one heading
|
|
1693
1821
|
is left as it is, and one with several, or one whose frontmatter it cannot
|
|
1694
|
-
read, is listed under "Needs review".
|
|
1695
|
-
|
|
1822
|
+
read, is listed under "Needs review". Each bare { on an mdx-body page is
|
|
1823
|
+
escaped as \\{ when the site's own MDX parser then reads the page clean, listed
|
|
1824
|
+
under "Placeholder brace escaped"; a page it cannot escape safely, or any page
|
|
1825
|
+
with one while the site's dependencies are not installed, is listed under
|
|
1826
|
+
"Needs review". Commit before running, and review the diff after.
|
|
1696
1827
|
`;
|
|
1697
1828
|
/** Parse the argv AFTER the `update` word. Strict `node:util` parseArgs, like `parseAdoptArgs`. */
|
|
1698
1829
|
export function parseUpdateArgs(argv) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-ailk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"tar": "^7.5.21"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@working-theory/schema": "0.
|
|
32
|
-
"@working-theory/templates": "0.
|
|
31
|
+
"@working-theory/schema": "0.55.0",
|
|
32
|
+
"@working-theory/templates": "0.55.0"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|
|
@@ -69,6 +69,8 @@ jobs:
|
|
|
69
69
|
- run: pnpm claude-md:check
|
|
70
70
|
# A new page file on a site whose pages come from the database (#10660).
|
|
71
71
|
- run: pnpm content-source:check
|
|
72
|
+
# A bare { on an mdx-body page, which fails the page or drops it (#10773).
|
|
73
|
+
- run: pnpm mdx-body:check
|
|
72
74
|
|
|
73
75
|
- run: pnpm test
|
|
74
76
|
|
|
@@ -91,9 +91,15 @@ MCP_ABUSE_BLOCKED_USER_AGENTS=
|
|
|
91
91
|
# (the Artifact store — the server resolves its own single workspace at
|
|
92
92
|
# bootstrap; a deployment that isn't unambiguously one-workspace serves no
|
|
93
93
|
# content rather than guessing).
|
|
94
|
+
# On a SPOKE (#10658), CONTENT_ADAPTER=database reads this spoke's own
|
|
95
|
+
# published pages through the hub at AILK_API_URL under AILK_SPOKE_KEY (below),
|
|
96
|
+
# with no DATABASE_URL — set it once the spoke's page files are removed. A
|
|
97
|
+
# spoke whose key will not be sent (unset, not https or loopback, or no
|
|
98
|
+
# AILK_API_URL) serves no content; the boot writes one stderr line saying
|
|
99
|
+
# which way it went.
|
|
94
100
|
# CONTENT_ADAPTER=mdx
|
|
95
101
|
|
|
96
|
-
# Required ONLY
|
|
102
|
+
# Required ONLY on a hub's CONTENT_ADAPTER=database — never on a spoke.
|
|
97
103
|
# DATABASE_URL=
|
|
98
104
|
|
|
99
105
|
# ── Spoke key ─────────────────────────────────────────────────────────────────
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TextDecoder, TextEncoder } from "node:util";
|
|
2
|
+
import { deserialize, serialize } from "node:v8";
|
|
2
3
|
|
|
3
4
|
import "@testing-library/jest-dom";
|
|
4
5
|
|
|
@@ -17,6 +18,14 @@ if (typeof globalThis.TextEncoder === "undefined") {
|
|
|
17
18
|
(globalThis as unknown as { TextDecoder: unknown }).TextDecoder = TextDecoder;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// #10704 D3 — nor `structuredClone`, which Node exposes only as a global. The
|
|
22
|
+
// database render path clones a page's words with it (`@working-theory/
|
|
23
|
+
// validation`'s `words-codec.ts`); the v8 round trip clones the same plain data.
|
|
24
|
+
if (typeof globalThis.structuredClone === "undefined") {
|
|
25
|
+
globalThis.structuredClone = (<T>(value: T): T =>
|
|
26
|
+
deserialize(serialize(value)) as T) as typeof structuredClone;
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
// SOFT_LOCK_OK: #6510 (D1) — jsdom does not implement ResizeObserver.
|
|
21
30
|
// BannerConsentIsland (the layout's always-mounted consent Banner,
|
|
22
31
|
// placement="bottom") now constructs one when reserveSpace is set — the
|
|
@@ -105,6 +105,9 @@ import { notFound } from "next/navigation";
|
|
|
105
105
|
import { listingExtension } from "../../../../../lib/listings";
|
|
106
106
|
import DetailPage, { generateMetadata } from "../page";
|
|
107
107
|
|
|
108
|
+
// #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
|
|
109
|
+
process.env.CONTENT_ADAPTER = "mdx";
|
|
110
|
+
|
|
108
111
|
const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
|
|
109
112
|
|
|
110
113
|
function makeSource(slug: string) {
|
|
@@ -45,6 +45,9 @@ jest.mock("@working-theory/ui", () => ({
|
|
|
45
45
|
import { notFound } from "next/navigation";
|
|
46
46
|
import SlugPage from "../page";
|
|
47
47
|
|
|
48
|
+
// #10704 — a fixture suite: its content is shaped for the mdx source, whatever the site serves from.
|
|
49
|
+
process.env.CONTENT_ADAPTER = "mdx";
|
|
50
|
+
|
|
48
51
|
const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
|
|
49
52
|
|
|
50
53
|
function makeSource(slug: string, schema?: Record<string, unknown>) {
|