create-ailk 0.31.0 → 0.33.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 +50 -8
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -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/export-content.d.ts +6 -3
- package/dist/export-content.js +79 -10
- package/dist/language-import.d.ts +16 -3
- package/dist/language-import.js +27 -11
- package/dist/lib/import-mdx.d.ts +42 -0
- package/dist/lib/import-mdx.js +584 -65
- 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/seed-flows.d.ts +1 -1
- package/dist/seed-flows.js +9 -4
- 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 +17 -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/api/CLAUDE.md +7 -5
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
- package/templates/apps/mcp/.env.example +7 -1
- package/templates/apps/web/__tests__/setup.ts +9 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -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]/contact/__tests__/contact.actions.test.ts +21 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
- package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
- 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 +3 -20
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
- package/templates/apps/web/auth.ts +22 -6
- package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
- 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/lib/__tests__/telemetry-scrub.test.ts +20 -0
- package/templates/apps/web/lib/account-config.ts +52 -14
- package/templates/apps/web/lib/auth-email.ts +87 -5
- package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
- package/templates/apps/web/messages/en.json +26 -1
- package/templates/apps/web/src/lib/routes.ts +5 -3
- package/templates/database/seed.ts +7 -7
- 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 +48 -48
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page body owns its H1 (#7495, spec docs/specs/7495-the-page-body-owns-its-spec.md
|
|
3
|
+
* D3–D5, D8): on a page `apps/web` renders in `bodyMode: "mdx-body"`, the one
|
|
4
|
+
* `<h1>` a reader sees is the body's `# ` heading — renderPage prints nothing
|
|
5
|
+
* from frontmatter `title` or `description` there.
|
|
6
|
+
*
|
|
7
|
+
* This module is the one owner of that rule, used by `create-ailk update`
|
|
8
|
+
* (the consumer's migration), `tools/content-compile` (the content check)
|
|
9
|
+
* and the tests:
|
|
10
|
+
*
|
|
11
|
+
* - which pages are mdx-body — read from the site's own `apps/web` routes,
|
|
12
|
+
* always or only when frontmatter `sections[]` is empty (`mdxBodyRule`,
|
|
13
|
+
* `isMdxBodyPage`);
|
|
14
|
+
* - how many `# ` headings a body has (`countBodyHeadings`);
|
|
15
|
+
* - the codemod that gives a page with none `# <title>` and its description
|
|
16
|
+
* paragraph (`addBodyHeading`, `planBodyHeadings`, `migrateBodyHeadings`).
|
|
17
|
+
* A page with one or more headings is never written;
|
|
18
|
+
* - the mdx-body pages of a content tree, with their bodies (`mdxBodyPages`),
|
|
19
|
+
* which the bare-brace pass and check read (#10773).
|
|
20
|
+
*
|
|
21
|
+
* Frontmatter is read with a dependency-free subset of YAML for the three
|
|
22
|
+
* keys it needs — `create-ailk` ships exactly one runtime dependency (`tar`),
|
|
23
|
+
* the `design-package.ts` posture — and any form the subset does not cover
|
|
24
|
+
* is refused, never guessed at. No relative imports: `tools/` imports this
|
|
25
|
+
* file from source.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
/** Per content type: mdx-body always, or only while `sections[]` is empty. */
|
|
31
|
+
export type MdxBodyRule = Map<string, "always" | "unless-sections">;
|
|
32
|
+
|
|
33
|
+
// The `renderPage({ … })` discovery regexes of `tools/lint-mdx-body-compiles.ts`.
|
|
34
|
+
const RENDER_PAGE_CALL_RE = /renderPage\s*\(\s*\{[\s\S]*?\}\s*\)/g;
|
|
35
|
+
const TYPE_RE = /type\s*:\s*["']([A-Za-z]+)["']/;
|
|
36
|
+
const MDX_BODY_MODE_RE = /bodyMode\s*:\s*["']mdx-body["']/;
|
|
37
|
+
// `...(composed ? {} : { bodyMode: "mdx-body" … })` — the routes' sections test.
|
|
38
|
+
const CONDITIONAL_MDX_BODY_RE = /\?\s*\{\s*\}\s*:\s*\{[^}]*bodyMode\s*:\s*["']mdx-body["']/;
|
|
39
|
+
// A content hub's `HubConfig` (#10121): `hubPageInput` sets `bodyMode` from
|
|
40
|
+
// its `body` — `mdx-body` always, `composed` unless `sections[]` is non-empty.
|
|
41
|
+
// A hub page holds one config, so its one `listType` and one `body` pair in
|
|
42
|
+
// any order; a file with more of either is not read.
|
|
43
|
+
const HUB_INPUT_RE = /\bhubPageInput\s*\(/;
|
|
44
|
+
const HUB_LIST_TYPE_RE = /\blistType\s*:\s*["']([A-Za-z]+)["']/g;
|
|
45
|
+
const HUB_BODY_RE = /\bbody\s*:\s*["'](mdx-body|composed|sections)["']/g;
|
|
46
|
+
|
|
47
|
+
const LOCALE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
48
|
+
const SKIPPED_DIRS = new Set(["node_modules", ".next", "dist"]);
|
|
49
|
+
|
|
50
|
+
function pageFiles(dir: string, out: string[] = []): string[] {
|
|
51
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
52
|
+
if (SKIPPED_DIRS.has(entry.name)) continue;
|
|
53
|
+
const path = join(dir, entry.name);
|
|
54
|
+
if (entry.isDirectory()) pageFiles(path, out);
|
|
55
|
+
else if (entry.isFile() && (entry.name === "page.tsx" || entry.name === "page.ts")) out.push(path);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Scan `<appsRoot>/apps/web/app/**\/page.{ts,tsx}` for `renderPage({…})` calls
|
|
62
|
+
* naming `bodyMode: "mdx-body"`. A call whose `bodyMode` sits in a conditional
|
|
63
|
+
* spread is `"unless-sections"`, any other `"always"`; `"always"` wins when two
|
|
64
|
+
* routes name one type. A content hub's `HubConfig` reads the same way, from
|
|
65
|
+
* its `body`. A missing `apps/web/app` gives an empty map.
|
|
66
|
+
*/
|
|
67
|
+
export function mdxBodyRule(appsRoot: string): MdxBodyRule {
|
|
68
|
+
const rule: MdxBodyRule = new Map();
|
|
69
|
+
const appDir = join(appsRoot, "apps", "web", "app");
|
|
70
|
+
if (!existsSync(appDir)) return rule;
|
|
71
|
+
for (const file of pageFiles(appDir)) {
|
|
72
|
+
const source = readFileSync(file, "utf8");
|
|
73
|
+
if (HUB_INPUT_RE.test(source)) {
|
|
74
|
+
const types = [...source.matchAll(HUB_LIST_TYPE_RE)];
|
|
75
|
+
const bodies = [...source.matchAll(HUB_BODY_RE)];
|
|
76
|
+
if (types.length === 1 && bodies.length === 1) {
|
|
77
|
+
const type = types[0]![1]!;
|
|
78
|
+
const body = bodies[0]![1];
|
|
79
|
+
if (body === "mdx-body") rule.set(type, "always");
|
|
80
|
+
else if (body === "composed" && !rule.has(type)) rule.set(type, "unless-sections");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const call of source.matchAll(RENDER_PAGE_CALL_RE)) {
|
|
84
|
+
const block = call[0];
|
|
85
|
+
if (!MDX_BODY_MODE_RE.test(block)) continue;
|
|
86
|
+
const type = TYPE_RE.exec(block)?.[1];
|
|
87
|
+
if (type === undefined) continue;
|
|
88
|
+
if (CONDITIONAL_MDX_BODY_RE.test(block)) {
|
|
89
|
+
if (!rule.has(type)) rule.set(type, "unless-sections");
|
|
90
|
+
} else {
|
|
91
|
+
rule.set(type, "always");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return rule;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The routes' own `composed` test: mdx-body unless `sections` is a non-empty array. */
|
|
99
|
+
export function isMdxBodyPage(
|
|
100
|
+
type: string,
|
|
101
|
+
frontmatter: Record<string, unknown>,
|
|
102
|
+
rule: MdxBodyRule,
|
|
103
|
+
): boolean {
|
|
104
|
+
const mode = rule.get(type);
|
|
105
|
+
if (mode === "always") return true;
|
|
106
|
+
if (mode !== "unless-sections") return false;
|
|
107
|
+
const sections = frontmatter.sections;
|
|
108
|
+
return !(Array.isArray(sections) && sections.length > 0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── The heading count (D4) ───────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})/;
|
|
114
|
+
const ATX_H1_RE = /^ {0,3}# +\S/;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* ATX level-1 headings (`^ {0,3}# +\S`) outside fenced code blocks and
|
|
118
|
+
* multi-line `{/* … *\/}` comments. `##`, `#text` and Setext headings do not
|
|
119
|
+
* count. A fence closes, as in CommonMark, on a run of its own character at
|
|
120
|
+
* least as long as the opening one, with nothing after it.
|
|
121
|
+
*/
|
|
122
|
+
export function countBodyHeadings(body: string): number {
|
|
123
|
+
let fence: string | undefined;
|
|
124
|
+
let inComment = false;
|
|
125
|
+
let count = 0;
|
|
126
|
+
for (const line of body.split(/\r?\n/)) {
|
|
127
|
+
if (inComment) {
|
|
128
|
+
if (line.includes("*/}")) inComment = false;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const fenceMatch = FENCE_RE.exec(line);
|
|
132
|
+
if (fence !== undefined) {
|
|
133
|
+
const run = fenceMatch?.[1];
|
|
134
|
+
if (
|
|
135
|
+
run !== undefined &&
|
|
136
|
+
run[0] === fence[0] &&
|
|
137
|
+
run.length >= fence.length &&
|
|
138
|
+
line.slice(fenceMatch![0].length).trim() === ""
|
|
139
|
+
) {
|
|
140
|
+
fence = undefined;
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (fenceMatch !== null) {
|
|
145
|
+
fence = fenceMatch[1];
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (ATX_H1_RE.test(line)) count += 1;
|
|
149
|
+
const open = line.lastIndexOf("{/*");
|
|
150
|
+
if (open !== -1 && !line.includes("*/", open + 3)) inComment = true;
|
|
151
|
+
}
|
|
152
|
+
return count;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Frontmatter: split and the subset reader (D8) ───────────────────────────
|
|
156
|
+
|
|
157
|
+
interface SplitPage {
|
|
158
|
+
/** Top-level frontmatter lines, between the first two `---` lines. */
|
|
159
|
+
frontmatter: string[];
|
|
160
|
+
/** Offset of the body: everything after the closing `---` line. */
|
|
161
|
+
bodyStart: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function splitPage(raw: string): SplitPage | undefined {
|
|
165
|
+
const open = /^\uFEFF?---[ \t]*\r?\n/.exec(raw);
|
|
166
|
+
if (open === null) return undefined;
|
|
167
|
+
const close = /^---[ \t]*(?:\r?\n|$)/m;
|
|
168
|
+
const rest = raw.slice(open[0].length);
|
|
169
|
+
const match = close.exec(rest);
|
|
170
|
+
if (match === null) return undefined;
|
|
171
|
+
return {
|
|
172
|
+
frontmatter: rest.slice(0, match.index).split(/\r?\n/),
|
|
173
|
+
bodyStart: open[0].length + match.index + match[0].length,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
class Refused extends Error {}
|
|
178
|
+
|
|
179
|
+
const refusedKey = (key: string): Refused =>
|
|
180
|
+
new Refused(`frontmatter the subset reader does not read: ${key}`);
|
|
181
|
+
|
|
182
|
+
/** The value after `<key>:` on its top-level line, and the lines after it. */
|
|
183
|
+
function topLevel(lines: string[], key: string): { value: string; next: string[] } | undefined {
|
|
184
|
+
const prefix = `${key}:`;
|
|
185
|
+
const index = lines.findIndex((line) => line.startsWith(prefix));
|
|
186
|
+
if (index === -1) return undefined;
|
|
187
|
+
return { value: lines[index]!.slice(prefix.length).trim(), next: lines.slice(index + 1) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const nextNonBlank = (lines: string[]): string | undefined =>
|
|
191
|
+
lines.find((line) => line.trim() !== "");
|
|
192
|
+
|
|
193
|
+
/** The key's value goes on below it: its next non-blank line is indented. */
|
|
194
|
+
const continues = (next: string[]): boolean => /^[ \t]/.test(nextNonBlank(next) ?? "");
|
|
195
|
+
|
|
196
|
+
const YAML_INDICATOR_RE = /^[-?:,[\]{}#&*!|>'"%@`]/;
|
|
197
|
+
|
|
198
|
+
/** A single-line plain or double-quoted scalar; `""` for an absent or empty key. */
|
|
199
|
+
function readScalar(lines: string[], key: string): string {
|
|
200
|
+
const entry = topLevel(lines, key);
|
|
201
|
+
if (entry === undefined) return "";
|
|
202
|
+
const { value, next } = entry;
|
|
203
|
+
if (continues(next)) throw refusedKey(key);
|
|
204
|
+
if (value === "") return "";
|
|
205
|
+
if (value.startsWith('"')) {
|
|
206
|
+
try {
|
|
207
|
+
const decoded: unknown = JSON.parse(value);
|
|
208
|
+
if (typeof decoded === "string") return decoded;
|
|
209
|
+
} catch {
|
|
210
|
+
// refused below
|
|
211
|
+
}
|
|
212
|
+
throw refusedKey(key);
|
|
213
|
+
}
|
|
214
|
+
if (YAML_INDICATOR_RE.test(value) || /\s#/.test(value)) throw refusedKey(key);
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `sections` as empty (absent, `[]`) or non-empty: a one-line flow sequence
|
|
220
|
+
* with content, or an empty value whose next line opens a block sequence
|
|
221
|
+
* (`- `, indented or not) or, indented, a multi-line flow sequence (`[` and
|
|
222
|
+
* anything but `]`).
|
|
223
|
+
*/
|
|
224
|
+
function readSectionsNonEmpty(lines: string[]): boolean {
|
|
225
|
+
const entry = topLevel(lines, "sections");
|
|
226
|
+
if (entry === undefined) return false;
|
|
227
|
+
const value = entry.value.replace(/\s/g, "");
|
|
228
|
+
if (value === "[]") return false;
|
|
229
|
+
if (value.startsWith("[") && value.endsWith("]")) return true;
|
|
230
|
+
const rest = entry.next.filter((line) => line.trim() !== "");
|
|
231
|
+
if (value === "" && rest[0] !== undefined) {
|
|
232
|
+
const first = rest[0].trim();
|
|
233
|
+
if (first.startsWith("- ")) return true;
|
|
234
|
+
if (/^[ \t]/.test(rest[0])) {
|
|
235
|
+
if (first.replace(/\s/g, "") === "[]") return false;
|
|
236
|
+
if (first.startsWith("[")) {
|
|
237
|
+
const after = first.slice(1).trim() || (rest[1]?.trim() ?? "");
|
|
238
|
+
if (after !== "" && !after.startsWith("]")) return true;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw refusedKey("sections");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Inserting the heading (D5) ───────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
const MDX_ESCAPED_RE = /[\\{<`*_[]/g;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Text for an MDX body line: whitespace runs collapsed, trimmed, and every
|
|
251
|
+
* `\`, `{`, `<`, `` ` ``, `*`, `_` and `[` backslash-escaped.
|
|
252
|
+
*/
|
|
253
|
+
export function mdxText(text: string): string {
|
|
254
|
+
return text.replace(/\s+/g, " ").trim().replace(MDX_ESCAPED_RE, (char) => `\\${char}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const headingText = (title: string): string => mdxText(title).replace(/#$/, "\\#");
|
|
258
|
+
|
|
259
|
+
// A leading block marker would start a heading, list or quote; a leading
|
|
260
|
+
// `import `/`export ` would make the line ESM, so its first letter becomes a
|
|
261
|
+
// character reference that renders as the same letter.
|
|
262
|
+
const paragraphText = (description: string): string =>
|
|
263
|
+
mdxText(description)
|
|
264
|
+
.replace(/^[#+>-]|^(\d+)([.)])/, (marker, digits?: string, delimiter?: string) =>
|
|
265
|
+
digits === undefined ? `\\${marker}` : `${digits}\\${delimiter}`,
|
|
266
|
+
)
|
|
267
|
+
.replace(/^(?:import|export)\b/, (word) => `&#${word.charCodeAt(0)};${word.slice(1)}`);
|
|
268
|
+
|
|
269
|
+
/** Offset in `body` after leading blank lines and a leading ESM block. */
|
|
270
|
+
function insertionPoint(body: string): { at: number; afterEsm: boolean } {
|
|
271
|
+
const lines = body.split(/(?<=\n)/);
|
|
272
|
+
let at = 0;
|
|
273
|
+
let i = 0;
|
|
274
|
+
const blank = (line: string | undefined): boolean => line !== undefined && line.trim() === "";
|
|
275
|
+
while (blank(lines[i])) at += lines[i++]!.length;
|
|
276
|
+
if (!/^(?:import|export) /.test(lines[i] ?? "")) return { at, afterEsm: false };
|
|
277
|
+
while (i < lines.length && !blank(lines[i])) at += lines[i++]!.length;
|
|
278
|
+
const sawBlank = blank(lines[i]);
|
|
279
|
+
while (blank(lines[i])) at += lines[i++]!.length;
|
|
280
|
+
return { at, afterEsm: !sawBlank };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
type Plan =
|
|
284
|
+
| { kind: "skip" }
|
|
285
|
+
| { kind: "write"; text: string }
|
|
286
|
+
| { kind: "refuse"; reason: string };
|
|
287
|
+
|
|
288
|
+
function planPage(raw: string, rule: MdxBodyRule, type: string): Plan {
|
|
289
|
+
const mode = rule.get(type);
|
|
290
|
+
if (mode === undefined) return { kind: "skip" };
|
|
291
|
+
const split = splitPage(raw);
|
|
292
|
+
const body = split === undefined ? raw : raw.slice(split.bodyStart);
|
|
293
|
+
if (countBodyHeadings(body) > 0) return { kind: "skip" };
|
|
294
|
+
if (split === undefined) return { kind: "refuse", reason: "no frontmatter" };
|
|
295
|
+
try {
|
|
296
|
+
if (mode === "unless-sections" && readSectionsNonEmpty(split.frontmatter)) {
|
|
297
|
+
return { kind: "skip" };
|
|
298
|
+
}
|
|
299
|
+
const title = readScalar(split.frontmatter, "title");
|
|
300
|
+
if (title.trim() === "") return { kind: "refuse", reason: "no title" };
|
|
301
|
+
const description = readScalar(split.frontmatter, "description");
|
|
302
|
+
if (description.trim() === "") return { kind: "refuse", reason: "no description" };
|
|
303
|
+
const eol = raw.includes("\r\n") ? "\r\n" : "\n";
|
|
304
|
+
const { at, afterEsm } = insertionPoint(body);
|
|
305
|
+
const rest = body.slice(at);
|
|
306
|
+
const front = raw.slice(0, split.bodyStart);
|
|
307
|
+
// A file that ends at its closing `---` with no line ending needs one first.
|
|
308
|
+
const lead = afterEsm
|
|
309
|
+
? body.slice(0, at).endsWith("\n") ? eol : eol + eol
|
|
310
|
+
: front.endsWith("\n") ? "" : eol;
|
|
311
|
+
const inserted =
|
|
312
|
+
`${lead}# ${headingText(title)}${eol}${eol}${paragraphText(description)}${eol}` +
|
|
313
|
+
(rest === "" ? "" : eol);
|
|
314
|
+
return { kind: "write", text: front + body.slice(0, at) + inserted + rest };
|
|
315
|
+
} catch (err) {
|
|
316
|
+
if (err instanceof Refused) return { kind: "refuse", reason: err.message };
|
|
317
|
+
throw err;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The new file text for an mdx-body page with zero `# ` headings — its
|
|
323
|
+
* `# <title>` and description paragraph inserted at the top of the body —
|
|
324
|
+
* else `undefined`. A page the subset reader refuses, with no frontmatter, or
|
|
325
|
+
* with an empty `title` or `description`, is never written.
|
|
326
|
+
*/
|
|
327
|
+
export function addBodyHeading(raw: string, rule: MdxBodyRule, type: string): string | undefined {
|
|
328
|
+
const plan = planPage(raw, rule, type);
|
|
329
|
+
return plan.kind === "write" ? plan.text : undefined;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Over a content tree ──────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
interface ContentPage {
|
|
335
|
+
path: string;
|
|
336
|
+
type: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Every `<contentDir>/<locale>/<Type>/<slug>.mdx` whose `<Type>` the rule
|
|
341
|
+
* names, sorted. Symlinks are skipped, as `copy.ts` and `update.ts` skip
|
|
342
|
+
* them, so the codemod never reads or writes outside `contentDir`.
|
|
343
|
+
*/
|
|
344
|
+
function rulePages(contentDir: string, rule: MdxBodyRule): ContentPage[] {
|
|
345
|
+
if (!existsSync(contentDir)) return [];
|
|
346
|
+
const pages: ContentPage[] = [];
|
|
347
|
+
for (const locale of readdirSync(contentDir, { withFileTypes: true })) {
|
|
348
|
+
if (!locale.isDirectory() || !LOCALE_RE.test(locale.name)) continue;
|
|
349
|
+
for (const type of rule.keys()) {
|
|
350
|
+
const typeDir = join(contentDir, locale.name, type);
|
|
351
|
+
if (!existsSync(typeDir) || !lstatSync(typeDir).isDirectory()) continue;
|
|
352
|
+
for (const entry of readdirSync(typeDir, { withFileTypes: true })) {
|
|
353
|
+
if (entry.isFile() && entry.name.endsWith(".mdx")) {
|
|
354
|
+
pages.push({ path: join(typeDir, entry.name), type });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return pages.sort((a, b) => a.path.localeCompare(b.path));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export interface BodyHeadingPlan {
|
|
363
|
+
writes: Array<{ path: string; text: string }>;
|
|
364
|
+
refused: Array<{ path: string; reason: string }>;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** What `migrateBodyHeadings` would write, and the heading-less pages it cannot. Never throws for a page. */
|
|
368
|
+
export function planBodyHeadings(contentDir: string, rule: MdxBodyRule): BodyHeadingPlan {
|
|
369
|
+
const plan: BodyHeadingPlan = { writes: [], refused: [] };
|
|
370
|
+
for (const { path, type } of rulePages(contentDir, rule)) {
|
|
371
|
+
let page: Plan;
|
|
372
|
+
try {
|
|
373
|
+
page = planPage(readFileSync(path, "utf8"), rule, type);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
plan.refused.push({ path, reason: `could not be read: ${String(err)}` });
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (page.kind === "write") plan.writes.push({ path, text: page.text });
|
|
379
|
+
else if (page.kind === "refuse") plan.refused.push({ path, reason: page.reason });
|
|
380
|
+
}
|
|
381
|
+
return plan;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface MdxBodyPage {
|
|
385
|
+
path: string;
|
|
386
|
+
/** The file's text. */
|
|
387
|
+
raw: string;
|
|
388
|
+
/** Offset of the body in `raw`; 0 for a page with no frontmatter. */
|
|
389
|
+
bodyStart: number;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Every page under `contentDir` that renders as mdx-body (#10773 D6): the
|
|
394
|
+
* `rulePages` walk, less an `unless-sections` page whose `sections` is
|
|
395
|
+
* non-empty or unreadable. An unreadable file is skipped. Sorted by path.
|
|
396
|
+
*/
|
|
397
|
+
export function mdxBodyPages(contentDir: string, rule: MdxBodyRule): MdxBodyPage[] {
|
|
398
|
+
const pages: MdxBodyPage[] = [];
|
|
399
|
+
for (const { path, type } of rulePages(contentDir, rule)) {
|
|
400
|
+
let raw: string;
|
|
401
|
+
try {
|
|
402
|
+
raw = readFileSync(path, "utf8");
|
|
403
|
+
} catch {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const split = splitPage(raw);
|
|
407
|
+
if (rule.get(type) === "unless-sections" && split !== undefined) {
|
|
408
|
+
try {
|
|
409
|
+
if (readSectionsNonEmpty(split.frontmatter)) continue;
|
|
410
|
+
} catch {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
pages.push({ path, raw, bodyStart: split?.bodyStart ?? 0 });
|
|
415
|
+
}
|
|
416
|
+
return pages;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Every mdx-body page whose body does not have exactly one `# ` heading. */
|
|
420
|
+
export function headingViolations(
|
|
421
|
+
contentDir: string,
|
|
422
|
+
rule: MdxBodyRule,
|
|
423
|
+
): Array<{ path: string; count: number }> {
|
|
424
|
+
const violations: Array<{ path: string; count: number }> = [];
|
|
425
|
+
for (const { path, raw, bodyStart } of mdxBodyPages(contentDir, rule)) {
|
|
426
|
+
const count = countBodyHeadings(raw.slice(bodyStart));
|
|
427
|
+
if (count !== 1) violations.push({ path, count });
|
|
428
|
+
}
|
|
429
|
+
return violations;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Write each planned page; the paths written and the refused rows. */
|
|
433
|
+
export function migrateBodyHeadings(
|
|
434
|
+
contentDir: string,
|
|
435
|
+
rule: MdxBodyRule,
|
|
436
|
+
): { written: string[]; refused: Array<{ path: string; reason: string }> } {
|
|
437
|
+
const { writes, refused } = planBodyHeadings(contentDir, rule);
|
|
438
|
+
for (const { path, text } of writes) writeFileSync(path, text);
|
|
439
|
+
return { written: writes.map((write) => write.path), refused };
|
|
440
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* MDX body check (#10773) — fails when a page that renders as mdx-body
|
|
4
|
+
* carries a bare `{` in its body.
|
|
5
|
+
*
|
|
6
|
+
* MDX reads a `{` in body text as JavaScript: a placeholder such as
|
|
7
|
+
* `{First engagement}` fails the page, and one that parses (`{Tagline}`) is
|
|
8
|
+
* dropped from it. Write the brace as `\{`. Which pages are mdx-body is read
|
|
9
|
+
* from the site's own `apps/web/app` routes; frontmatter, fenced and inline
|
|
10
|
+
* code, JSX tags and `{/* … *\/}` comments are never flagged. When the site's
|
|
11
|
+
* dependencies are installed, each page with no bare brace is also parsed by
|
|
12
|
+
* the site's own MDX parser, and a parse failure, or an expression left that
|
|
13
|
+
* is not a comment, is reported.
|
|
14
|
+
*
|
|
15
|
+
* `mdx-braces.ts` and `body-heading.ts` beside this file are copies of
|
|
16
|
+
* create-ailk's own; the same rule `create-ailk update` repairs with.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* pnpm mdx-body:check # or: tsx tools/check-mdx-body.ts
|
|
20
|
+
*
|
|
21
|
+
* Exit codes:
|
|
22
|
+
* 0 — no bare brace (and no parse failure) on any mdx-body page
|
|
23
|
+
* 1 — one or more findings, each named by file, line and column
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync } from "node:fs";
|
|
27
|
+
import { createRequire } from "node:module";
|
|
28
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
29
|
+
import { pathToFileURL } from "node:url";
|
|
30
|
+
|
|
31
|
+
import { mdxBodyPages, mdxBodyRule } from "./body-heading.js";
|
|
32
|
+
import { findBareBraces, firstExpression, type MdastNode } from "./mdx-braces.js";
|
|
33
|
+
|
|
34
|
+
const ROOT = resolve(dirname(process.argv[1] ?? "."), "..");
|
|
35
|
+
|
|
36
|
+
type Parse = (body: string) => MdastNode;
|
|
37
|
+
|
|
38
|
+
/** `@mdx-js/mdx` through the `next-mdx-remote` apps/web renders with, or undefined. */
|
|
39
|
+
async function siteParse(): Promise<Parse | undefined> {
|
|
40
|
+
const web = join(ROOT, "apps", "web");
|
|
41
|
+
// `resolve` falls back to NODE_PATH, so the package must be found here first.
|
|
42
|
+
for (let at = web; ; at = dirname(at)) {
|
|
43
|
+
if (existsSync(join(at, "node_modules", "next-mdx-remote", "package.json"))) break;
|
|
44
|
+
if (dirname(at) === at) return undefined;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const remote = createRequire(join(web, "package.json")).resolve(
|
|
48
|
+
"next-mdx-remote/package.json",
|
|
49
|
+
);
|
|
50
|
+
const entry = createRequire(remote).resolve("@mdx-js/mdx");
|
|
51
|
+
const mdx = (await import(pathToFileURL(entry).href)) as {
|
|
52
|
+
createProcessor?: () => { parse: Parse };
|
|
53
|
+
};
|
|
54
|
+
const createProcessor = mdx.createProcessor;
|
|
55
|
+
return typeof createProcessor === "function"
|
|
56
|
+
? (body) => createProcessor().parse(body)
|
|
57
|
+
: undefined;
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function main(): Promise<void> {
|
|
64
|
+
const parse = await siteParse();
|
|
65
|
+
const pages = mdxBodyPages(join(ROOT, "content"), mdxBodyRule(ROOT));
|
|
66
|
+
const rows: string[] = [];
|
|
67
|
+
const failed = new Set<string>();
|
|
68
|
+
|
|
69
|
+
for (const { path, raw, bodyStart } of pages) {
|
|
70
|
+
const file = relative(ROOT, path).split(sep).join("/");
|
|
71
|
+
const lineOffset = raw.slice(0, bodyStart).split("\n").length - 1;
|
|
72
|
+
const body = raw.slice(bodyStart);
|
|
73
|
+
const braces = findBareBraces(body);
|
|
74
|
+
for (const { line, column, kind } of braces) {
|
|
75
|
+
rows.push(
|
|
76
|
+
`${file}:${line + lineOffset}:${column} — ` +
|
|
77
|
+
(kind === "comment-like"
|
|
78
|
+
? "{/ is not a {/* … */} comment."
|
|
79
|
+
: "a bare { in an mdx-body page is MDX code: the page fails or drops it. Write it as \\{."),
|
|
80
|
+
);
|
|
81
|
+
failed.add(file);
|
|
82
|
+
}
|
|
83
|
+
if (braces.length > 0 || parse === undefined) continue;
|
|
84
|
+
try {
|
|
85
|
+
const left = firstExpression(parse(body));
|
|
86
|
+
const line = left?.position?.start?.line;
|
|
87
|
+
if (left !== undefined) {
|
|
88
|
+
rows.push(
|
|
89
|
+
`${file}:${typeof line === "number" ? line + lineOffset : 1}:1 — an expression in an mdx-body page is dropped when it renders. Write its braces as \\{.`,
|
|
90
|
+
);
|
|
91
|
+
failed.add(file);
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const { line, column, reason } = (err ?? {}) as {
|
|
95
|
+
line?: unknown;
|
|
96
|
+
column?: unknown;
|
|
97
|
+
reason?: unknown;
|
|
98
|
+
};
|
|
99
|
+
const at =
|
|
100
|
+
typeof line === "number"
|
|
101
|
+
? `${line + lineOffset}:${typeof column === "number" ? column : 1}`
|
|
102
|
+
: "1:1";
|
|
103
|
+
rows.push(
|
|
104
|
+
`${file}:${at} — ${typeof reason === "string" ? reason : String(err)}`,
|
|
105
|
+
);
|
|
106
|
+
failed.add(file);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (rows.length === 0) {
|
|
111
|
+
console.log(
|
|
112
|
+
`check-mdx-body: OK — ${pages.length} mdx-body page(s), no bare brace.`,
|
|
113
|
+
);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
console.error(
|
|
117
|
+
`check-mdx-body: FAIL — ${rows.length} finding(s) in ${failed.size} page(s)`,
|
|
118
|
+
);
|
|
119
|
+
for (const row of rows) console.error(row);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
void main();
|