@clarify-labs/cli 0.11.18 → 0.11.20

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.
@@ -3,16 +3,13 @@ import mdxPlugin from "@mdx-js/rollup";
3
3
  import tailwindcss from "@tailwindcss/vite";
4
4
  import react from "@vitejs/plugin-react";
5
5
  import rehypeRaw from "rehype-raw";
6
- import { compile } from "@mdx-js/mdx";
6
+ import { compile, createProcessor } from "@mdx-js/mdx";
7
+ import GithubSlugger, { slug } from "github-slugger";
8
+ import { markdownRemarkPlugins, parseCodeMeta, themeBootstrapScript, themePresets } from "@clarify-labs/renderer";
7
9
  import rehypeShiki from "@shikijs/rehype";
8
- import rehypeSlug from "rehype-slug";
9
10
  import { createCssVariablesTheme } from "shiki";
10
11
  import { visit } from "unist-util-visit";
11
- import { markdownRemarkPlugins, parseCodeMeta, themeBootstrapScript, themePresets } from "@clarify-labs/renderer";
12
12
  import { readFile, readdir, stat } from "node:fs/promises";
13
- import GithubSlugger, { slug } from "github-slugger";
14
- import { toString } from "mdast-util-to-string";
15
- import { remark } from "remark";
16
13
  import matter from "gray-matter";
17
14
  import { spawnSync } from "node:child_process";
18
15
  import { createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
@@ -45,6 +42,402 @@ function createContentDiagnostic(options) {
45
42
  };
46
43
  }
47
44
  //#endregion
45
+ //#region source/parsers/markdown/headings.ts
46
+ var HEADING_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
47
+ var INTERNAL_HEADING_ID_PREFIX = "clarify-internal-heading-id:";
48
+ var EXPLICIT_ID_PATTERN = /\s+\{#([^{}]*)\}$/;
49
+ var HEADING_REMARK_PLUGINS = markdownRemarkPlugins;
50
+ function visitHeadings(node, callback) {
51
+ if (node.type === "heading") callback(node);
52
+ for (const child of node.children ?? []) visitHeadings(child, callback);
53
+ }
54
+ function visitHtml(node, callback) {
55
+ if (node.type === "html") callback(node);
56
+ for (const child of node.children ?? []) visitHtml(child, callback);
57
+ }
58
+ function visitMdxIntrinsicHeadings(node, callback) {
59
+ if ((node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") && node.name && /^h[1-6]$/.test(node.name)) callback(node);
60
+ for (const child of node.children ?? []) visitMdxIntrinsicHeadings(child, callback);
61
+ }
62
+ function visitHastHeadings(node, callback) {
63
+ if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) callback(node);
64
+ for (const child of node.children ?? []) visitHastHeadings(child, callback);
65
+ }
66
+ function hastText$1(node) {
67
+ if (node.type === "text") return node.value ?? "";
68
+ return (node.children ?? []).map(hastText$1).join("");
69
+ }
70
+ function applySourceEdits(content, edits) {
71
+ return edits.sort((left, right) => right.start - left.start).reduce((result, edit) => result.slice(0, edit.start) + edit.replacement + result.slice(edit.end), content);
72
+ }
73
+ function headingPosition(node, lineOffset) {
74
+ return {
75
+ line: (node.position?.start.line ?? 1) + lineOffset,
76
+ column: node.position?.start.column ?? 1
77
+ };
78
+ }
79
+ function formatPosition(position) {
80
+ return `${position.line}:${position.column}`;
81
+ }
82
+ function finalTextNode(heading) {
83
+ const node = heading.children?.at(-1);
84
+ return node?.type === "text" && typeof node.value === "string" ? node : void 0;
85
+ }
86
+ function contentEndOffset(heading) {
87
+ return (heading.children?.at(-1))?.position?.end.offset ?? heading.position?.end.offset;
88
+ }
89
+ function explicitIdInfo(heading, content) {
90
+ const text = finalTextNode(heading);
91
+ if (!text?.position) return void 0;
92
+ const startOffset = text.position.start.offset;
93
+ const endOffset = text.position.end.offset;
94
+ if (startOffset === void 0 || endOffset === void 0) return void 0;
95
+ const match = content.slice(startOffset, endOffset).match(EXPLICIT_ID_PATTERN);
96
+ if (!match || match.index === void 0) return void 0;
97
+ return {
98
+ id: match[1] ?? "",
99
+ start: startOffset + match.index,
100
+ end: startOffset + match.index + match[0].length
101
+ };
102
+ }
103
+ function maskExplicitHeadingMarkers(content) {
104
+ const tree = createProcessor({
105
+ format: "md",
106
+ remarkPlugins: HEADING_REMARK_PLUGINS
107
+ }).parse(content);
108
+ const edits = [];
109
+ visitHeadings(tree, (heading) => {
110
+ const marker = explicitIdInfo(heading, content);
111
+ if (marker) edits.push({
112
+ start: marker.start,
113
+ end: marker.end,
114
+ replacement: "x".repeat(marker.end - marker.start)
115
+ });
116
+ });
117
+ return applySourceEdits(content, edits);
118
+ }
119
+ function parseHeadingTree(content, kind) {
120
+ if (kind === "markdown") return createProcessor({
121
+ format: "md",
122
+ remarkPlugins: HEADING_REMARK_PLUGINS
123
+ }).parse(content);
124
+ const maskedContent = maskExplicitHeadingMarkers(content);
125
+ try {
126
+ return createProcessor({
127
+ format: "mdx",
128
+ remarkPlugins: HEADING_REMARK_PLUGINS
129
+ }).parse(maskedContent);
130
+ } catch {
131
+ return;
132
+ }
133
+ }
134
+ function renderedMarkdownHeadingTitles(tree, content, kind) {
135
+ const originalText = /* @__PURE__ */ new Map();
136
+ visitHeadings(tree, (heading) => {
137
+ const marker = explicitIdInfo(heading, content);
138
+ const text = finalTextNode(heading);
139
+ if (!marker || !text || text.value === void 0) return;
140
+ originalText.set(text, text.value);
141
+ text.value = text.value.slice(0, -(marker.end - marker.start)).replace(/\s+$/, "");
142
+ });
143
+ try {
144
+ let hast;
145
+ const captureHast = () => (tree) => {
146
+ hast = tree;
147
+ };
148
+ createProcessor({
149
+ format: kind === "markdown" ? "md" : "mdx",
150
+ remarkPlugins: HEADING_REMARK_PLUGINS,
151
+ ...kind === "markdown" ? { remarkRehypeOptions: { allowDangerousHtml: true } } : {},
152
+ rehypePlugins: kind === "markdown" ? [rehypeRaw, captureHast] : [captureHast]
153
+ }).runSync(tree, { value: content });
154
+ const titles = /* @__PURE__ */ new Map();
155
+ if (!hast) return titles;
156
+ visitHastHeadings(hast, (heading) => {
157
+ const offset = heading.position?.start.offset;
158
+ if (offset !== void 0) titles.set(offset, hastText$1(heading));
159
+ });
160
+ return titles;
161
+ } finally {
162
+ for (const [text, value] of originalText) text.value = value;
163
+ }
164
+ }
165
+ function isHtmlWhitespace(character) {
166
+ return character === " " || character === "\n" || character === "\f" || character === "\r" || character === " ";
167
+ }
168
+ function isMdxWhitespace(character) {
169
+ return /\s/u.test(character ?? "");
170
+ }
171
+ function openingTagAttributeOffset(content, start, end, isWhitespace) {
172
+ let state = "tagName";
173
+ let quote;
174
+ for (let index = start; index < end; index++) {
175
+ const character = content[index];
176
+ const closesTag = character === ">";
177
+ const selfClosesTag = character === "/" && content[index + 1] === ">";
178
+ if (state === "quotedAttributeValue") {
179
+ if (character === quote) {
180
+ quote = void 0;
181
+ state = "afterQuotedAttributeValue";
182
+ }
183
+ continue;
184
+ }
185
+ if (state === "unquotedAttributeValue") {
186
+ if (closesTag) return index;
187
+ if (isWhitespace(character)) state = "beforeAttributeName";
188
+ continue;
189
+ }
190
+ if (closesTag) return index;
191
+ if (selfClosesTag && state !== "beforeAttributeValue") {
192
+ let cursor = index;
193
+ while (cursor > start && isWhitespace(content[cursor - 1])) cursor--;
194
+ return cursor;
195
+ }
196
+ if (state === "tagName") {
197
+ if (isWhitespace(character)) state = "beforeAttributeName";
198
+ } else if (state === "beforeAttributeName") {
199
+ if (!isWhitespace(character)) state = "attributeName";
200
+ } else if (state === "attributeName") {
201
+ if (character === "=") state = "beforeAttributeValue";
202
+ else if (isWhitespace(character)) state = "afterAttributeName";
203
+ } else if (state === "afterAttributeName") {
204
+ if (character === "=") state = "beforeAttributeValue";
205
+ else if (!isWhitespace(character)) state = "attributeName";
206
+ } else if (state === "beforeAttributeValue") {
207
+ if (character === "\"" || character === "'") {
208
+ quote = character;
209
+ state = "quotedAttributeValue";
210
+ } else if (!isWhitespace(character)) state = "unquotedAttributeValue";
211
+ } else if (state === "afterQuotedAttributeValue" && isWhitespace(character)) state = "beforeAttributeName";
212
+ }
213
+ }
214
+ function rawHtmlHeadings(tree, content, lineOffset) {
215
+ let hasRawHtml = false;
216
+ visitHtml(tree, () => {
217
+ hasRawHtml = true;
218
+ });
219
+ if (!hasRawHtml) return [];
220
+ const markdownOffsets = /* @__PURE__ */ new Set();
221
+ visitHeadings(tree, (heading) => {
222
+ const offset = heading.position?.start.offset;
223
+ if (offset !== void 0) markdownOffsets.add(offset);
224
+ });
225
+ let hast;
226
+ const captureHast = () => (tree) => {
227
+ hast = tree;
228
+ };
229
+ createProcessor({
230
+ format: "md",
231
+ remarkRehypeOptions: { allowDangerousHtml: true },
232
+ rehypePlugins: [rehypeRaw, captureHast]
233
+ }).runSync(tree, { value: content });
234
+ if (!hast) return [];
235
+ const headings = [];
236
+ visitHastHeadings(hast, (heading) => {
237
+ const offset = heading.position?.start.offset;
238
+ const headingEnd = heading.position?.end.offset;
239
+ if (offset === void 0 || headingEnd === void 0 || markdownOffsets.has(offset)) return;
240
+ const tagEnd = openingTagAttributeOffset(content, offset, headingEnd, isHtmlWhitespace);
241
+ if (tagEnd === void 0) return;
242
+ const rawId = heading.properties?.id;
243
+ headings.push({
244
+ title: hastText$1(heading),
245
+ ...typeof rawId === "string" && rawId ? { id: rawId } : {},
246
+ position: {
247
+ line: (heading.position?.start.line ?? 1) + lineOffset,
248
+ column: heading.position?.start.column ?? 1
249
+ },
250
+ offset,
251
+ attributeOffset: tagEnd
252
+ });
253
+ });
254
+ return headings;
255
+ }
256
+ function staticMdxText(node) {
257
+ if (node.type === "text" || node.type === "inlineCode") return node.value ?? "";
258
+ if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
259
+ if (!node.name || !/^[a-z][a-z0-9-]*$/.test(node.name)) return void 0;
260
+ } else if (!node.children) return;
261
+ const parts = [];
262
+ for (const child of node.children ?? []) {
263
+ const text = staticMdxText(child);
264
+ if (text === void 0) return void 0;
265
+ parts.push(text);
266
+ }
267
+ return parts.join("");
268
+ }
269
+ function literalMdxId(node) {
270
+ let result = { kind: "missing" };
271
+ for (const attribute of node.attributes ?? []) {
272
+ if (attribute.type !== "mdxJsxAttribute") {
273
+ result = { kind: "unknown" };
274
+ continue;
275
+ }
276
+ if (attribute.name !== "id") continue;
277
+ result = typeof attribute.value === "string" ? {
278
+ kind: "literal",
279
+ id: attribute.value
280
+ } : { kind: "unknown" };
281
+ }
282
+ return result;
283
+ }
284
+ function mdxIntrinsicHeadings(tree, content, lineOffset) {
285
+ const headings = [];
286
+ visitMdxIntrinsicHeadings(tree, (heading) => {
287
+ const position = heading.position;
288
+ const offset = position?.start.offset;
289
+ const end = position?.end.offset;
290
+ const title = staticMdxText(heading);
291
+ const literalId = literalMdxId(heading);
292
+ if (!position || offset === void 0 || end === void 0 || literalId.kind !== "literal") return;
293
+ const tagEnd = openingTagAttributeOffset(content, offset, end, isMdxWhitespace);
294
+ if (tagEnd === void 0) return;
295
+ headings.push({
296
+ ...title !== void 0 ? { title } : {},
297
+ id: literalId.id,
298
+ position: {
299
+ line: position.start.line + lineOffset,
300
+ column: position.start.column
301
+ },
302
+ offset,
303
+ attributeOffset: tagEnd
304
+ });
305
+ });
306
+ return headings;
307
+ }
308
+ function escapeHtmlAttribute(value) {
309
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;");
310
+ }
311
+ function analyzeHeadings(content, options) {
312
+ const lineOffset = options.lineOffset ?? 0;
313
+ const headings = [];
314
+ const edits = [];
315
+ const errors = [];
316
+ const ids = /* @__PURE__ */ new Map();
317
+ const documentHeadings = [];
318
+ const markdownHeadingInputs = /* @__PURE__ */ new Map();
319
+ const tree = parseHeadingTree(content, options.kind);
320
+ if (!tree) return {
321
+ headings,
322
+ normalizedContent: content
323
+ };
324
+ const renderedTitles = renderedMarkdownHeadingTitles(tree, content, options.kind);
325
+ const claimId = (id, position) => {
326
+ const owner = ids.get(id);
327
+ if (owner) {
328
+ errors.push(`Heading ID "${id}" at ${formatPosition(position)} conflicts with the heading at ${formatPosition(owner.position)}.`);
329
+ return;
330
+ }
331
+ ids.set(id, {
332
+ id,
333
+ position
334
+ });
335
+ };
336
+ visitHeadings(tree, (node) => {
337
+ const marker = explicitIdInfo(node, content);
338
+ const title = renderedTitles.get(node.position?.start.offset ?? 0) ?? "";
339
+ const hasValidExplicitId = marker !== void 0 && HEADING_ID_PATTERN.test(marker.id);
340
+ const heading = {
341
+ level: node.depth ?? 1,
342
+ title,
343
+ id: hasValidExplicitId ? marker.id : "",
344
+ position: headingPosition(node, lineOffset)
345
+ };
346
+ if (marker && !hasValidExplicitId) errors.push(`Invalid heading ID "${marker.id}" at ${formatPosition(heading.position)}. IDs must match [a-z0-9]+(?:-[a-z0-9]+)*.`);
347
+ headings.push(heading);
348
+ markdownHeadingInputs.set(heading, {
349
+ ...marker ? { marker } : {},
350
+ hasValidExplicitId,
351
+ contentEnd: contentEndOffset(node)
352
+ });
353
+ documentHeadings.push({
354
+ title,
355
+ offset: node.position?.start.offset ?? 0,
356
+ markdown: heading
357
+ });
358
+ });
359
+ const renderedHeadings = options.kind === "markdown" ? rawHtmlHeadings(tree, content, lineOffset) : mdxIntrinsicHeadings(tree, content, lineOffset);
360
+ for (const rendered of renderedHeadings) documentHeadings.push({
361
+ ...rendered.title !== void 0 ? { title: rendered.title } : {},
362
+ offset: rendered.offset,
363
+ rendered
364
+ });
365
+ documentHeadings.sort((left, right) => left.offset - right.offset);
366
+ const slugger = new GithubSlugger();
367
+ for (const documentHeading of documentHeadings) {
368
+ if (documentHeading.markdown) {
369
+ const heading = documentHeading.markdown;
370
+ const input = markdownHeadingInputs.get(heading);
371
+ if (input.hasValidExplicitId) {
372
+ edits.push({
373
+ start: input.marker.start,
374
+ end: input.marker.end,
375
+ replacement: ` [](clarify-internal-heading-id:${heading.id})`
376
+ });
377
+ continue;
378
+ }
379
+ }
380
+ if (documentHeading.rendered?.id !== void 0) continue;
381
+ if (documentHeading.title === void 0) continue;
382
+ const generatedId = slugger.slug(documentHeading.title);
383
+ if (documentHeading.markdown) {
384
+ const heading = documentHeading.markdown;
385
+ const input = markdownHeadingInputs.get(heading);
386
+ heading.id = generatedId;
387
+ const link = ` [](clarify-internal-heading-id:${heading.id})`;
388
+ if (input.marker) edits.push({
389
+ start: input.marker.start,
390
+ end: input.marker.end,
391
+ replacement: link
392
+ });
393
+ else if (input.contentEnd !== void 0) edits.push({
394
+ start: input.contentEnd,
395
+ end: input.contentEnd,
396
+ replacement: link
397
+ });
398
+ } else if (documentHeading.rendered && documentHeading.rendered.id === void 0) {
399
+ documentHeading.rendered.id = generatedId;
400
+ edits.push({
401
+ start: documentHeading.rendered.attributeOffset,
402
+ end: documentHeading.rendered.attributeOffset,
403
+ replacement: ` id="${escapeHtmlAttribute(generatedId)}"`
404
+ });
405
+ }
406
+ }
407
+ for (const documentHeading of documentHeadings) if (documentHeading.markdown) claimId(documentHeading.markdown.id, documentHeading.markdown.position);
408
+ else if (documentHeading.rendered?.id) claimId(documentHeading.rendered.id, documentHeading.rendered.position);
409
+ const diagnostic = errors.length === 0 ? void 0 : createContentDiagnostic({
410
+ kind: options.kind,
411
+ title: "Heading ID error",
412
+ message: "This page has invalid or conflicting heading IDs. Fix the IDs below, then reload the route.",
413
+ error: new Error(errors.join("\n")),
414
+ filePath: options.filePath,
415
+ projectRoot: options.projectRoot
416
+ });
417
+ return {
418
+ headings,
419
+ normalizedContent: applySourceEdits(content, edits),
420
+ diagnostic
421
+ };
422
+ }
423
+ function remarkApplyHeadingIds() {
424
+ return (tree) => {
425
+ visitHeadings(tree, (heading) => {
426
+ const finalNode = heading.children?.at(-1);
427
+ if (finalNode?.type !== "link" || finalNode.children?.length !== 0 || typeof finalNode.url !== "string" || !finalNode.url.startsWith(INTERNAL_HEADING_ID_PREFIX)) return;
428
+ const id = finalNode.url.slice(28);
429
+ heading.children?.pop();
430
+ const previousNode = heading.children?.at(-1);
431
+ if (previousNode?.type === "text" && typeof previousNode.value === "string") previousNode.value = previousNode.value.replace(/ $/, "");
432
+ heading.data = heading.data ?? {};
433
+ heading.data.hProperties = {
434
+ ...heading.data.hProperties,
435
+ id
436
+ };
437
+ });
438
+ };
439
+ }
440
+ //#endregion
48
441
  //#region source/parsers/markdown/mdx.ts
49
442
  function getLanguage(className) {
50
443
  return (Array.isArray(className) ? className : typeof className === "string" ? className.split(/\s+/) : []).find((item) => typeof item === "string" && item.startsWith("language-"))?.replace(/^language-/, "") || "txt";
@@ -67,9 +460,34 @@ function rehypeParseCodeBlocks() {
67
460
  });
68
461
  };
69
462
  }
70
- var remarkPlugins = markdownRemarkPlugins;
463
+ function hastText(node) {
464
+ if (node.type === "text") return node.value ?? "";
465
+ return (node.children ?? []).map(hastText).join("");
466
+ }
467
+ function isHeadingElement(node) {
468
+ return Boolean(node.tagName && /^h[1-6]$/.test(node.tagName));
469
+ }
470
+ function rehypeAddMissingHeadingIds() {
471
+ return (tree) => {
472
+ const slugger = new GithubSlugger();
473
+ const reservedIds = /* @__PURE__ */ new Set();
474
+ visit(tree, "element", (node) => {
475
+ if (isHeadingElement(node) && typeof node.properties?.id === "string" && node.properties.id) reservedIds.add(node.properties.id);
476
+ });
477
+ visit(tree, "element", (node) => {
478
+ if (!isHeadingElement(node)) return;
479
+ const title = hastText(node);
480
+ let fallbackId = slugger.slug(title);
481
+ if (node.properties?.id) return;
482
+ while (reservedIds.has(fallbackId)) fallbackId = slugger.slug(title);
483
+ node.properties = node.properties ?? {};
484
+ node.properties.id = fallbackId;
485
+ });
486
+ };
487
+ }
488
+ var remarkPlugins = [remarkApplyHeadingIds, ...markdownRemarkPlugins];
71
489
  var rehypePlugins = [
72
- rehypeSlug,
490
+ rehypeAddMissingHeadingIds,
73
491
  rehypeParseCodeBlocks,
74
492
  [rehypeShiki, {
75
493
  theme: cssVariablesTheme,
@@ -224,9 +642,12 @@ async function runBuildDoneHooks(plugins, ctx) {
224
642
  */
225
643
  function parseFrontmatter(content) {
226
644
  const parsed = matter(content);
645
+ const body = parsed.content.replace(/^\r?\n/, "");
646
+ const prefix = content.slice(0, content.length - body.length);
227
647
  return {
228
648
  frontmatter: parsed.data,
229
- content: parsed.content.replace(/^\r?\n/, "")
649
+ content: body,
650
+ lineOffset: [...prefix.matchAll(/\r\n|\r|\n/g)].length
230
651
  };
231
652
  }
232
653
  //#endregion
@@ -237,7 +658,7 @@ async function transformContent(transform, input) {
237
658
  function createContentProcessor(transform) {
238
659
  return {
239
660
  async processMdx(source, filePath) {
240
- const { frontmatter, content } = parseFrontmatter(source);
661
+ const { frontmatter, content, lineOffset } = parseFrontmatter(source);
241
662
  const transformed = await transformContent(transform, {
242
663
  kind: "markdown+jsx",
243
664
  source,
@@ -247,7 +668,8 @@ function createContentProcessor(transform) {
247
668
  });
248
669
  return {
249
670
  frontmatter: transformed.frontmatter,
250
- content: transformed.content
671
+ content: transformed.content,
672
+ lineOffset
251
673
  };
252
674
  },
253
675
  async processText(source, kind, filePath) {
@@ -357,13 +779,47 @@ function resolveUpdatedAt(frontmatter, detectedAt) {
357
779
  return normalizeDate(typeof frontmatter.updatedAt === "string" ? frontmatter.updatedAt : void 0) ?? detectedAt;
358
780
  }
359
781
  //#endregion
360
- //#region source/parsers/routes/routes.ts
782
+ //#region source/parsers/routes/heading-metadata.ts
361
783
  function kebabToTitle(str) {
362
- return str.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
363
- }
364
- function parseMdxTree(content) {
365
- return remark.parse(content);
784
+ return str.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
785
+ }
786
+ function headingMetadata(analysis) {
787
+ return { sections: analysis.headings.filter((heading) => heading.level === 2 || heading.level === 3).map((heading) => ({
788
+ id: heading.id,
789
+ title: heading.title,
790
+ level: heading.level
791
+ })) };
792
+ }
793
+ function resolvedRouteTitle(route, analysis) {
794
+ const frontmatterTitle = route.source.frontmatter?.title;
795
+ if (typeof frontmatterTitle === "string" && frontmatterTitle) return frontmatterTitle;
796
+ const routePath = route.basePath ?? route.path;
797
+ return kebabToTitle(routePath === "/" ? analysis.headings.find((heading) => heading.level === 1)?.title ?? "" : routePath.split("/").filter(Boolean).at(-1) ?? "") || "Untitled";
798
+ }
799
+ async function reanalyzeMarkdownRoute(route, projectRoot) {
800
+ if (route.kind !== "markdown" && route.kind !== "markdown+jsx") return route;
801
+ const analysis = analyzeHeadings(route.source.content ?? "", {
802
+ kind: route.kind,
803
+ filePath: route.source.filePath,
804
+ projectRoot,
805
+ lineOffset: route.source.lineOffset
806
+ });
807
+ const result = await (route.kind === "markdown" ? compileMarkdownContent : compileMdxContent)(analysis.normalizedContent, {
808
+ filePath: route.source.filePath,
809
+ projectRoot
810
+ });
811
+ return {
812
+ ...route,
813
+ meta: {
814
+ ...route.meta,
815
+ title: resolvedRouteTitle(route, analysis),
816
+ ...headingMetadata(analysis)
817
+ },
818
+ diagnostic: analysis.diagnostic ?? (result.ok ? void 0 : result.diagnostic)
819
+ };
366
820
  }
821
+ //#endregion
822
+ //#region source/parsers/routes/routes.ts
367
823
  async function compileRouteDiagnostic(content, filePath, baseDir) {
368
824
  const result = await (extname(filePath).toLowerCase() === ".md" ? compileMarkdownContent : compileMdxContent)(content, {
369
825
  filePath,
@@ -371,30 +827,6 @@ async function compileRouteDiagnostic(content, filePath, baseDir) {
371
827
  });
372
828
  return result.ok ? void 0 : result.diagnostic;
373
829
  }
374
- /** 从内容中提取第一个 H1 标题 */
375
- function extractH1(content) {
376
- const tree = parseMdxTree(content);
377
- let title = "";
378
- visit(tree, "heading", (node) => {
379
- if (!title && node.depth === 1) title = toString(node);
380
- });
381
- return title;
382
- }
383
- /** 从 MDX/Markdown 内容中提取 H2/H3 章节 */
384
- function extractMdxSections(content) {
385
- const sections = [];
386
- const slugger = new GithubSlugger();
387
- visit(parseMdxTree(content), "heading", (node) => {
388
- if (node.depth !== 2 && node.depth !== 3) return;
389
- const title = toString(node);
390
- sections.push({
391
- id: slugger.slug(title),
392
- title,
393
- level: node.depth
394
- });
395
- });
396
- return sections;
397
- }
398
830
  function navigationSections(sections) {
399
831
  return sections.map((section) => ({
400
832
  id: section.id,
@@ -451,11 +883,12 @@ async function findContentRoutes(dir, base = dir, options = {}) {
451
883
  const fullPath = join(dir, entry.name);
452
884
  if (entry.isDirectory()) routes.push(...await findContentRoutes(fullPath, base, options));
453
885
  else if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
886
+ const kind = entry.name.toLowerCase().endsWith(".mdx") ? "markdown+jsx" : "markdown";
454
887
  const relativePath = relative(base, fullPath);
455
888
  const pathParts = relativePath.replace(/\.mdx?$/, "").split("/");
456
889
  const cleanPath = ("/" + pathParts.map((p) => p === "index" ? "" : p).filter(Boolean).join("/")).replace(/\/+/g, "/").replace(/\/$/, "") || "/";
457
890
  const source = await readFile(fullPath, "utf-8");
458
- const { frontmatter, content } = await (options.contentProcessor ?? createContentProcessor()).processMdx(source, fullPath);
891
+ const { frontmatter, content, lineOffset } = await (options.contentProcessor ?? createContentProcessor()).processMdx(source, fullPath);
459
892
  const updatedAt = resolveUpdatedAt(frontmatter, await getFileUpdatedAt(fullPath, base));
460
893
  const page = {
461
894
  path: cleanPath,
@@ -463,14 +896,21 @@ async function findContentRoutes(dir, base = dir, options = {}) {
463
896
  frontmatter,
464
897
  content
465
898
  };
466
- const diagnostic = await compileRouteDiagnostic(page.content, fullPath, base);
899
+ const analysis = analyzeHeadings(page.content, {
900
+ kind,
901
+ filePath: fullPath,
902
+ projectRoot: base,
903
+ lineOffset
904
+ });
905
+ const compileDiagnostic = await compileRouteDiagnostic(analysis.normalizedContent, fullPath, base);
906
+ const { sections } = headingMetadata(analysis);
467
907
  let title = typeof page.frontmatter.title === "string" ? page.frontmatter.title : "";
468
908
  if (!title) {
469
909
  const lastPart = pathParts[pathParts.length - 1] ?? "";
470
- title = kebabToTitle(lastPart === "index" ? pathParts.length >= 2 ? pathParts[pathParts.length - 2] : extractH1(page.content) : lastPart) || "Untitled";
910
+ title = kebabToTitle(lastPart === "index" ? pathParts.length >= 2 ? pathParts[pathParts.length - 2] : analysis.headings.find((heading) => heading.level === 1)?.title ?? "" : lastPart) || "Untitled";
471
911
  }
472
912
  routes.push({
473
- kind: entry.name.toLowerCase().endsWith(".mdx") ? "markdown+jsx" : "markdown",
913
+ kind,
474
914
  path: cleanPath,
475
915
  basePath: cleanPath,
476
916
  meta: {
@@ -480,7 +920,7 @@ async function findContentRoutes(dir, base = dir, options = {}) {
480
920
  group: typeof page.frontmatter.group === "string" ? page.frontmatter.group : void 0,
481
921
  layout: page.frontmatter.layout === "documentation" || page.frontmatter.layout === "blog" ? page.frontmatter.layout : void 0,
482
922
  updatedAt,
483
- sections: extractMdxSections(page.content)
923
+ sections
484
924
  },
485
925
  module: {
486
926
  pageVirtualModuleId: pageVirtualModuleId(relativePath.replace(/\.mdx?$/, "")),
@@ -489,9 +929,10 @@ async function findContentRoutes(dir, base = dir, options = {}) {
489
929
  source: {
490
930
  filePath: fullPath,
491
931
  frontmatter: page.frontmatter,
492
- content: page.content
932
+ content: page.content,
933
+ lineOffset
493
934
  },
494
- diagnostic
935
+ diagnostic: analysis.diagnostic ?? compileDiagnostic
495
936
  });
496
937
  }
497
938
  }
@@ -647,6 +1088,7 @@ function routeIntentFromPagesItem(item) {
647
1088
  kind: "openapi",
648
1089
  ref: item.openapi,
649
1090
  tagFilter: item.filter?.tags,
1091
+ operationOrder: item.operationOrder,
650
1092
  path: item.path,
651
1093
  title: item.title,
652
1094
  icon: item.icon
@@ -1113,6 +1555,7 @@ var clarifyPagesItemSchema = z.lazy(() => z.union([
1113
1555
  z.object({
1114
1556
  openapi: z.string(),
1115
1557
  path: z.string().optional(),
1558
+ operationOrder: z.array(z.string()).optional(),
1116
1559
  icon: z.string().optional(),
1117
1560
  title: clarifyLocalizedTextSchema.optional(),
1118
1561
  filter: z.object({ tags: z.array(z.string()).optional() }).strict().optional()
@@ -1595,10 +2038,14 @@ function createNormalizedContentPlugin(engine) {
1595
2038
  transform(_code, id) {
1596
2039
  if (!/\.mdx?(?:\?|$)/.test(id)) return null;
1597
2040
  const filePath = id.replace(/\?.*$/, "");
1598
- const route = engine.routes.find((route) => (route.kind === "markdown+jsx" || route.kind === "markdown") && route.source.filePath === filePath);
1599
- if (!route || route.source.content === void 0) return null;
2041
+ const route = engine.routes.find((route) => route.source.filePath === filePath);
2042
+ if (!route || route.kind !== "markdown+jsx" && route.kind !== "markdown" || route.source.content === void 0) return null;
1600
2043
  return {
1601
- code: route.source.content,
2044
+ code: analyzeHeadings(route.source.content, {
2045
+ kind: route.kind,
2046
+ filePath,
2047
+ projectRoot: engine.root
2048
+ }).normalizedContent,
1602
2049
  map: null
1603
2050
  };
1604
2051
  }
@@ -1788,7 +2235,7 @@ function createViteAdapter(engine) {
1788
2235
  }
1789
2236
  var cliPackageVersion = {
1790
2237
  name: "@clarify-labs/cli",
1791
- version: "0.11.18",
2238
+ version: "0.11.20",
1792
2239
  license: "AGPL-3.0-only",
1793
2240
  type: "module",
1794
2241
  repository: {
@@ -1991,6 +2438,191 @@ function createLlmsTxtArtifact(routes, projectConfig) {
1991
2438
  return withUtf8Signature(createLlmsTxt(routes, projectConfig));
1992
2439
  }
1993
2440
  //#endregion
2441
+ //#region source/plugins/openapi/aggregation.ts
2442
+ function mergeOpenAPIRecord(target, source, section, filePath) {
2443
+ if (!source) return;
2444
+ for (const [key, value] of Object.entries(source)) {
2445
+ const existing = target[key];
2446
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(value)) throw new Error(`[clarify] Cannot aggregate OpenAPI specs: conflicting ${section} entry "${key}" in ${filePath}.`);
2447
+ target[key] = value;
2448
+ }
2449
+ }
2450
+ function openAPIVersionFamily(version) {
2451
+ return version.split(".").slice(0, 2).join(".");
2452
+ }
2453
+ var OPENAPI_OPERATION_METHODS = /* @__PURE__ */ new Set([
2454
+ "get",
2455
+ "put",
2456
+ "post",
2457
+ "delete",
2458
+ "options",
2459
+ "head",
2460
+ "patch",
2461
+ "trace"
2462
+ ]);
2463
+ function sourceComponentPrefix(source) {
2464
+ return (source.id || source.filePath.split(/[\\/]/).pop()?.replace(/\.openapi\.(?:json|ya?ml)$/i, "") || "service").replace(/[^a-zA-Z0-9._-]/g, "_") || "service";
2465
+ }
2466
+ function securitySchemeNameMap(source, sourceSchemes, targetSchemes) {
2467
+ const names = /* @__PURE__ */ new Map();
2468
+ for (const [name, definition] of Object.entries(sourceSchemes)) {
2469
+ const existing = targetSchemes[name];
2470
+ if (existing === void 0 || JSON.stringify(existing) === JSON.stringify(definition)) {
2471
+ targetSchemes[name] = definition;
2472
+ names.set(name, name);
2473
+ continue;
2474
+ }
2475
+ const prefix = sourceComponentPrefix(source);
2476
+ let candidate = `${prefix}__${name}`;
2477
+ let suffix = 2;
2478
+ while (targetSchemes[candidate] !== void 0 && JSON.stringify(targetSchemes[candidate]) !== JSON.stringify(definition)) candidate = `${prefix}__${name}_${suffix++}`;
2479
+ targetSchemes[candidate] = definition;
2480
+ names.set(name, candidate);
2481
+ }
2482
+ return names;
2483
+ }
2484
+ function rewriteSecurityRequirements(value, schemeNames) {
2485
+ if (!Array.isArray(value)) return value;
2486
+ return value.map((requirement) => {
2487
+ if (!requirement || typeof requirement !== "object" || Array.isArray(requirement)) return requirement;
2488
+ return Object.fromEntries(Object.entries(requirement).map(([name, scopes]) => [schemeNames.get(name) ?? name, scopes]));
2489
+ });
2490
+ }
2491
+ function localizePathItemContext(pathItemValue, documentServers, documentSecurity, hasDocumentSecurity, schemeNames) {
2492
+ if (!pathItemValue || typeof pathItemValue !== "object" || Array.isArray(pathItemValue)) return pathItemValue;
2493
+ const pathItem = structuredClone(pathItemValue);
2494
+ const pathServers = Object.hasOwn(pathItem, "servers") ? pathItem.servers : documentServers;
2495
+ for (const [method, operationValue] of Object.entries(pathItem)) {
2496
+ if (!OPENAPI_OPERATION_METHODS.has(method) || !operationValue || typeof operationValue !== "object" || Array.isArray(operationValue)) continue;
2497
+ const operation = operationValue;
2498
+ if (!Object.hasOwn(operation, "servers") && pathServers !== void 0) operation.servers = structuredClone(pathServers);
2499
+ if (Object.hasOwn(operation, "security")) operation.security = rewriteSecurityRequirements(operation.security, schemeNames);
2500
+ else if (hasDocumentSecurity) operation.security = rewriteSecurityRequirements(structuredClone(documentSecurity), schemeNames);
2501
+ }
2502
+ delete pathItem.servers;
2503
+ return pathItem;
2504
+ }
2505
+ function localizeOpenAPIContexts(spec, schemeNames) {
2506
+ const document = spec;
2507
+ const hasDocumentSecurity = Object.hasOwn(document, "security");
2508
+ const localizeEntries = (entries) => entries && Object.fromEntries(Object.entries(entries).map(([name, pathItem]) => [name, localizePathItemContext(pathItem, document.servers, document.security, hasDocumentSecurity, schemeNames)]));
2509
+ return {
2510
+ paths: localizeEntries(spec.paths) ?? {},
2511
+ webhooks: localizeEntries("webhooks" in spec ? spec.webhooks : void 0)
2512
+ };
2513
+ }
2514
+ function aggregateOpenAPISources(sources, info) {
2515
+ const paths = {};
2516
+ const webhooks = {};
2517
+ const components = {};
2518
+ const tags = /* @__PURE__ */ new Map();
2519
+ let openapiVersion;
2520
+ for (const source of sources) {
2521
+ const { document: spec, filePath } = source;
2522
+ if (openapiVersion && openAPIVersionFamily(openapiVersion) !== openAPIVersionFamily(spec.openapi)) throw new Error(`[clarify] Cannot aggregate OpenAPI specs: incompatible versions "${openapiVersion}" and "${spec.openapi}" in ${filePath}.`);
2523
+ openapiVersion ??= spec.openapi;
2524
+ components.securitySchemes ??= {};
2525
+ const sourceComponents = spec.components;
2526
+ const localized = localizeOpenAPIContexts(spec, securitySchemeNameMap(source, sourceComponents?.securitySchemes ?? {}, components.securitySchemes));
2527
+ mergeOpenAPIRecord(paths, localized.paths, "paths", filePath);
2528
+ mergeOpenAPIRecord(webhooks, localized.webhooks, "webhooks", filePath);
2529
+ for (const [section, entries] of Object.entries(sourceComponents ?? {})) {
2530
+ if (section === "securitySchemes") continue;
2531
+ components[section] ??= {};
2532
+ mergeOpenAPIRecord(components[section], entries, `components.${section}`, filePath);
2533
+ }
2534
+ for (const tag of spec.tags ?? []) {
2535
+ const existing = tags.get(tag.name);
2536
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(tag)) throw new Error(`[clarify] Cannot aggregate OpenAPI specs: conflicting tag "${tag.name}" in ${filePath}.`);
2537
+ tags.set(tag.name, tag);
2538
+ }
2539
+ }
2540
+ if (components.securitySchemes && Object.keys(components.securitySchemes).length === 0) delete components.securitySchemes;
2541
+ return {
2542
+ openapi: openapiVersion ?? "3.1.0",
2543
+ info: {
2544
+ title: info.title,
2545
+ description: info.description,
2546
+ version: info.version ?? "1.0.0"
2547
+ },
2548
+ paths,
2549
+ ...Object.keys(webhooks).length > 0 ? { webhooks } : {},
2550
+ ...Object.keys(components).length > 0 ? { components } : {},
2551
+ ...tags.size > 0 ? { tags: [...tags.values()] } : {}
2552
+ };
2553
+ }
2554
+ //#endregion
2555
+ //#region source/plugins/openapi/artifacts.ts
2556
+ function selectRootOpenAPISources(routes, projectConfig) {
2557
+ const defaultLocale = projectConfig.locales?.default;
2558
+ const sourcesByFilePath = /* @__PURE__ */ new Map();
2559
+ for (const route of routes) {
2560
+ if (route.kind !== "openapi" || route.diagnostic || !route.source.content || route.openapi?.tagFilter?.length || route.isBareAlias || defaultLocale && route.locale && route.locale !== defaultLocale) continue;
2561
+ sourcesByFilePath.set(route.source.filePath, {
2562
+ id: route.openapi?.sourceSpecId ?? route.source.filePath.split(/[\\/]/).pop()?.replace(/\.openapi\.(?:json|ya?ml)$/i, "") ?? "service",
2563
+ filePath: route.source.filePath,
2564
+ document: JSON.parse(route.source.content)
2565
+ });
2566
+ }
2567
+ return [...sourcesByFilePath.values()];
2568
+ }
2569
+ function createRootOpenAPISpec(routes, projectConfig) {
2570
+ return aggregateOpenAPISources(selectRootOpenAPISources(routes, projectConfig), {
2571
+ title: projectConfig.title,
2572
+ description: projectConfig.description
2573
+ });
2574
+ }
2575
+ function createOpenAPIRouteArtifacts(route) {
2576
+ if (route.kind !== "openapi" || !route.source.content) return [];
2577
+ const contentArtifactUrl = route.artifacts?.contentArtifactUrl;
2578
+ if (!contentArtifactUrl) return [];
2579
+ return [{
2580
+ fileName: contentArtifactUrl.replace(/^\//, ""),
2581
+ contentType: "application/json; charset=utf-8",
2582
+ source: route.source.content
2583
+ }, {
2584
+ fileName: contentArtifactUrl.replace(/\.json$/, ".yaml").replace(/^\//, ""),
2585
+ contentType: "text/yaml; charset=utf-8",
2586
+ source: stringify(JSON.parse(route.source.content), { lineWidth: 0 })
2587
+ }];
2588
+ }
2589
+ function createRootOpenAPIArtifact(routes, projectConfig) {
2590
+ return {
2591
+ fileName: "openapi.json",
2592
+ contentType: "application/json; charset=utf-8",
2593
+ source: JSON.stringify(createRootOpenAPISpec(routes, projectConfig), null, 2)
2594
+ };
2595
+ }
2596
+ //#endregion
2597
+ //#region source/plugins/content-artifacts/provider.ts
2598
+ function createContentArtifacts(routes, projectConfig) {
2599
+ const artifacts = [];
2600
+ for (const route of routes) {
2601
+ if (route.kind === "openapi") {
2602
+ artifacts.push(...createOpenAPIRouteArtifacts(route));
2603
+ continue;
2604
+ }
2605
+ const contentArtifactUrl = route.artifacts?.contentArtifactUrl;
2606
+ if (!contentArtifactUrl) continue;
2607
+ artifacts.push({
2608
+ fileName: contentArtifactUrl.replace(/^\//, ""),
2609
+ contentType: "text/markdown; charset=utf-8",
2610
+ source: readRouteArtifactContent(route)
2611
+ });
2612
+ }
2613
+ artifacts.push({
2614
+ fileName: "llms.txt",
2615
+ contentType: "text/plain; charset=utf-8",
2616
+ source: createLlmsTxtArtifact(routes, projectConfig)
2617
+ });
2618
+ artifacts.push(createRootOpenAPIArtifact(routes, projectConfig));
2619
+ return artifacts;
2620
+ }
2621
+ function findContentArtifact(path, routes, projectConfig) {
2622
+ const fileName = path.replace(/^\/+/, "");
2623
+ return createContentArtifacts(routes, projectConfig).find((artifact) => artifact.fileName === fileName);
2624
+ }
2625
+ //#endregion
1994
2626
  //#region source/plugins/content-artifacts/server.ts
1995
2627
  function normalizeRoutePrefix$1(routePrefix) {
1996
2628
  if (!routePrefix || routePrefix === "/") return "";
@@ -2002,34 +2634,12 @@ function resolveContentArtifactPath(url, projectConfig) {
2002
2634
  const hasBasePath = requestPath === basePath || requestPath.startsWith(`${basePath}/`);
2003
2635
  return basePath && hasBasePath ? requestPath.slice(basePath.length) || "/" : requestPath;
2004
2636
  }
2005
- function resolveContentArtifactType(route) {
2006
- return route.kind === "openapi" ? "application/json; charset=utf-8" : "text/markdown; charset=utf-8";
2007
- }
2008
2637
  function serveContentArtifacts(req, res, projectConfig, routes) {
2009
- const contentPath = resolveContentArtifactPath(req.url, projectConfig);
2010
- if (contentPath === "/llms.txt") {
2011
- res.statusCode = 200;
2012
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2013
- res.end(createLlmsTxtArtifact(routes, projectConfig), "utf8");
2014
- return true;
2015
- }
2016
- if (contentPath.endsWith(".openapi.yaml")) {
2017
- const jsonPath = contentPath.replace(/\.yaml$/, ".json");
2018
- const route = routes.find((route) => route.artifacts?.contentArtifactUrl === jsonPath);
2019
- if (route?.kind === "openapi" && route.source.content) {
2020
- const spec = JSON.parse(route.source.content);
2021
- res.statusCode = 200;
2022
- res.setHeader("Content-Type", "text/yaml; charset=utf-8");
2023
- res.end(stringify(spec, { lineWidth: 0 }), "utf8");
2024
- return true;
2025
- }
2026
- return false;
2027
- }
2028
- const route = routes.find((route) => route.artifacts?.contentArtifactUrl === contentPath);
2029
- if (!route) return false;
2638
+ const artifact = findContentArtifact(resolveContentArtifactPath(req.url, projectConfig), routes, projectConfig);
2639
+ if (!artifact) return false;
2030
2640
  res.statusCode = 200;
2031
- res.setHeader("Content-Type", resolveContentArtifactType(route));
2032
- res.end(readRouteArtifactContent(route), "utf8");
2641
+ res.setHeader("Content-Type", artifact.contentType);
2642
+ res.end(artifact.source);
2033
2643
  return true;
2034
2644
  }
2035
2645
  //#endregion
@@ -2050,30 +2660,7 @@ function createContentArtifactsPlugin() {
2050
2660
  });
2051
2661
  };
2052
2662
  },
2053
- "build:assets": (ctx) => {
2054
- const assets = [];
2055
- for (const route of ctx.routes) {
2056
- const contentArtifactUrl = route.artifacts?.contentArtifactUrl;
2057
- if (!contentArtifactUrl) continue;
2058
- assets.push({
2059
- fileName: contentArtifactUrl.replace(/^\//, ""),
2060
- source: readRouteArtifactContent(route)
2061
- });
2062
- if (route.kind === "openapi" && route.source.content) {
2063
- const yamlFileName = contentArtifactUrl.replace(/\.json$/, ".yaml");
2064
- const spec = JSON.parse(route.source.content);
2065
- assets.push({
2066
- fileName: yamlFileName.replace(/^\//, ""),
2067
- source: stringify(spec, { lineWidth: 0 })
2068
- });
2069
- }
2070
- }
2071
- assets.push({
2072
- fileName: "llms.txt",
2073
- source: createLlmsTxtArtifact(ctx.routes, ctx.projectConfig)
2074
- });
2075
- return assets;
2076
- }
2663
+ "build:assets": (ctx) => createContentArtifacts(ctx.routes, ctx.projectConfig)
2077
2664
  }
2078
2665
  };
2079
2666
  }
@@ -2597,9 +3184,9 @@ function filterSpecByTags(spec, tags) {
2597
3184
  };
2598
3185
  }
2599
3186
  /** Extract endpoint operations from an OpenAPI spec as page sections. */
2600
- function extractOpenAPISections(spec, filterTags) {
3187
+ function extractOpenAPISections(spec, filterTags, operationOrder) {
2601
3188
  normalizeOpenAPISpecSectionIds(spec);
2602
- const sections = [];
3189
+ const entries = [];
2603
3190
  const items = [...Object.entries(spec.paths ?? {}).map(([path, pathItem]) => ({
2604
3191
  path,
2605
3192
  pathItem,
@@ -2617,16 +3204,25 @@ function extractOpenAPISections(spec, filterTags) {
2617
3204
  const tags = Array.isArray(op?.tags) ? op.tags : void 0;
2618
3205
  if (!op || !operationMatchesTags(tags, filterTags)) continue;
2619
3206
  const title = typeof op.summary === "string" ? op.summary : `${method.toUpperCase()} ${path}`;
2620
- sections.push({
2621
- id: operationSectionId(op),
2622
- title,
2623
- level: 2,
2624
- badge: kind === "webhook" ? "WEBHOOK" : method.toUpperCase(),
2625
- tags
3207
+ entries.push({
3208
+ section: {
3209
+ id: operationSectionId(op),
3210
+ title,
3211
+ level: 2,
3212
+ badge: kind === "webhook" ? "WEBHOOK" : method.toUpperCase(),
3213
+ tags
3214
+ },
3215
+ operationId: typeof op.operationId === "string" ? op.operationId : void 0
2626
3216
  });
2627
3217
  }
2628
3218
  }
2629
- return sections;
3219
+ if (!operationOrder?.length) return entries.map(({ section }) => section);
3220
+ const orderById = new Map(operationOrder.map((operationId, index) => [operationId, index]));
3221
+ return entries.map((entry, index) => ({
3222
+ ...entry,
3223
+ index,
3224
+ order: entry.operationId ? orderById.get(entry.operationId) : void 0
3225
+ })).sort((left, right) => (left.order ?? operationOrder.length) - (right.order ?? operationOrder.length) || left.index - right.index).map(({ section }) => section);
2630
3226
  }
2631
3227
  async function readOpenAPISpec(filePath, contentProcessor, projectRoot) {
2632
3228
  try {
@@ -2673,7 +3269,7 @@ function collectOpenAPIPageIntents(config) {
2673
3269
  continue;
2674
3270
  }
2675
3271
  const intent = routeIntentFromPagesItem(item);
2676
- if (intent.kind === "openapi" && (intent.path || intent.tagFilter?.length)) intents.push(intent);
3272
+ if (intent.kind === "openapi" && (intent.path || intent.tagFilter?.length || intent.operationOrder?.length)) intents.push(intent);
2677
3273
  }
2678
3274
  }
2679
3275
  function visitPages(pages) {
@@ -2694,7 +3290,8 @@ function cloneOpenAPIRoute(route, overrides = {}) {
2694
3290
  source: { ...route.source },
2695
3291
  openapi: route.openapi ? {
2696
3292
  ...route.openapi,
2697
- tagFilter: route.openapi.tagFilter ? [...route.openapi.tagFilter] : void 0
3293
+ tagFilter: route.openapi.tagFilter ? [...route.openapi.tagFilter] : void 0,
3294
+ operationOrder: route.openapi.operationOrder ? [...route.openapi.operationOrder] : void 0
2698
3295
  } : void 0,
2699
3296
  alternates: route.alternates ? { ...route.alternates } : void 0,
2700
3297
  ...overrides
@@ -2743,9 +3340,21 @@ function expandConfiguredOpenAPIRoutes(routes, config) {
2743
3340
  const existingKeys = new Set(routes.map((route) => `${route.locale ?? ""}:${route.basePath ?? route.path}`));
2744
3341
  for (const intent of openAPIIntents) {
2745
3342
  const tagFilter = intent.tagFilter?.filter(Boolean);
3343
+ const operationOrder = intent.operationOrder?.filter(Boolean);
2746
3344
  const sourceBasePath = openAPIPagePathFromRef(intent.ref);
2747
3345
  const targetBasePath = openAPIPagePathFromRef(intent.ref, tagFilter, intent.path);
2748
- if (sourceBasePath === targetBasePath && !tagFilter?.length) continue;
3346
+ if (sourceBasePath === targetBasePath && !tagFilter?.length) {
3347
+ if (!operationOrder?.length) continue;
3348
+ for (let index = 0; index < routes.length; index++) {
3349
+ const route = routes[index];
3350
+ if (route.kind !== "openapi" || (route.basePath ?? route.path) !== sourceBasePath) continue;
3351
+ routes[index] = cloneOpenAPIRoute(route, { openapi: {
3352
+ ...route.openapi,
3353
+ operationOrder: [...operationOrder]
3354
+ } });
3355
+ }
3356
+ continue;
3357
+ }
2749
3358
  for (const route of routes) {
2750
3359
  if (route.kind !== "openapi" || (route.basePath ?? route.path) !== sourceBasePath) continue;
2751
3360
  const key = `${route.locale ?? ""}:${targetBasePath}`;
@@ -2758,7 +3367,8 @@ function expandConfiguredOpenAPIRoutes(routes, config) {
2758
3367
  module: { pageVirtualModuleId: pageVirtualModuleId(path) },
2759
3368
  openapi: {
2760
3369
  ...route.openapi,
2761
- tagFilter: tagFilter?.length ? tagFilter : void 0
3370
+ tagFilter: tagFilter?.length ? tagFilter : void 0,
3371
+ operationOrder: operationOrder?.length ? [...operationOrder] : void 0
2762
3372
  }
2763
3373
  }));
2764
3374
  }
@@ -2800,10 +3410,11 @@ function generateOpenAPISpecModule(spec) {
2800
3410
  return `export default ${JSON.stringify(spec)};`;
2801
3411
  }
2802
3412
  function generateOpenAPIPageModule(opts) {
3413
+ const routeData = opts.operationOrder?.length ? { operationOrder: opts.operationOrder } : {};
2803
3414
  return `import { createOpenApiRouteComponent } from '@clarify-labs/renderer';
2804
3415
  import spec from ${JSON.stringify(opts.specModuleId)};
2805
3416
 
2806
- export const routeData = {};
3417
+ export const routeData = ${JSON.stringify(routeData)};
2807
3418
 
2808
3419
  export default createOpenApiRouteComponent({ ...routeData, spec });
2809
3420
  `;
@@ -2863,7 +3474,7 @@ var OpenAPIPluginState = class {
2863
3474
  route.meta.title = spec.info?.title ?? route.meta.title;
2864
3475
  route.meta.description = spec.info?.description ?? route.meta.description;
2865
3476
  route.meta.updatedAt = updatedAt;
2866
- route.meta.sections = extractOpenAPISections(spec, route.openapi.tagFilter);
3477
+ route.meta.sections = extractOpenAPISections(spec, route.openapi.tagFilter, route.openapi.operationOrder);
2867
3478
  const pageSpec = route.openapi.tagFilter?.length ? filterSpecByTags(specWithMetadata, route.openapi.tagFilter) : specWithMetadata;
2868
3479
  const routeSpecModuleId = routeSpecModuleIdFromSourceSpecId(sourceSpecId, route.openapi.tagFilter);
2869
3480
  route.openapi.routeSpecModuleId = routeSpecModuleId;
@@ -2897,7 +3508,10 @@ var OpenAPIPluginState = class {
2897
3508
  if (!route.openapi?.sourceSpecId) continue;
2898
3509
  const routeSpecModuleId = route.openapi.routeSpecModuleId ?? route.openapi.sourceSpecId;
2899
3510
  if (!this.specModules.has(routeSpecModuleId)) continue;
2900
- modules.set(route.module.pageVirtualModuleId, generateOpenAPIPageModule({ specModuleId: specVirtualModuleId(routeSpecModuleId) }));
3511
+ modules.set(route.module.pageVirtualModuleId, generateOpenAPIPageModule({
3512
+ specModuleId: specVirtualModuleId(routeSpecModuleId),
3513
+ operationOrder: route.openapi.operationOrder
3514
+ }));
2901
3515
  }
2902
3516
  return modules;
2903
3517
  }
@@ -3387,7 +4001,7 @@ async function resolveRoutePages(routes, plugins, ctx) {
3387
4001
  pageByRouteIndex.set(routeIndex, page);
3388
4002
  }
3389
4003
  if (pageByRouteIndex.size !== routes.length) throw new Error("[clarify] pages:resolved hooks must return exactly one page for each route");
3390
- return routes.map((route, routeIndex) => {
4004
+ const updatedRoutes = routes.map((route, routeIndex) => {
3391
4005
  const page = pageByRouteIndex.get(routeIndex);
3392
4006
  return {
3393
4007
  ...route,
@@ -3398,6 +4012,7 @@ async function resolveRoutePages(routes, plugins, ctx) {
3398
4012
  }
3399
4013
  };
3400
4014
  });
4015
+ return Promise.all(updatedRoutes.map((route) => reanalyzeMarkdownRoute(route, ctx.projectRoot)));
3401
4016
  }
3402
4017
  function buildNavigationTree(routes, ctx) {
3403
4018
  const tabs = ctx.projectConfig.navigation?.tabs;
@@ -3995,8 +4610,9 @@ var ClarifyEngine = class {
3995
4610
  * - `skipModules`: skip `buildModules()` (check command).
3996
4611
  * - `skipHints`: skip `logStartupHints()` (check command).
3997
4612
  *
3998
- * Idempotent: calling prepare() again with the same env is a no-op unless
3999
- * `force` is set.
4613
+ * Configuration initialization is cached unless `force` is set. Discovery
4614
+ * and virtual module generation intentionally run on every call so callers
4615
+ * observe content changes without rebuilding the Engine instance.
4000
4616
  */
4001
4617
  async prepare(env = {
4002
4618
  command: this.runtime.command,
@@ -4116,10 +4732,11 @@ var ClarifyEngine = class {
4116
4732
  assertNoContentDiagnostics(this.routes);
4117
4733
  const outputDir = this.runtime.outputDirectory ?? this.generateOptions.outputDirectory;
4118
4734
  if (!outputDir) throw new Error("[clarify] outputDirectory is required before SSG runs");
4119
- const ssrOutputDir = join(outputDir, ".ssr");
4735
+ let ssrOutputDir;
4120
4736
  let tempEntryPath;
4121
4737
  try {
4122
4738
  tempEntryPath = createTempEntryFile(SSR_ENTRY_CODE);
4739
+ ssrOutputDir = createClarifyTempDir("ssr-output");
4123
4740
  await (this.runtime.buildSSRBundle ?? ((options) => buildSSRBundle(options.root, options.ssrEntry, options.ssrOutDir, options.plugins)))({
4124
4741
  root: this.root,
4125
4742
  ssrEntry: tempEntryPath,
@@ -4132,9 +4749,8 @@ var ClarifyEngine = class {
4132
4749
  console.error("[clarify] SSG failed:", err);
4133
4750
  throw err;
4134
4751
  } finally {
4135
- if (tempEntryPath) try {
4136
- rmSync(tempEntryPath, { force: true });
4137
- } catch {}
4752
+ removeClarifyTempDir(tempEntryPath ? resolve(tempEntryPath, "..") : void 0);
4753
+ removeClarifyTempDir(ssrOutputDir);
4138
4754
  }
4139
4755
  });
4140
4756
  await this.runBuildDone();
@@ -4174,4 +4790,4 @@ function createClarifyEngine(options = {}) {
4174
4790
  //#endregion
4175
4791
  export { createClarifyTempDir as a, cliPackageVersion as c, createViteAdapter as d, defineConfig as f, findRouteConflicts as i, cliPackageVersionWithCaret as l, createClarifyEngine as n, deserializeSearchIndex as o, createClarifyRuntimeAliases as r, searchMcpIndex as s, ClarifyEngine as t, printCliVersion as u };
4176
4792
 
4177
- //# sourceMappingURL=engine-DeA6EGHW.js.map
4793
+ //# sourceMappingURL=engine-BgfGFPKK.js.map