create-ailk 0.30.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 +108 -4
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/body-heading.d.ts +13 -0
- package/dist/body-heading.js +19 -5
- package/dist/cli.js +99 -0
- package/dist/cutover.d.ts +114 -0
- package/dist/cutover.js +511 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +127 -0
- package/dist/language-import.js +451 -0
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +445 -65
- package/dist/lib/render-comparison.d.ts +251 -0
- package/dist/lib/render-comparison.js +573 -0
- package/dist/mdx-braces.d.ts +73 -0
- package/dist/mdx-braces.js +285 -0
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +62 -14
- package/dist/programmatic.js +2 -8
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- 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/topology.d.ts +12 -0
- package/dist/topology.js +24 -10
- package/dist/update.d.ts +21 -1
- package/dist/update.js +142 -11
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/mcp/CLAUDE.md +8 -4
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +16 -5
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +156 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- 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]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- 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/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/llms.txt/route.ts +16 -9
- package/templates/apps/web/app/sitemap.xml/route.ts +3 -0
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +93 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +145 -11
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +5 -1
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/package.json +3 -1
- package/templates/tools/body-heading.ts +440 -0
- package/templates/tools/check-content-source.ts +108 -0
- package/templates/tools/check-mdx-body.ts +123 -0
- package/templates/tools/mdx-braces.ts +303 -0
- package/versions.json +48 -48
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",
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
"matcher": "Edit|Write",
|
|
6
6
|
"command": "bash .claude/hooks/template-guard.sh"
|
|
7
7
|
},
|
|
8
|
+
{
|
|
9
|
+
"event": "PreToolUse",
|
|
10
|
+
"matcher": "Edit|Write",
|
|
11
|
+
"command": "bash .claude/hooks/content-source-guard.sh"
|
|
12
|
+
},
|
|
8
13
|
{
|
|
9
14
|
"event": "PostToolUse",
|
|
10
15
|
"matcher": "Edit|Write",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PreToolUse (Edit|Write): refuse a new page file on a site whose pages come
|
|
3
|
+
# from the database. When CONTENT_ADAPTER=database — in the environment or in
|
|
4
|
+
# one of Next's eight env files under apps/web/ — a call whose file_path is a
|
|
5
|
+
# content/en/**/*.mdx that does not exist yet is refused (exit 2); the refusal
|
|
6
|
+
# on stderr names the MCP tools that change a page instead. An existing page
|
|
7
|
+
# file, any other path and a files site pass silently. The same rule and the
|
|
8
|
+
# same message as tools/check-content-source.ts, which the preflight runs.
|
|
9
|
+
# Pure bash and awk — nothing a scaffold has to install. Registered in
|
|
10
|
+
# .claude/settings.json and declared in .claude/cloudparity-repo-hooks.json;
|
|
11
|
+
# keep the two in step.
|
|
12
|
+
export LC_ALL=C
|
|
13
|
+
cd "$CLAUDE_PROJECT_DIR" || exit 0
|
|
14
|
+
|
|
15
|
+
IFS= read -r -d '' input
|
|
16
|
+
[[ $input =~ \"file_path\"[[:space:]]*:[[:space:]]*\"([^\"\\]*)\" ]] || exit 0
|
|
17
|
+
rel=${BASH_REMATCH[1]}
|
|
18
|
+
case $rel in
|
|
19
|
+
"$PWD"/*) rel=${rel#"$PWD"/} ;;
|
|
20
|
+
/*) exit 0 ;;
|
|
21
|
+
./*) rel=${rel#./} ;;
|
|
22
|
+
esac
|
|
23
|
+
case "/$rel/" in
|
|
24
|
+
*/./*|*/../*|*//*) exit 0 ;;
|
|
25
|
+
esac
|
|
26
|
+
# `*` crosses `/` in a case pattern, so this is content/en/**/*.mdx.
|
|
27
|
+
case $rel in
|
|
28
|
+
content/en/*.mdx) ;;
|
|
29
|
+
*) exit 0 ;;
|
|
30
|
+
esac
|
|
31
|
+
[ ! -e "$rel" ] || exit 0
|
|
32
|
+
|
|
33
|
+
# The source rule: the environment, else the first env file whose last
|
|
34
|
+
# CONTENT_ADAPTER assignment reads exactly `database`.
|
|
35
|
+
source_name=
|
|
36
|
+
if [ "${CONTENT_ADAPTER-}" = database ]; then
|
|
37
|
+
source_name="the environment"
|
|
38
|
+
else
|
|
39
|
+
for name in .env .env.local .env.development .env.development.local \
|
|
40
|
+
.env.production .env.production.local .env.test .env.test.local; do
|
|
41
|
+
file=apps/web/$name
|
|
42
|
+
[ -f "$file" ] || continue
|
|
43
|
+
value=$(awk -v sq="'" '
|
|
44
|
+
/^[ \t]*(export[ \t]+)?CONTENT_ADAPTER[ \t]*=/ {
|
|
45
|
+
v = substr($0, index($0, "=") + 1)
|
|
46
|
+
n = length(v); quote = ""; cut = n
|
|
47
|
+
for (i = 1; i <= n; i++) {
|
|
48
|
+
c = substr(v, i, 1)
|
|
49
|
+
if (quote != "") { if (c == quote) quote = ""; continue }
|
|
50
|
+
if (c == "\"" || c == sq) { quote = c; continue }
|
|
51
|
+
if (c == "#" && i > 1 && substr(v, i - 1, 1) ~ /[ \t]/) { cut = i - 1; break }
|
|
52
|
+
}
|
|
53
|
+
v = substr(v, 1, cut)
|
|
54
|
+
sub(/^[ \t\r]+/, "", v); sub(/[ \t\r]+$/, "", v)
|
|
55
|
+
f = substr(v, 1, 1)
|
|
56
|
+
if (length(v) >= 2 && (f == "\"" || f == sq) && substr(v, length(v), 1) == f) {
|
|
57
|
+
v = substr(v, 2, length(v) - 2)
|
|
58
|
+
}
|
|
59
|
+
last = v
|
|
60
|
+
}
|
|
61
|
+
END { print last }
|
|
62
|
+
' "$file")
|
|
63
|
+
if [ "$value" = database ]; then
|
|
64
|
+
source_name=$file
|
|
65
|
+
break
|
|
66
|
+
fi
|
|
67
|
+
done
|
|
68
|
+
fi
|
|
69
|
+
[ -n "$source_name" ] || exit 0
|
|
70
|
+
|
|
71
|
+
printf '%s\n' >&2 \
|
|
72
|
+
"content-source: $rel is a page file, but this site's pages come from the database (CONTENT_ADAPTER=database in $source_name), so the site never reads it." \
|
|
73
|
+
"Change a page through the site's MCP tools instead: save_draft writes the draft and publish_draft publishes it; read_content returns the current page." \
|
|
74
|
+
"Pages and layout: words live in the database, layout stays in code (CLAUDE.md § Content)."
|
|
75
|
+
exit 2
|
|
@@ -67,6 +67,10 @@ jobs:
|
|
|
67
67
|
# A hand-edit inside a template-owned section of CLAUDE.md — the same
|
|
68
68
|
# check `pnpm preflight` runs locally.
|
|
69
69
|
- run: pnpm claude-md:check
|
|
70
|
+
# A new page file on a site whose pages come from the database (#10660).
|
|
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
|
|
70
74
|
|
|
71
75
|
- run: pnpm test
|
|
72
76
|
|
package/templates/CLAUDE.md
CHANGED
|
@@ -178,6 +178,11 @@ sections:
|
|
|
178
178
|
Copy an existing file in the same folder, then change it. Text in `{braces}`
|
|
179
179
|
is placeholder copy. Don't invent a `type` that `@working-theory/schema`
|
|
180
180
|
doesn't export.
|
|
181
|
+
|
|
182
|
+
Where a page lives depends on `CONTENT_ADAPTER` in `apps/web/.env.local`.
|
|
183
|
+
Unset or `mdx` (the default): the file above. `database`: the database —
|
|
184
|
+
change a page with the MCP tools `save_draft` then `publish_draft`; a new
|
|
185
|
+
file under `content/en/` is refused by a hook and by `pnpm preflight`.
|
|
181
186
|
<!-- /ailk:template id="content" -->
|
|
182
187
|
|
|
183
188
|
<!-- ailk:template id="hard-floors" -->
|
|
@@ -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 ─────────────────────────────────────────────────────────────────
|
|
@@ -187,7 +187,7 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
|
|
|
187
187
|
- **apps/mcp checks only the key's shape.** The key is attached as the SDK's `req.auth` for that
|
|
188
188
|
request alone and reaches a tool as `extra.authInfo.token`. apps/api is the one place it is
|
|
189
189
|
checked, and its tenant guard and the caller's clamped role decide every answer.
|
|
190
|
-
- **Thirty-
|
|
190
|
+
- **Thirty-nine tools, each a `twin`.** The catalog rows carry `surface: "workspace"`, `tier: "pro"`
|
|
191
191
|
and `modulesAnyOf` (the modules any one of which registers the tool — its twin route's gate):
|
|
192
192
|
`get_agent_context`, (#9683) `get_workspace_knowledge` — twin of `GET
|
|
193
193
|
/v1/workspaces/:workspaceId/knowledge`: the business's shown offerings, live magnets, listed
|
|
@@ -209,7 +209,7 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
|
|
|
209
209
|
tools, `list_proposals` and `create_proposal`, twinning `GET`/`POST
|
|
210
210
|
/v1/workspaces/:workspaceId/people/:personId/proposals` with the same grant: a proposal is
|
|
211
211
|
private to one client, `payments` says whether approval leads to a Stripe page, and an agent's
|
|
212
|
-
price is refused `proposal_needs_person` (a person prices) — and (#9681) the four person-thread tools, `get_person_thread`, `set_person_thread_state`, `list_external_threads` and `add_external_thread`, twinning `GET`/`PATCH …/people/:personId/thread` and `GET`/`POST …/people/:personId/external-threads`: a client's whole thread page by page with its read/archived/resolved state, and pointers to email and Slack threads (never message text); reading needs the `people-read` grant, writing also `inbox-act`, and the client erasure route has no tool, by decision — and (#9682) the three person-memory tools, `list_person_memories`, `get_person_memory` and `propose_person_memory`, twinning `GET …/people/:personId/memories`, `GET …/memories/:path` and `POST …/memories`: an agent reads only live, unexpired memories (confirmed by a person, read within 180 days) and its proposal is never live — a new path is a draft, a confirmed one gets a pending version — needing the `people-read` or `people-memory-propose` grant to read and `people-memory-propose` to propose; the six person-only memory routes (edit, delete, confirm, redact, the versions list, restore) have no tool, by decision — and (#9749) `get_slack_connection_status`, twin of `GET /v1/workspaces/:workspaceId/slack-connection` (registered with `workspace`/`multi-tenant`, its route's `TENANCY` row: whether the Organization's Slack is connected, its team name and the date, never a credential; connect and disconnect are a person's act and have no tool, by decision), and `get_prospect_channel_settings` / `set_prospect_channel_settings`, twins of `GET`/`PUT …/prospect-channel` (registered with `scheduling`, their routes' module): the update replaces every field, needs the `meeting-types-manage` grant, is refused `slack_not_connected` while the Organization has no Slack connection, and an agent may change only `enabled`, `channelPrefix` and `archiveDays` — a change to `memberIds` or `welcomeTemplate` is refused `prospect_channel_needs_person` — and (#10218) the content-suggestion pair, `draft_page_suggestions` and `decide_page_suggestion` — and (#10336) the draft pair, `save_draft` and `read_report`, twinning `PATCH`/`GET …/content/:type/:slug` with the `content-edit` grant: `save_draft` takes the page's `content` whole as the PATCH body and its `version` as `X-Draft-Version`, writes a draft version that never goes live
|
|
212
|
+
price is refused `proposal_needs_person` (a person prices) — and (#9681) the four person-thread tools, `get_person_thread`, `set_person_thread_state`, `list_external_threads` and `add_external_thread`, twinning `GET`/`PATCH …/people/:personId/thread` and `GET`/`POST …/people/:personId/external-threads`: a client's whole thread page by page with its read/archived/resolved state, and pointers to email and Slack threads (never message text); reading needs the `people-read` grant, writing also `inbox-act`, and the client erasure route has no tool, by decision — and (#9682) the three person-memory tools, `list_person_memories`, `get_person_memory` and `propose_person_memory`, twinning `GET …/people/:personId/memories`, `GET …/memories/:path` and `POST …/memories`: an agent reads only live, unexpired memories (confirmed by a person, read within 180 days) and its proposal is never live — a new path is a draft, a confirmed one gets a pending version — needing the `people-read` or `people-memory-propose` grant to read and `people-memory-propose` to propose; the six person-only memory routes (edit, delete, confirm, redact, the versions list, restore) have no tool, by decision — and (#9749) `get_slack_connection_status`, twin of `GET /v1/workspaces/:workspaceId/slack-connection` (registered with `workspace`/`multi-tenant`, its route's `TENANCY` row: whether the Organization's Slack is connected, its team name and the date, never a credential; connect and disconnect are a person's act and have no tool, by decision), and `get_prospect_channel_settings` / `set_prospect_channel_settings`, twins of `GET`/`PUT …/prospect-channel` (registered with `scheduling`, their routes' module): the update replaces every field, needs the `meeting-types-manage` grant, is refused `slack_not_connected` while the Organization has no Slack connection, and an agent may change only `enabled`, `channelPrefix` and `archiveDays` — a change to `memberIds` or `welcomeTemplate` is refused `prospect_channel_needs_person` — and (#10218) the content-suggestion pair, `draft_page_suggestions` and `decide_page_suggestion` — and (#10336) the draft pair, `save_draft` and `read_report`, twinning `PATCH`/`GET …/content/:type/:slug` with the `content-edit` grant: `save_draft` takes the page's `content` whole as the PATCH body and its `version` as `X-Draft-Version`, writes a draft version that never goes live, and answers 409 `conflict` with `currentVersion` when the draft moved; `read_report` returns the draft tip, the version a save must send, its open suggestions and the route's check report (`ready`, `needs repair` or `needs your words`), computed by the route and never by the tool — and (#10661) `publish_draft`, twinning `POST …/content/:type/:slug/publish` with the `content-publish` grant: it publishes the draft tip (or a named version) exactly as a person holding that permission does, an agent without the grant is refused 403 `agent_refused` before anything is read, and it never widens Audience or re-enables a doc — and (#10345) the knowledge-base pair, `read_knowledge` and `read_knowledge_doc`, twinning `GET …/knowledge-base` and `GET …/knowledge-base/:type/:slug` with no grant (`docs-read` is every role's): the docs a named member may read (`userId`, by default the agent's own person), at the lower of the agent's person's role and that member's, so naming someone only narrows; Live does not gate them and a disabled doc never appears; `filter: "workspace-memory"` answers the always-loaded set with its `totalTokens` and an 8000-token `warning` — and (#10733) the five Language tools, `list_language_items`, `get_language_item`, `save_language_item`, `set_language_item_live` and `list_language_item_pages`, twinning `GET …/language`, `GET`/`PUT …/language/:kind/:slug`, `POST …/language/:kind/:slug/live` and `GET …/language/:kind/:slug/appears-on` (`mcp-workspace`'s `language.ts`): a workspace's glossary terms, FAQs and keywords; reading needs no grant, saving the `content-edit` grant (`version: null` creates, a tip id updates, 409 `slug_taken` / `stale` otherwise) and the Live switch `content-publish` with the `version` the person reviewed; tags are the hub's and never written by a tool; a keyword is vocabulary and is never shown to visitors; the delete route is a person's write (FEAT-101 Disable) and has no tool, by decision. No workspace tool widens Audience or re-enables a doc, and only `publish_draft` and `set_language_item_live`, with the `content-publish` grant, set Live — the access and Language delete routes answer every agent 403 `agent_refused`, and the publish and Live routes an agent without the grant, which reaches a tool as the code and status. Each takes its route's params, query and body and returns the route's
|
|
213
213
|
`data`. They are in the parity guard like every other tool, and never in
|
|
214
214
|
`/.well-known/mcp.json`.
|
|
215
215
|
- **The OAuth bearer branch (#10391).** When `BETTER_AUTH_URL` (the site's origin, its OAuth
|
|
@@ -230,7 +230,7 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
|
|
|
230
230
|
a spoke has no authorization server, so it leaves `BETTER_AUTH_URL` unset. `/mcp` is untouched.
|
|
231
231
|
- **Handlers forward, nothing else.** `workspace-server.ts` resolves each tool from
|
|
232
232
|
`@working-theory/mcp-workspace`'s `workspaceTools` (`agent_context.ts`, `knowledge.ts`, `inbox_messages.ts`,
|
|
233
|
-
`inbox_submissions.ts`, `inbox_applications.ts`, `people_list.ts`, `person_notes.ts`, `person_thread.ts`, `person_memory.ts`, `proposals.ts`, `slack_connection.ts`, `page_suggestions.ts`, `content_draft.ts`, `knowledge_base.ts`, all
|
|
233
|
+
`inbox_submissions.ts`, `inbox_applications.ts`, `people_list.ts`, `person_notes.ts`, `person_thread.ts`, `person_memory.ts`, `proposals.ts`, `slack_connection.ts`, `page_suggestions.ts`, `content_draft.ts`, `content_publish.ts`, `knowledge_base.ts`, all
|
|
234
234
|
`// oss: false`) and, for the two prospect-channel settings tools, `@working-theory/mcp-scheduling`'s
|
|
235
235
|
`workspaceTools` (`prospect_channel_settings.ts`, #9749 — the pack the scheduling module carries, so a
|
|
236
236
|
scheduling-only build serves what it lists), loaded through the site's generated registry, as `server.ts` resolves the
|
|
@@ -313,7 +313,11 @@ Example (lead routing):
|
|
|
313
313
|
- Don't add a tool without the matching `apps/api` endpoint.
|
|
314
314
|
- Don't put business logic in tool handlers — they're adapters.
|
|
315
315
|
|
|
316
|
-
Last updated: 2026-
|
|
316
|
+
Last updated: 2026-11-02 — #10733 (D7, D8): the five Language twins (`mcp-workspace`'s `language.ts`) twin the Language collection's list, read, save, Live and appears-on routes; the person-only delete has no tool — the signed-in workspace surface gains five tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts`, `packages-paid/api-workspace/src/routes/workspaces/language.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
317
|
+
|
|
318
|
+
Prior: 2026-11-02 — #10661 (D1, D5–D8): `publish_draft` (`mcp-workspace`'s `content_publish.ts`) twins the publish route for an agent holding `content-publish`, whose blanket agent refusal narrows to agents without the grant — the signed-in workspace surface is thirty-nine tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts`, `packages-paid/api-content-write/src/routes/workspaces/content.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
319
|
+
|
|
320
|
+
Prior: 2026-10-31 — #10391 (D2–D6): `/mcp/workspace` gains the OAuth bearer branch beside the agent key — one `token-info` lookup per request, the `WWW-Authenticate` challenge and the RFC 9728 protected-resource document, on only with `BETTER_AUTH_URL` and `MCP_PUBLIC_HTTP_URL` — verified against `packages/mcp-server/src/{http-transport,oauth-bearer}.ts`.
|
|
317
321
|
|
|
318
322
|
Prior: 2026-10-31 — #10345 (D9, D12): `read_knowledge` and `read_knowledge_doc` (`mcp-workspace`'s `knowledge_base.ts`) twin the per-user knowledge-base read — the signed-in workspace surface is thirty-seven tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
|
|
319
323
|
|
|
@@ -17,8 +17,10 @@ CONTENT_ADAPTER=mdx
|
|
|
17
17
|
# Override the on-disk content root. Defaults to <repo-root>/content.
|
|
18
18
|
# AILK_CONTENT_ROOT=
|
|
19
19
|
|
|
20
|
-
# The
|
|
21
|
-
#
|
|
20
|
+
# The secret /api/revalidate verifies apps/api's publish webhook against
|
|
21
|
+
# (#5877). On a site a hub serves (a spoke), the value `spokes revalidate-url`
|
|
22
|
+
# printed for it — NEVER the hub's own secret (#10593). Otherwise, the SAME
|
|
23
|
+
# value as the API's CONTENT_REVALIDATE_SECRET.
|
|
22
24
|
# Needed when CONTENT_ADAPTER=database. Unset means the route returns 503 and
|
|
23
25
|
# the site is not push-revalidated: a publish changes nothing user-visible
|
|
24
26
|
# until a rebuild.
|
|
@@ -43,7 +43,7 @@ Raw HTML is allowed only when no `@working-theory/ui` component fits. Flag it in
|
|
|
43
43
|
- Animations: only `transform` and `opacity`. Never `transition-all`.
|
|
44
44
|
- Every interactive element needs hover, focus-visible, and active states.
|
|
45
45
|
|
|
46
|
-
The four widths above are the responsive contract. Brand reference files (logos, photos, colour and type notes) live in `brand_assets/` at the repo root; the site's own brand colours and fonts are set in `content/_site.mdx` under `brand.colors` and `brand.fonts`. The signed-out header button is set there too, as `header.cta` (`label`, `href`), with `header.signIn: link | hidden` choosing whether Sign in stays beside it as a text link — never by editing `app/[locale]/layout.tsx`. Each page's chrome is set there too (#10049, resolved by `lib/page-chrome.ts`): `header.style` (`full | bare | none`), `footer.style` (`columns | centered | closing | none`), `layout` (`deck | default`, the home's) and one `{ header, footer }` key per route family (`blog`, `docs`, `services`, …), with a page's own `header:` / `footer:` / `layout:` frontmatter winning over all of them.
|
|
46
|
+
The four widths above are the responsive contract. Brand reference files (logos, photos, colour and type notes) live in `brand_assets/` at the repo root; the site's own brand colours and fonts are set in `content/_site.mdx` under `brand.colors` and `brand.fonts`. The signed-out header button is set there too, as `header.cta` (`label`, `href`), with `header.signIn: link | hidden` choosing whether Sign in stays beside it as a text link — never by editing `app/[locale]/layout.tsx`. Each page's chrome is set there too (#10049, resolved by `lib/page-chrome.ts`): `header.style` (`full | bare | none`), `footer.style` (`columns | centered | closing | none`), `layout` (`deck | default`, the home's) and one `{ header, footer }` key per route family (`blog`, `docs`, `services`, …), with a page's own `header:` / `footer:` / `layout:` frontmatter winning over all of them. The closing footer alone also reads three site-level keys (#10550): `footer.locality` (the line above the email, winning over the identity's address), `footer.actions` (one or two `{ label, href }` site paths — the primary, then the secondary — in place of the auth links) and `footer.legalBar` (off by default: `true` moves the legal line into a full-width bottom bar, on the deck's closing slide its bottom edge). The cookie notice's look is set there too (#10555, #10721): `consent.appearance` is `compact` (the floating card, bottom-left) or `hairline` (the full-width band on the page ground); absent, it follows the design's `chromeStyle` — the band under `minimal`, else the card. Only the band waits for the minimal deck home's first slide to leave view (#7782); the card shows on load on every page.
|
|
47
47
|
|
|
48
48
|
## Local dev
|
|
49
49
|
|
|
@@ -277,8 +277,8 @@ embedding for it is behind the hero CTA's lightbox gate
|
|
|
277
277
|
|
|
278
278
|
The workspace's **Newsletter magnet** (#8847) — seeded live at slug
|
|
279
279
|
`newsletter` in every workspace — is the only source of `/newsletter`,
|
|
280
|
-
`/unsubscribe
|
|
281
|
-
content or the message catalog. All
|
|
280
|
+
`/unsubscribe`, the footer signup and a bound `newsletter` section; none of
|
|
281
|
+
their copy is in page code, content or the message catalog. All four read it through
|
|
282
282
|
`lib/in-page-magnet.ts`'s `readServedMagnet("newsletter")`, shared for 15
|
|
283
283
|
seconds per instance like /contact's read.
|
|
284
284
|
|
|
@@ -306,6 +306,13 @@ seconds per instance like /contact's read.
|
|
|
306
306
|
- **The footer** fills `Footer`'s `subscribe` slot with the compact form
|
|
307
307
|
(stacked, `sr-only` label) only when the magnet is served and capture is
|
|
308
308
|
on; the built-in config never fills it.
|
|
309
|
+
- **A bound `newsletter` section** (#10629) — a content `newsletter` section
|
|
310
|
+
with `magnet: "newsletter"` in place of `action` renders, under its own
|
|
311
|
+
headline, the full-size signup the locale layout builds from the footer's
|
|
312
|
+
same read and hands every page through `NewsletterSignupProvider` — with
|
|
313
|
+
`consentGate: "on-submit"`, so Subscribe stays enabled and an unticked
|
|
314
|
+
submit shows the consent error beside the box. It renders nothing whenever
|
|
315
|
+
the footer would show nothing. A section with `action` posts as before.
|
|
309
316
|
|
|
310
317
|
## Careers
|
|
311
318
|
|
|
@@ -581,7 +588,7 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
|
|
|
581
588
|
- **No cross-site POST.** A `Sec-Fetch-Site` other than `same-origin`, or an `Origin` not on this site's own host, is refused 403 before the hub is called.
|
|
582
589
|
- **Headers are an allow-list.** The visitor's `x-forwarded-for` (else `x-real-ip`) and `user-agent` pass through so per-visitor rate limits still count each visitor; no `authorization`, client-sent `x-ailk-*` or `host` is forwarded, and a `cookie` goes only on `POST /v1/commerce/invoices`, the one session route.
|
|
583
590
|
- **The relay has its own per-visitor budget (#7707).** Each visitor gets 8 calls per 10 minutes across the five visitor routes, and the session route (`POST /v1/commerce/invoices`) gets its own, wider budget of 30 per 10 minutes; the call that exceeds either gets `429 rate_limited` before any hub call. The budget is held in this instance's own process (no shared store, no env variable) and holds only where the site's edge overwrites `x-forwarded-for` and `x-real-ip` — Vercel does; a bare self-hosted Next server does not.
|
|
584
|
-
- **A bot check on the public writes (#9889, #9255).** On Vercel, `lib/bot-check.ts`'s `runBotCheck` asks Vercel BotID for a verdict before the hub is called — in the relay for `POST /v1/leads/{submit,subscribe,callback}` and `POST /v1/schedule/book` (`BOT_CHECKED_ROUTES`: `403 bot_detected`, or `503 bot_check_unavailable` when the check throws, answers no boolean or takes over 5 seconds), and in the contact, careers, newsletter, flow-step, flow-checkout, saved-recommendation and `/dev/purchase` Server Actions immediately before their hub call, which answer their own existing failure variant. The locale layout's `BotIdClient` protects every same-origin POST (`BOT_CLIENT_PROTECT`, `/*`), because a Server Action posts to the page it was submitted from; `next.config.mjs` wraps `withBotId` and `middleware.ts` keeps BotID's proxy prefix out of its matcher. It is on by default and inert off Vercel: `AILK_BOT_CHECK=off` turns every check off, `AILK_BOOKING_BOT_CHECK=off` the booking's alone, and MCP tools never pass through a site, so they are never challenged.
|
|
591
|
+
- **A bot check on the public writes (#9889, #9255).** On Vercel, `lib/bot-check.ts`'s `runBotCheck` asks Vercel BotID for a verdict before the hub is called — in the relay for `POST /v1/leads/{submit,subscribe,callback}` and `POST /v1/schedule/book` (`BOT_CHECKED_ROUTES`: `403 bot_detected`, or `503 bot_check_unavailable` when the check throws, answers no boolean or takes over 5 seconds), and in the contact, careers, newsletter, flow-step, flow-checkout, saved-recommendation and `/dev/purchase` Server Actions immediately before their hub call, which answer their own existing failure variant. The locale layout's `BotIdClient` protects every same-origin POST (`BOT_CLIENT_PROTECT`, `/*`), because a Server Action posts to the page it was submitted from; `next.config.mjs` wraps `withBotId` and `middleware.ts` keeps BotID's proxy prefix out of its matcher. It is on by default and inert off Vercel: `AILK_BOT_CHECK=off` turns every check off, `AILK_BOOKING_BOT_CHECK=off` the booking's alone, and MCP tools never pass through a site, so they are never challenged. A refusal logs `[<source>] bot refused` with BotID's `classificationReason`, `bypassed` and `isVerifiedBot`, the request's `host` and `forwardedHost`, and `proxied` (a `cf-ray` header) — no other header value, no form field (#10715). `AILK_BOT_CHECK=observe` runs the check and logs the same facts plus `isBot` as `observed`, but passes every write — for diagnosis only. **A BotID-protected site must be reached directly — Cloudflare DNS-only, no reverse proxy in front of Vercel (#10715).** Behind a proxy the write reaches Vercel from the proxy's egress rather than the browser that solved the challenge, so BotID refuses every real visitor; the first proxied request per process logs `[<source>] reverse proxy detected` (outcome `proxied`) naming that fix.
|
|
585
592
|
- `/v1` is exempt from next-intl's locale rewrite in `middleware.ts`, as `/api` is.
|
|
586
593
|
|
|
587
594
|
## Where the docs live (`docsPath`)
|
|
@@ -637,7 +644,11 @@ A list page whose frontmatter sets `navOnly: true` (valid on a registry list typ
|
|
|
637
644
|
- Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
|
|
638
645
|
- Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
|
|
639
646
|
|
|
640
|
-
Last updated: 2026-11-
|
|
647
|
+
Last updated: 2026-11-02 — § Theme & styling: `consent.appearance` named beside the other `_site.mdx` chrome keys, and the minimal deck home's hold-back is the band's alone, so a minimal site that picks the card sees it on load there too (#10721) — verified against `app/[locale]/layout.tsx`.
|
|
648
|
+
|
|
649
|
+
Prior: 2026-11-02 — § The same-origin relay: a bot-check refusal names its cause (`classificationReason`, `bypassed`, `isVerifiedBot`, both hosts, `proxied`), `AILK_BOT_CHECK=observe` logs and never refuses, and a BotID-protected site must be reached directly, Cloudflare DNS-only (#10715) — verified against `lib/bot-check.ts`.
|
|
650
|
+
|
|
651
|
+
Prior: 2026-11-01 — § The owner's discovery switch: the six discovery files read the workspace's `discoverable` setting per request through `siteDiscoverable()`, off answering robots.txt's disallow-all and 404 for the rest, never cached (#10510) — verified against `app/{robots.txt,sitemap.xml,llms.txt,openapi.json}/route.ts`, `app/.well-known/{mcp.json,ai-plugin.json}/route.ts`, `lib/slices/docs-blog/llms-route.ts` and `packages/api-client/src/discovery.ts`.
|
|
641
652
|
|
|
642
653
|
Prior: 2026-10-30 — § The MCP authorization server: the site's OAuth 2.1 authorization server, its consent step, rotation and revocation (#10357) — verified against `auth.ts`, `lib/mcp-oauth.ts`, `app/[locale]/(auth)/consent/` and `app/.well-known/oauth-authorization-server/route.ts`.
|
|
643
654
|
|
|
@@ -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
|