@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.
@@ -30,18 +30,14 @@ _vitejs_plugin_react = __toESM(_vitejs_plugin_react, 1);
30
30
  let rehype_raw = require("rehype-raw");
31
31
  rehype_raw = __toESM(rehype_raw, 1);
32
32
  let _mdx_js_mdx = require("@mdx-js/mdx");
33
+ let github_slugger = require("github-slugger");
34
+ github_slugger = __toESM(github_slugger, 1);
35
+ let _clarify_labs_renderer = require("@clarify-labs/renderer");
33
36
  let _shikijs_rehype = require("@shikijs/rehype");
34
37
  _shikijs_rehype = __toESM(_shikijs_rehype, 1);
35
- let rehype_slug = require("rehype-slug");
36
- rehype_slug = __toESM(rehype_slug, 1);
37
38
  let shiki = require("shiki");
38
39
  let unist_util_visit = require("unist-util-visit");
39
- let _clarify_labs_renderer = require("@clarify-labs/renderer");
40
40
  let node_fs_promises = require("node:fs/promises");
41
- let github_slugger = require("github-slugger");
42
- github_slugger = __toESM(github_slugger, 1);
43
- let mdast_util_to_string = require("mdast-util-to-string");
44
- let remark = require("remark");
45
41
  let gray_matter = require("gray-matter");
46
42
  gray_matter = __toESM(gray_matter, 1);
47
43
  let node_child_process = require("node:child_process");
@@ -76,6 +72,402 @@ function createContentDiagnostic(options) {
76
72
  };
77
73
  }
78
74
  //#endregion
75
+ //#region source/parsers/markdown/headings.ts
76
+ var HEADING_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
77
+ var INTERNAL_HEADING_ID_PREFIX = "clarify-internal-heading-id:";
78
+ var EXPLICIT_ID_PATTERN = /\s+\{#([^{}]*)\}$/;
79
+ var HEADING_REMARK_PLUGINS = _clarify_labs_renderer.markdownRemarkPlugins;
80
+ function visitHeadings(node, callback) {
81
+ if (node.type === "heading") callback(node);
82
+ for (const child of node.children ?? []) visitHeadings(child, callback);
83
+ }
84
+ function visitHtml(node, callback) {
85
+ if (node.type === "html") callback(node);
86
+ for (const child of node.children ?? []) visitHtml(child, callback);
87
+ }
88
+ function visitMdxIntrinsicHeadings(node, callback) {
89
+ if ((node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") && node.name && /^h[1-6]$/.test(node.name)) callback(node);
90
+ for (const child of node.children ?? []) visitMdxIntrinsicHeadings(child, callback);
91
+ }
92
+ function visitHastHeadings(node, callback) {
93
+ if (node.type === "element" && node.tagName && /^h[1-6]$/.test(node.tagName)) callback(node);
94
+ for (const child of node.children ?? []) visitHastHeadings(child, callback);
95
+ }
96
+ function hastText$1(node) {
97
+ if (node.type === "text") return node.value ?? "";
98
+ return (node.children ?? []).map(hastText$1).join("");
99
+ }
100
+ function applySourceEdits(content, edits) {
101
+ return edits.sort((left, right) => right.start - left.start).reduce((result, edit) => result.slice(0, edit.start) + edit.replacement + result.slice(edit.end), content);
102
+ }
103
+ function headingPosition(node, lineOffset) {
104
+ return {
105
+ line: (node.position?.start.line ?? 1) + lineOffset,
106
+ column: node.position?.start.column ?? 1
107
+ };
108
+ }
109
+ function formatPosition(position) {
110
+ return `${position.line}:${position.column}`;
111
+ }
112
+ function finalTextNode(heading) {
113
+ const node = heading.children?.at(-1);
114
+ return node?.type === "text" && typeof node.value === "string" ? node : void 0;
115
+ }
116
+ function contentEndOffset(heading) {
117
+ return (heading.children?.at(-1))?.position?.end.offset ?? heading.position?.end.offset;
118
+ }
119
+ function explicitIdInfo(heading, content) {
120
+ const text = finalTextNode(heading);
121
+ if (!text?.position) return void 0;
122
+ const startOffset = text.position.start.offset;
123
+ const endOffset = text.position.end.offset;
124
+ if (startOffset === void 0 || endOffset === void 0) return void 0;
125
+ const match = content.slice(startOffset, endOffset).match(EXPLICIT_ID_PATTERN);
126
+ if (!match || match.index === void 0) return void 0;
127
+ return {
128
+ id: match[1] ?? "",
129
+ start: startOffset + match.index,
130
+ end: startOffset + match.index + match[0].length
131
+ };
132
+ }
133
+ function maskExplicitHeadingMarkers(content) {
134
+ const tree = (0, _mdx_js_mdx.createProcessor)({
135
+ format: "md",
136
+ remarkPlugins: HEADING_REMARK_PLUGINS
137
+ }).parse(content);
138
+ const edits = [];
139
+ visitHeadings(tree, (heading) => {
140
+ const marker = explicitIdInfo(heading, content);
141
+ if (marker) edits.push({
142
+ start: marker.start,
143
+ end: marker.end,
144
+ replacement: "x".repeat(marker.end - marker.start)
145
+ });
146
+ });
147
+ return applySourceEdits(content, edits);
148
+ }
149
+ function parseHeadingTree(content, kind) {
150
+ if (kind === "markdown") return (0, _mdx_js_mdx.createProcessor)({
151
+ format: "md",
152
+ remarkPlugins: HEADING_REMARK_PLUGINS
153
+ }).parse(content);
154
+ const maskedContent = maskExplicitHeadingMarkers(content);
155
+ try {
156
+ return (0, _mdx_js_mdx.createProcessor)({
157
+ format: "mdx",
158
+ remarkPlugins: HEADING_REMARK_PLUGINS
159
+ }).parse(maskedContent);
160
+ } catch {
161
+ return;
162
+ }
163
+ }
164
+ function renderedMarkdownHeadingTitles(tree, content, kind) {
165
+ const originalText = /* @__PURE__ */ new Map();
166
+ visitHeadings(tree, (heading) => {
167
+ const marker = explicitIdInfo(heading, content);
168
+ const text = finalTextNode(heading);
169
+ if (!marker || !text || text.value === void 0) return;
170
+ originalText.set(text, text.value);
171
+ text.value = text.value.slice(0, -(marker.end - marker.start)).replace(/\s+$/, "");
172
+ });
173
+ try {
174
+ let hast;
175
+ const captureHast = () => (tree) => {
176
+ hast = tree;
177
+ };
178
+ (0, _mdx_js_mdx.createProcessor)({
179
+ format: kind === "markdown" ? "md" : "mdx",
180
+ remarkPlugins: HEADING_REMARK_PLUGINS,
181
+ ...kind === "markdown" ? { remarkRehypeOptions: { allowDangerousHtml: true } } : {},
182
+ rehypePlugins: kind === "markdown" ? [rehype_raw.default, captureHast] : [captureHast]
183
+ }).runSync(tree, { value: content });
184
+ const titles = /* @__PURE__ */ new Map();
185
+ if (!hast) return titles;
186
+ visitHastHeadings(hast, (heading) => {
187
+ const offset = heading.position?.start.offset;
188
+ if (offset !== void 0) titles.set(offset, hastText$1(heading));
189
+ });
190
+ return titles;
191
+ } finally {
192
+ for (const [text, value] of originalText) text.value = value;
193
+ }
194
+ }
195
+ function isHtmlWhitespace(character) {
196
+ return character === " " || character === "\n" || character === "\f" || character === "\r" || character === " ";
197
+ }
198
+ function isMdxWhitespace(character) {
199
+ return /\s/u.test(character ?? "");
200
+ }
201
+ function openingTagAttributeOffset(content, start, end, isWhitespace) {
202
+ let state = "tagName";
203
+ let quote;
204
+ for (let index = start; index < end; index++) {
205
+ const character = content[index];
206
+ const closesTag = character === ">";
207
+ const selfClosesTag = character === "/" && content[index + 1] === ">";
208
+ if (state === "quotedAttributeValue") {
209
+ if (character === quote) {
210
+ quote = void 0;
211
+ state = "afterQuotedAttributeValue";
212
+ }
213
+ continue;
214
+ }
215
+ if (state === "unquotedAttributeValue") {
216
+ if (closesTag) return index;
217
+ if (isWhitespace(character)) state = "beforeAttributeName";
218
+ continue;
219
+ }
220
+ if (closesTag) return index;
221
+ if (selfClosesTag && state !== "beforeAttributeValue") {
222
+ let cursor = index;
223
+ while (cursor > start && isWhitespace(content[cursor - 1])) cursor--;
224
+ return cursor;
225
+ }
226
+ if (state === "tagName") {
227
+ if (isWhitespace(character)) state = "beforeAttributeName";
228
+ } else if (state === "beforeAttributeName") {
229
+ if (!isWhitespace(character)) state = "attributeName";
230
+ } else if (state === "attributeName") {
231
+ if (character === "=") state = "beforeAttributeValue";
232
+ else if (isWhitespace(character)) state = "afterAttributeName";
233
+ } else if (state === "afterAttributeName") {
234
+ if (character === "=") state = "beforeAttributeValue";
235
+ else if (!isWhitespace(character)) state = "attributeName";
236
+ } else if (state === "beforeAttributeValue") {
237
+ if (character === "\"" || character === "'") {
238
+ quote = character;
239
+ state = "quotedAttributeValue";
240
+ } else if (!isWhitespace(character)) state = "unquotedAttributeValue";
241
+ } else if (state === "afterQuotedAttributeValue" && isWhitespace(character)) state = "beforeAttributeName";
242
+ }
243
+ }
244
+ function rawHtmlHeadings(tree, content, lineOffset) {
245
+ let hasRawHtml = false;
246
+ visitHtml(tree, () => {
247
+ hasRawHtml = true;
248
+ });
249
+ if (!hasRawHtml) return [];
250
+ const markdownOffsets = /* @__PURE__ */ new Set();
251
+ visitHeadings(tree, (heading) => {
252
+ const offset = heading.position?.start.offset;
253
+ if (offset !== void 0) markdownOffsets.add(offset);
254
+ });
255
+ let hast;
256
+ const captureHast = () => (tree) => {
257
+ hast = tree;
258
+ };
259
+ (0, _mdx_js_mdx.createProcessor)({
260
+ format: "md",
261
+ remarkRehypeOptions: { allowDangerousHtml: true },
262
+ rehypePlugins: [rehype_raw.default, captureHast]
263
+ }).runSync(tree, { value: content });
264
+ if (!hast) return [];
265
+ const headings = [];
266
+ visitHastHeadings(hast, (heading) => {
267
+ const offset = heading.position?.start.offset;
268
+ const headingEnd = heading.position?.end.offset;
269
+ if (offset === void 0 || headingEnd === void 0 || markdownOffsets.has(offset)) return;
270
+ const tagEnd = openingTagAttributeOffset(content, offset, headingEnd, isHtmlWhitespace);
271
+ if (tagEnd === void 0) return;
272
+ const rawId = heading.properties?.id;
273
+ headings.push({
274
+ title: hastText$1(heading),
275
+ ...typeof rawId === "string" && rawId ? { id: rawId } : {},
276
+ position: {
277
+ line: (heading.position?.start.line ?? 1) + lineOffset,
278
+ column: heading.position?.start.column ?? 1
279
+ },
280
+ offset,
281
+ attributeOffset: tagEnd
282
+ });
283
+ });
284
+ return headings;
285
+ }
286
+ function staticMdxText(node) {
287
+ if (node.type === "text" || node.type === "inlineCode") return node.value ?? "";
288
+ if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") {
289
+ if (!node.name || !/^[a-z][a-z0-9-]*$/.test(node.name)) return void 0;
290
+ } else if (!node.children) return;
291
+ const parts = [];
292
+ for (const child of node.children ?? []) {
293
+ const text = staticMdxText(child);
294
+ if (text === void 0) return void 0;
295
+ parts.push(text);
296
+ }
297
+ return parts.join("");
298
+ }
299
+ function literalMdxId(node) {
300
+ let result = { kind: "missing" };
301
+ for (const attribute of node.attributes ?? []) {
302
+ if (attribute.type !== "mdxJsxAttribute") {
303
+ result = { kind: "unknown" };
304
+ continue;
305
+ }
306
+ if (attribute.name !== "id") continue;
307
+ result = typeof attribute.value === "string" ? {
308
+ kind: "literal",
309
+ id: attribute.value
310
+ } : { kind: "unknown" };
311
+ }
312
+ return result;
313
+ }
314
+ function mdxIntrinsicHeadings(tree, content, lineOffset) {
315
+ const headings = [];
316
+ visitMdxIntrinsicHeadings(tree, (heading) => {
317
+ const position = heading.position;
318
+ const offset = position?.start.offset;
319
+ const end = position?.end.offset;
320
+ const title = staticMdxText(heading);
321
+ const literalId = literalMdxId(heading);
322
+ if (!position || offset === void 0 || end === void 0 || literalId.kind !== "literal") return;
323
+ const tagEnd = openingTagAttributeOffset(content, offset, end, isMdxWhitespace);
324
+ if (tagEnd === void 0) return;
325
+ headings.push({
326
+ ...title !== void 0 ? { title } : {},
327
+ id: literalId.id,
328
+ position: {
329
+ line: position.start.line + lineOffset,
330
+ column: position.start.column
331
+ },
332
+ offset,
333
+ attributeOffset: tagEnd
334
+ });
335
+ });
336
+ return headings;
337
+ }
338
+ function escapeHtmlAttribute(value) {
339
+ return value.replaceAll("&", "&amp;").replaceAll("\"", "&quot;");
340
+ }
341
+ function analyzeHeadings(content, options) {
342
+ const lineOffset = options.lineOffset ?? 0;
343
+ const headings = [];
344
+ const edits = [];
345
+ const errors = [];
346
+ const ids = /* @__PURE__ */ new Map();
347
+ const documentHeadings = [];
348
+ const markdownHeadingInputs = /* @__PURE__ */ new Map();
349
+ const tree = parseHeadingTree(content, options.kind);
350
+ if (!tree) return {
351
+ headings,
352
+ normalizedContent: content
353
+ };
354
+ const renderedTitles = renderedMarkdownHeadingTitles(tree, content, options.kind);
355
+ const claimId = (id, position) => {
356
+ const owner = ids.get(id);
357
+ if (owner) {
358
+ errors.push(`Heading ID "${id}" at ${formatPosition(position)} conflicts with the heading at ${formatPosition(owner.position)}.`);
359
+ return;
360
+ }
361
+ ids.set(id, {
362
+ id,
363
+ position
364
+ });
365
+ };
366
+ visitHeadings(tree, (node) => {
367
+ const marker = explicitIdInfo(node, content);
368
+ const title = renderedTitles.get(node.position?.start.offset ?? 0) ?? "";
369
+ const hasValidExplicitId = marker !== void 0 && HEADING_ID_PATTERN.test(marker.id);
370
+ const heading = {
371
+ level: node.depth ?? 1,
372
+ title,
373
+ id: hasValidExplicitId ? marker.id : "",
374
+ position: headingPosition(node, lineOffset)
375
+ };
376
+ if (marker && !hasValidExplicitId) errors.push(`Invalid heading ID "${marker.id}" at ${formatPosition(heading.position)}. IDs must match [a-z0-9]+(?:-[a-z0-9]+)*.`);
377
+ headings.push(heading);
378
+ markdownHeadingInputs.set(heading, {
379
+ ...marker ? { marker } : {},
380
+ hasValidExplicitId,
381
+ contentEnd: contentEndOffset(node)
382
+ });
383
+ documentHeadings.push({
384
+ title,
385
+ offset: node.position?.start.offset ?? 0,
386
+ markdown: heading
387
+ });
388
+ });
389
+ const renderedHeadings = options.kind === "markdown" ? rawHtmlHeadings(tree, content, lineOffset) : mdxIntrinsicHeadings(tree, content, lineOffset);
390
+ for (const rendered of renderedHeadings) documentHeadings.push({
391
+ ...rendered.title !== void 0 ? { title: rendered.title } : {},
392
+ offset: rendered.offset,
393
+ rendered
394
+ });
395
+ documentHeadings.sort((left, right) => left.offset - right.offset);
396
+ const slugger = new github_slugger.default();
397
+ for (const documentHeading of documentHeadings) {
398
+ if (documentHeading.markdown) {
399
+ const heading = documentHeading.markdown;
400
+ const input = markdownHeadingInputs.get(heading);
401
+ if (input.hasValidExplicitId) {
402
+ edits.push({
403
+ start: input.marker.start,
404
+ end: input.marker.end,
405
+ replacement: ` [](clarify-internal-heading-id:${heading.id})`
406
+ });
407
+ continue;
408
+ }
409
+ }
410
+ if (documentHeading.rendered?.id !== void 0) continue;
411
+ if (documentHeading.title === void 0) continue;
412
+ const generatedId = slugger.slug(documentHeading.title);
413
+ if (documentHeading.markdown) {
414
+ const heading = documentHeading.markdown;
415
+ const input = markdownHeadingInputs.get(heading);
416
+ heading.id = generatedId;
417
+ const link = ` [](clarify-internal-heading-id:${heading.id})`;
418
+ if (input.marker) edits.push({
419
+ start: input.marker.start,
420
+ end: input.marker.end,
421
+ replacement: link
422
+ });
423
+ else if (input.contentEnd !== void 0) edits.push({
424
+ start: input.contentEnd,
425
+ end: input.contentEnd,
426
+ replacement: link
427
+ });
428
+ } else if (documentHeading.rendered && documentHeading.rendered.id === void 0) {
429
+ documentHeading.rendered.id = generatedId;
430
+ edits.push({
431
+ start: documentHeading.rendered.attributeOffset,
432
+ end: documentHeading.rendered.attributeOffset,
433
+ replacement: ` id="${escapeHtmlAttribute(generatedId)}"`
434
+ });
435
+ }
436
+ }
437
+ for (const documentHeading of documentHeadings) if (documentHeading.markdown) claimId(documentHeading.markdown.id, documentHeading.markdown.position);
438
+ else if (documentHeading.rendered?.id) claimId(documentHeading.rendered.id, documentHeading.rendered.position);
439
+ const diagnostic = errors.length === 0 ? void 0 : createContentDiagnostic({
440
+ kind: options.kind,
441
+ title: "Heading ID error",
442
+ message: "This page has invalid or conflicting heading IDs. Fix the IDs below, then reload the route.",
443
+ error: new Error(errors.join("\n")),
444
+ filePath: options.filePath,
445
+ projectRoot: options.projectRoot
446
+ });
447
+ return {
448
+ headings,
449
+ normalizedContent: applySourceEdits(content, edits),
450
+ diagnostic
451
+ };
452
+ }
453
+ function remarkApplyHeadingIds() {
454
+ return (tree) => {
455
+ visitHeadings(tree, (heading) => {
456
+ const finalNode = heading.children?.at(-1);
457
+ if (finalNode?.type !== "link" || finalNode.children?.length !== 0 || typeof finalNode.url !== "string" || !finalNode.url.startsWith(INTERNAL_HEADING_ID_PREFIX)) return;
458
+ const id = finalNode.url.slice(28);
459
+ heading.children?.pop();
460
+ const previousNode = heading.children?.at(-1);
461
+ if (previousNode?.type === "text" && typeof previousNode.value === "string") previousNode.value = previousNode.value.replace(/ $/, "");
462
+ heading.data = heading.data ?? {};
463
+ heading.data.hProperties = {
464
+ ...heading.data.hProperties,
465
+ id
466
+ };
467
+ });
468
+ };
469
+ }
470
+ //#endregion
79
471
  //#region source/parsers/markdown/mdx.ts
80
472
  function getLanguage(className) {
81
473
  return (Array.isArray(className) ? className : typeof className === "string" ? className.split(/\s+/) : []).find((item) => typeof item === "string" && item.startsWith("language-"))?.replace(/^language-/, "") || "txt";
@@ -98,9 +490,34 @@ function rehypeParseCodeBlocks() {
98
490
  });
99
491
  };
100
492
  }
101
- var remarkPlugins = _clarify_labs_renderer.markdownRemarkPlugins;
493
+ function hastText(node) {
494
+ if (node.type === "text") return node.value ?? "";
495
+ return (node.children ?? []).map(hastText).join("");
496
+ }
497
+ function isHeadingElement(node) {
498
+ return Boolean(node.tagName && /^h[1-6]$/.test(node.tagName));
499
+ }
500
+ function rehypeAddMissingHeadingIds() {
501
+ return (tree) => {
502
+ const slugger = new github_slugger.default();
503
+ const reservedIds = /* @__PURE__ */ new Set();
504
+ (0, unist_util_visit.visit)(tree, "element", (node) => {
505
+ if (isHeadingElement(node) && typeof node.properties?.id === "string" && node.properties.id) reservedIds.add(node.properties.id);
506
+ });
507
+ (0, unist_util_visit.visit)(tree, "element", (node) => {
508
+ if (!isHeadingElement(node)) return;
509
+ const title = hastText(node);
510
+ let fallbackId = slugger.slug(title);
511
+ if (node.properties?.id) return;
512
+ while (reservedIds.has(fallbackId)) fallbackId = slugger.slug(title);
513
+ node.properties = node.properties ?? {};
514
+ node.properties.id = fallbackId;
515
+ });
516
+ };
517
+ }
518
+ var remarkPlugins = [remarkApplyHeadingIds, ..._clarify_labs_renderer.markdownRemarkPlugins];
102
519
  var rehypePlugins = [
103
- rehype_slug.default,
520
+ rehypeAddMissingHeadingIds,
104
521
  rehypeParseCodeBlocks,
105
522
  [_shikijs_rehype.default, {
106
523
  theme: cssVariablesTheme,
@@ -255,9 +672,12 @@ async function runBuildDoneHooks(plugins, ctx) {
255
672
  */
256
673
  function parseFrontmatter(content) {
257
674
  const parsed = (0, gray_matter.default)(content);
675
+ const body = parsed.content.replace(/^\r?\n/, "");
676
+ const prefix = content.slice(0, content.length - body.length);
258
677
  return {
259
678
  frontmatter: parsed.data,
260
- content: parsed.content.replace(/^\r?\n/, "")
679
+ content: body,
680
+ lineOffset: [...prefix.matchAll(/\r\n|\r|\n/g)].length
261
681
  };
262
682
  }
263
683
  //#endregion
@@ -268,7 +688,7 @@ async function transformContent(transform, input) {
268
688
  function createContentProcessor(transform) {
269
689
  return {
270
690
  async processMdx(source, filePath) {
271
- const { frontmatter, content } = parseFrontmatter(source);
691
+ const { frontmatter, content, lineOffset } = parseFrontmatter(source);
272
692
  const transformed = await transformContent(transform, {
273
693
  kind: "markdown+jsx",
274
694
  source,
@@ -278,7 +698,8 @@ function createContentProcessor(transform) {
278
698
  });
279
699
  return {
280
700
  frontmatter: transformed.frontmatter,
281
- content: transformed.content
701
+ content: transformed.content,
702
+ lineOffset
282
703
  };
283
704
  },
284
705
  async processText(source, kind, filePath) {
@@ -388,13 +809,47 @@ function resolveUpdatedAt(frontmatter, detectedAt) {
388
809
  return normalizeDate(typeof frontmatter.updatedAt === "string" ? frontmatter.updatedAt : void 0) ?? detectedAt;
389
810
  }
390
811
  //#endregion
391
- //#region source/parsers/routes/routes.ts
812
+ //#region source/parsers/routes/heading-metadata.ts
392
813
  function kebabToTitle(str) {
393
- return str.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
394
- }
395
- function parseMdxTree(content) {
396
- return remark.remark.parse(content);
814
+ return str.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
815
+ }
816
+ function headingMetadata(analysis) {
817
+ return { sections: analysis.headings.filter((heading) => heading.level === 2 || heading.level === 3).map((heading) => ({
818
+ id: heading.id,
819
+ title: heading.title,
820
+ level: heading.level
821
+ })) };
822
+ }
823
+ function resolvedRouteTitle(route, analysis) {
824
+ const frontmatterTitle = route.source.frontmatter?.title;
825
+ if (typeof frontmatterTitle === "string" && frontmatterTitle) return frontmatterTitle;
826
+ const routePath = route.basePath ?? route.path;
827
+ return kebabToTitle(routePath === "/" ? analysis.headings.find((heading) => heading.level === 1)?.title ?? "" : routePath.split("/").filter(Boolean).at(-1) ?? "") || "Untitled";
828
+ }
829
+ async function reanalyzeMarkdownRoute(route, projectRoot) {
830
+ if (route.kind !== "markdown" && route.kind !== "markdown+jsx") return route;
831
+ const analysis = analyzeHeadings(route.source.content ?? "", {
832
+ kind: route.kind,
833
+ filePath: route.source.filePath,
834
+ projectRoot,
835
+ lineOffset: route.source.lineOffset
836
+ });
837
+ const result = await (route.kind === "markdown" ? compileMarkdownContent : compileMdxContent)(analysis.normalizedContent, {
838
+ filePath: route.source.filePath,
839
+ projectRoot
840
+ });
841
+ return {
842
+ ...route,
843
+ meta: {
844
+ ...route.meta,
845
+ title: resolvedRouteTitle(route, analysis),
846
+ ...headingMetadata(analysis)
847
+ },
848
+ diagnostic: analysis.diagnostic ?? (result.ok ? void 0 : result.diagnostic)
849
+ };
397
850
  }
851
+ //#endregion
852
+ //#region source/parsers/routes/routes.ts
398
853
  async function compileRouteDiagnostic(content, filePath, baseDir) {
399
854
  const result = await ((0, node_path.extname)(filePath).toLowerCase() === ".md" ? compileMarkdownContent : compileMdxContent)(content, {
400
855
  filePath,
@@ -402,30 +857,6 @@ async function compileRouteDiagnostic(content, filePath, baseDir) {
402
857
  });
403
858
  return result.ok ? void 0 : result.diagnostic;
404
859
  }
405
- /** 从内容中提取第一个 H1 标题 */
406
- function extractH1(content) {
407
- const tree = parseMdxTree(content);
408
- let title = "";
409
- (0, unist_util_visit.visit)(tree, "heading", (node) => {
410
- if (!title && node.depth === 1) title = (0, mdast_util_to_string.toString)(node);
411
- });
412
- return title;
413
- }
414
- /** 从 MDX/Markdown 内容中提取 H2/H3 章节 */
415
- function extractMdxSections(content) {
416
- const sections = [];
417
- const slugger = new github_slugger.default();
418
- (0, unist_util_visit.visit)(parseMdxTree(content), "heading", (node) => {
419
- if (node.depth !== 2 && node.depth !== 3) return;
420
- const title = (0, mdast_util_to_string.toString)(node);
421
- sections.push({
422
- id: slugger.slug(title),
423
- title,
424
- level: node.depth
425
- });
426
- });
427
- return sections;
428
- }
429
860
  function navigationSections(sections) {
430
861
  return sections.map((section) => ({
431
862
  id: section.id,
@@ -482,11 +913,12 @@ async function findContentRoutes(dir, base = dir, options = {}) {
482
913
  const fullPath = (0, node_path.join)(dir, entry.name);
483
914
  if (entry.isDirectory()) routes.push(...await findContentRoutes(fullPath, base, options));
484
915
  else if (entry.isFile() && /\.mdx?$/.test(entry.name)) {
916
+ const kind = entry.name.toLowerCase().endsWith(".mdx") ? "markdown+jsx" : "markdown";
485
917
  const relativePath = (0, node_path.relative)(base, fullPath);
486
918
  const pathParts = relativePath.replace(/\.mdx?$/, "").split("/");
487
919
  const cleanPath = ("/" + pathParts.map((p) => p === "index" ? "" : p).filter(Boolean).join("/")).replace(/\/+/g, "/").replace(/\/$/, "") || "/";
488
920
  const source = await (0, node_fs_promises.readFile)(fullPath, "utf-8");
489
- const { frontmatter, content } = await (options.contentProcessor ?? createContentProcessor()).processMdx(source, fullPath);
921
+ const { frontmatter, content, lineOffset } = await (options.contentProcessor ?? createContentProcessor()).processMdx(source, fullPath);
490
922
  const updatedAt = resolveUpdatedAt(frontmatter, await getFileUpdatedAt(fullPath, base));
491
923
  const page = {
492
924
  path: cleanPath,
@@ -494,14 +926,21 @@ async function findContentRoutes(dir, base = dir, options = {}) {
494
926
  frontmatter,
495
927
  content
496
928
  };
497
- const diagnostic = await compileRouteDiagnostic(page.content, fullPath, base);
929
+ const analysis = analyzeHeadings(page.content, {
930
+ kind,
931
+ filePath: fullPath,
932
+ projectRoot: base,
933
+ lineOffset
934
+ });
935
+ const compileDiagnostic = await compileRouteDiagnostic(analysis.normalizedContent, fullPath, base);
936
+ const { sections } = headingMetadata(analysis);
498
937
  let title = typeof page.frontmatter.title === "string" ? page.frontmatter.title : "";
499
938
  if (!title) {
500
939
  const lastPart = pathParts[pathParts.length - 1] ?? "";
501
- title = kebabToTitle(lastPart === "index" ? pathParts.length >= 2 ? pathParts[pathParts.length - 2] : extractH1(page.content) : lastPart) || "Untitled";
940
+ title = kebabToTitle(lastPart === "index" ? pathParts.length >= 2 ? pathParts[pathParts.length - 2] : analysis.headings.find((heading) => heading.level === 1)?.title ?? "" : lastPart) || "Untitled";
502
941
  }
503
942
  routes.push({
504
- kind: entry.name.toLowerCase().endsWith(".mdx") ? "markdown+jsx" : "markdown",
943
+ kind,
505
944
  path: cleanPath,
506
945
  basePath: cleanPath,
507
946
  meta: {
@@ -511,7 +950,7 @@ async function findContentRoutes(dir, base = dir, options = {}) {
511
950
  group: typeof page.frontmatter.group === "string" ? page.frontmatter.group : void 0,
512
951
  layout: page.frontmatter.layout === "documentation" || page.frontmatter.layout === "blog" ? page.frontmatter.layout : void 0,
513
952
  updatedAt,
514
- sections: extractMdxSections(page.content)
953
+ sections
515
954
  },
516
955
  module: {
517
956
  pageVirtualModuleId: pageVirtualModuleId(relativePath.replace(/\.mdx?$/, "")),
@@ -520,9 +959,10 @@ async function findContentRoutes(dir, base = dir, options = {}) {
520
959
  source: {
521
960
  filePath: fullPath,
522
961
  frontmatter: page.frontmatter,
523
- content: page.content
962
+ content: page.content,
963
+ lineOffset
524
964
  },
525
- diagnostic
965
+ diagnostic: analysis.diagnostic ?? compileDiagnostic
526
966
  });
527
967
  }
528
968
  }
@@ -678,6 +1118,7 @@ function routeIntentFromPagesItem(item) {
678
1118
  kind: "openapi",
679
1119
  ref: item.openapi,
680
1120
  tagFilter: item.filter?.tags,
1121
+ operationOrder: item.operationOrder,
681
1122
  path: item.path,
682
1123
  title: item.title,
683
1124
  icon: item.icon
@@ -1144,6 +1585,7 @@ var clarifyPagesItemSchema = zod.z.lazy(() => zod.z.union([
1144
1585
  zod.z.object({
1145
1586
  openapi: zod.z.string(),
1146
1587
  path: zod.z.string().optional(),
1588
+ operationOrder: zod.z.array(zod.z.string()).optional(),
1147
1589
  icon: zod.z.string().optional(),
1148
1590
  title: clarifyLocalizedTextSchema.optional(),
1149
1591
  filter: zod.z.object({ tags: zod.z.array(zod.z.string()).optional() }).strict().optional()
@@ -1626,10 +2068,14 @@ function createNormalizedContentPlugin(engine) {
1626
2068
  transform(_code, id) {
1627
2069
  if (!/\.mdx?(?:\?|$)/.test(id)) return null;
1628
2070
  const filePath = id.replace(/\?.*$/, "");
1629
- const route = engine.routes.find((route) => (route.kind === "markdown+jsx" || route.kind === "markdown") && route.source.filePath === filePath);
1630
- if (!route || route.source.content === void 0) return null;
2071
+ const route = engine.routes.find((route) => route.source.filePath === filePath);
2072
+ if (!route || route.kind !== "markdown+jsx" && route.kind !== "markdown" || route.source.content === void 0) return null;
1631
2073
  return {
1632
- code: route.source.content,
2074
+ code: analyzeHeadings(route.source.content, {
2075
+ kind: route.kind,
2076
+ filePath,
2077
+ projectRoot: engine.root
2078
+ }).normalizedContent,
1633
2079
  map: null
1634
2080
  };
1635
2081
  }
@@ -1819,7 +2265,7 @@ function createViteAdapter(engine) {
1819
2265
  }
1820
2266
  var cliPackageVersion = {
1821
2267
  name: "@clarify-labs/cli",
1822
- version: "0.11.18",
2268
+ version: "0.11.20",
1823
2269
  license: "AGPL-3.0-only",
1824
2270
  type: "module",
1825
2271
  repository: {
@@ -2022,6 +2468,191 @@ function createLlmsTxtArtifact(routes, projectConfig) {
2022
2468
  return withUtf8Signature(createLlmsTxt(routes, projectConfig));
2023
2469
  }
2024
2470
  //#endregion
2471
+ //#region source/plugins/openapi/aggregation.ts
2472
+ function mergeOpenAPIRecord(target, source, section, filePath) {
2473
+ if (!source) return;
2474
+ for (const [key, value] of Object.entries(source)) {
2475
+ const existing = target[key];
2476
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(value)) throw new Error(`[clarify] Cannot aggregate OpenAPI specs: conflicting ${section} entry "${key}" in ${filePath}.`);
2477
+ target[key] = value;
2478
+ }
2479
+ }
2480
+ function openAPIVersionFamily(version) {
2481
+ return version.split(".").slice(0, 2).join(".");
2482
+ }
2483
+ var OPENAPI_OPERATION_METHODS = /* @__PURE__ */ new Set([
2484
+ "get",
2485
+ "put",
2486
+ "post",
2487
+ "delete",
2488
+ "options",
2489
+ "head",
2490
+ "patch",
2491
+ "trace"
2492
+ ]);
2493
+ function sourceComponentPrefix(source) {
2494
+ return (source.id || source.filePath.split(/[\\/]/).pop()?.replace(/\.openapi\.(?:json|ya?ml)$/i, "") || "service").replace(/[^a-zA-Z0-9._-]/g, "_") || "service";
2495
+ }
2496
+ function securitySchemeNameMap(source, sourceSchemes, targetSchemes) {
2497
+ const names = /* @__PURE__ */ new Map();
2498
+ for (const [name, definition] of Object.entries(sourceSchemes)) {
2499
+ const existing = targetSchemes[name];
2500
+ if (existing === void 0 || JSON.stringify(existing) === JSON.stringify(definition)) {
2501
+ targetSchemes[name] = definition;
2502
+ names.set(name, name);
2503
+ continue;
2504
+ }
2505
+ const prefix = sourceComponentPrefix(source);
2506
+ let candidate = `${prefix}__${name}`;
2507
+ let suffix = 2;
2508
+ while (targetSchemes[candidate] !== void 0 && JSON.stringify(targetSchemes[candidate]) !== JSON.stringify(definition)) candidate = `${prefix}__${name}_${suffix++}`;
2509
+ targetSchemes[candidate] = definition;
2510
+ names.set(name, candidate);
2511
+ }
2512
+ return names;
2513
+ }
2514
+ function rewriteSecurityRequirements(value, schemeNames) {
2515
+ if (!Array.isArray(value)) return value;
2516
+ return value.map((requirement) => {
2517
+ if (!requirement || typeof requirement !== "object" || Array.isArray(requirement)) return requirement;
2518
+ return Object.fromEntries(Object.entries(requirement).map(([name, scopes]) => [schemeNames.get(name) ?? name, scopes]));
2519
+ });
2520
+ }
2521
+ function localizePathItemContext(pathItemValue, documentServers, documentSecurity, hasDocumentSecurity, schemeNames) {
2522
+ if (!pathItemValue || typeof pathItemValue !== "object" || Array.isArray(pathItemValue)) return pathItemValue;
2523
+ const pathItem = structuredClone(pathItemValue);
2524
+ const pathServers = Object.hasOwn(pathItem, "servers") ? pathItem.servers : documentServers;
2525
+ for (const [method, operationValue] of Object.entries(pathItem)) {
2526
+ if (!OPENAPI_OPERATION_METHODS.has(method) || !operationValue || typeof operationValue !== "object" || Array.isArray(operationValue)) continue;
2527
+ const operation = operationValue;
2528
+ if (!Object.hasOwn(operation, "servers") && pathServers !== void 0) operation.servers = structuredClone(pathServers);
2529
+ if (Object.hasOwn(operation, "security")) operation.security = rewriteSecurityRequirements(operation.security, schemeNames);
2530
+ else if (hasDocumentSecurity) operation.security = rewriteSecurityRequirements(structuredClone(documentSecurity), schemeNames);
2531
+ }
2532
+ delete pathItem.servers;
2533
+ return pathItem;
2534
+ }
2535
+ function localizeOpenAPIContexts(spec, schemeNames) {
2536
+ const document = spec;
2537
+ const hasDocumentSecurity = Object.hasOwn(document, "security");
2538
+ const localizeEntries = (entries) => entries && Object.fromEntries(Object.entries(entries).map(([name, pathItem]) => [name, localizePathItemContext(pathItem, document.servers, document.security, hasDocumentSecurity, schemeNames)]));
2539
+ return {
2540
+ paths: localizeEntries(spec.paths) ?? {},
2541
+ webhooks: localizeEntries("webhooks" in spec ? spec.webhooks : void 0)
2542
+ };
2543
+ }
2544
+ function aggregateOpenAPISources(sources, info) {
2545
+ const paths = {};
2546
+ const webhooks = {};
2547
+ const components = {};
2548
+ const tags = /* @__PURE__ */ new Map();
2549
+ let openapiVersion;
2550
+ for (const source of sources) {
2551
+ const { document: spec, filePath } = source;
2552
+ if (openapiVersion && openAPIVersionFamily(openapiVersion) !== openAPIVersionFamily(spec.openapi)) throw new Error(`[clarify] Cannot aggregate OpenAPI specs: incompatible versions "${openapiVersion}" and "${spec.openapi}" in ${filePath}.`);
2553
+ openapiVersion ??= spec.openapi;
2554
+ components.securitySchemes ??= {};
2555
+ const sourceComponents = spec.components;
2556
+ const localized = localizeOpenAPIContexts(spec, securitySchemeNameMap(source, sourceComponents?.securitySchemes ?? {}, components.securitySchemes));
2557
+ mergeOpenAPIRecord(paths, localized.paths, "paths", filePath);
2558
+ mergeOpenAPIRecord(webhooks, localized.webhooks, "webhooks", filePath);
2559
+ for (const [section, entries] of Object.entries(sourceComponents ?? {})) {
2560
+ if (section === "securitySchemes") continue;
2561
+ components[section] ??= {};
2562
+ mergeOpenAPIRecord(components[section], entries, `components.${section}`, filePath);
2563
+ }
2564
+ for (const tag of spec.tags ?? []) {
2565
+ const existing = tags.get(tag.name);
2566
+ if (existing !== void 0 && JSON.stringify(existing) !== JSON.stringify(tag)) throw new Error(`[clarify] Cannot aggregate OpenAPI specs: conflicting tag "${tag.name}" in ${filePath}.`);
2567
+ tags.set(tag.name, tag);
2568
+ }
2569
+ }
2570
+ if (components.securitySchemes && Object.keys(components.securitySchemes).length === 0) delete components.securitySchemes;
2571
+ return {
2572
+ openapi: openapiVersion ?? "3.1.0",
2573
+ info: {
2574
+ title: info.title,
2575
+ description: info.description,
2576
+ version: info.version ?? "1.0.0"
2577
+ },
2578
+ paths,
2579
+ ...Object.keys(webhooks).length > 0 ? { webhooks } : {},
2580
+ ...Object.keys(components).length > 0 ? { components } : {},
2581
+ ...tags.size > 0 ? { tags: [...tags.values()] } : {}
2582
+ };
2583
+ }
2584
+ //#endregion
2585
+ //#region source/plugins/openapi/artifacts.ts
2586
+ function selectRootOpenAPISources(routes, projectConfig) {
2587
+ const defaultLocale = projectConfig.locales?.default;
2588
+ const sourcesByFilePath = /* @__PURE__ */ new Map();
2589
+ for (const route of routes) {
2590
+ if (route.kind !== "openapi" || route.diagnostic || !route.source.content || route.openapi?.tagFilter?.length || route.isBareAlias || defaultLocale && route.locale && route.locale !== defaultLocale) continue;
2591
+ sourcesByFilePath.set(route.source.filePath, {
2592
+ id: route.openapi?.sourceSpecId ?? route.source.filePath.split(/[\\/]/).pop()?.replace(/\.openapi\.(?:json|ya?ml)$/i, "") ?? "service",
2593
+ filePath: route.source.filePath,
2594
+ document: JSON.parse(route.source.content)
2595
+ });
2596
+ }
2597
+ return [...sourcesByFilePath.values()];
2598
+ }
2599
+ function createRootOpenAPISpec(routes, projectConfig) {
2600
+ return aggregateOpenAPISources(selectRootOpenAPISources(routes, projectConfig), {
2601
+ title: projectConfig.title,
2602
+ description: projectConfig.description
2603
+ });
2604
+ }
2605
+ function createOpenAPIRouteArtifacts(route) {
2606
+ if (route.kind !== "openapi" || !route.source.content) return [];
2607
+ const contentArtifactUrl = route.artifacts?.contentArtifactUrl;
2608
+ if (!contentArtifactUrl) return [];
2609
+ return [{
2610
+ fileName: contentArtifactUrl.replace(/^\//, ""),
2611
+ contentType: "application/json; charset=utf-8",
2612
+ source: route.source.content
2613
+ }, {
2614
+ fileName: contentArtifactUrl.replace(/\.json$/, ".yaml").replace(/^\//, ""),
2615
+ contentType: "text/yaml; charset=utf-8",
2616
+ source: (0, yaml.stringify)(JSON.parse(route.source.content), { lineWidth: 0 })
2617
+ }];
2618
+ }
2619
+ function createRootOpenAPIArtifact(routes, projectConfig) {
2620
+ return {
2621
+ fileName: "openapi.json",
2622
+ contentType: "application/json; charset=utf-8",
2623
+ source: JSON.stringify(createRootOpenAPISpec(routes, projectConfig), null, 2)
2624
+ };
2625
+ }
2626
+ //#endregion
2627
+ //#region source/plugins/content-artifacts/provider.ts
2628
+ function createContentArtifacts(routes, projectConfig) {
2629
+ const artifacts = [];
2630
+ for (const route of routes) {
2631
+ if (route.kind === "openapi") {
2632
+ artifacts.push(...createOpenAPIRouteArtifacts(route));
2633
+ continue;
2634
+ }
2635
+ const contentArtifactUrl = route.artifacts?.contentArtifactUrl;
2636
+ if (!contentArtifactUrl) continue;
2637
+ artifacts.push({
2638
+ fileName: contentArtifactUrl.replace(/^\//, ""),
2639
+ contentType: "text/markdown; charset=utf-8",
2640
+ source: readRouteArtifactContent(route)
2641
+ });
2642
+ }
2643
+ artifacts.push({
2644
+ fileName: "llms.txt",
2645
+ contentType: "text/plain; charset=utf-8",
2646
+ source: createLlmsTxtArtifact(routes, projectConfig)
2647
+ });
2648
+ artifacts.push(createRootOpenAPIArtifact(routes, projectConfig));
2649
+ return artifacts;
2650
+ }
2651
+ function findContentArtifact(path, routes, projectConfig) {
2652
+ const fileName = path.replace(/^\/+/, "");
2653
+ return createContentArtifacts(routes, projectConfig).find((artifact) => artifact.fileName === fileName);
2654
+ }
2655
+ //#endregion
2025
2656
  //#region source/plugins/content-artifacts/server.ts
2026
2657
  function normalizeRoutePrefix$1(routePrefix) {
2027
2658
  if (!routePrefix || routePrefix === "/") return "";
@@ -2033,34 +2664,12 @@ function resolveContentArtifactPath(url, projectConfig) {
2033
2664
  const hasBasePath = requestPath === basePath || requestPath.startsWith(`${basePath}/`);
2034
2665
  return basePath && hasBasePath ? requestPath.slice(basePath.length) || "/" : requestPath;
2035
2666
  }
2036
- function resolveContentArtifactType(route) {
2037
- return route.kind === "openapi" ? "application/json; charset=utf-8" : "text/markdown; charset=utf-8";
2038
- }
2039
2667
  function serveContentArtifacts(req, res, projectConfig, routes) {
2040
- const contentPath = resolveContentArtifactPath(req.url, projectConfig);
2041
- if (contentPath === "/llms.txt") {
2042
- res.statusCode = 200;
2043
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2044
- res.end(createLlmsTxtArtifact(routes, projectConfig), "utf8");
2045
- return true;
2046
- }
2047
- if (contentPath.endsWith(".openapi.yaml")) {
2048
- const jsonPath = contentPath.replace(/\.yaml$/, ".json");
2049
- const route = routes.find((route) => route.artifacts?.contentArtifactUrl === jsonPath);
2050
- if (route?.kind === "openapi" && route.source.content) {
2051
- const spec = JSON.parse(route.source.content);
2052
- res.statusCode = 200;
2053
- res.setHeader("Content-Type", "text/yaml; charset=utf-8");
2054
- res.end((0, yaml.stringify)(spec, { lineWidth: 0 }), "utf8");
2055
- return true;
2056
- }
2057
- return false;
2058
- }
2059
- const route = routes.find((route) => route.artifacts?.contentArtifactUrl === contentPath);
2060
- if (!route) return false;
2668
+ const artifact = findContentArtifact(resolveContentArtifactPath(req.url, projectConfig), routes, projectConfig);
2669
+ if (!artifact) return false;
2061
2670
  res.statusCode = 200;
2062
- res.setHeader("Content-Type", resolveContentArtifactType(route));
2063
- res.end(readRouteArtifactContent(route), "utf8");
2671
+ res.setHeader("Content-Type", artifact.contentType);
2672
+ res.end(artifact.source);
2064
2673
  return true;
2065
2674
  }
2066
2675
  //#endregion
@@ -2081,30 +2690,7 @@ function createContentArtifactsPlugin() {
2081
2690
  });
2082
2691
  };
2083
2692
  },
2084
- "build:assets": (ctx) => {
2085
- const assets = [];
2086
- for (const route of ctx.routes) {
2087
- const contentArtifactUrl = route.artifacts?.contentArtifactUrl;
2088
- if (!contentArtifactUrl) continue;
2089
- assets.push({
2090
- fileName: contentArtifactUrl.replace(/^\//, ""),
2091
- source: readRouteArtifactContent(route)
2092
- });
2093
- if (route.kind === "openapi" && route.source.content) {
2094
- const yamlFileName = contentArtifactUrl.replace(/\.json$/, ".yaml");
2095
- const spec = JSON.parse(route.source.content);
2096
- assets.push({
2097
- fileName: yamlFileName.replace(/^\//, ""),
2098
- source: (0, yaml.stringify)(spec, { lineWidth: 0 })
2099
- });
2100
- }
2101
- }
2102
- assets.push({
2103
- fileName: "llms.txt",
2104
- source: createLlmsTxtArtifact(ctx.routes, ctx.projectConfig)
2105
- });
2106
- return assets;
2107
- }
2693
+ "build:assets": (ctx) => createContentArtifacts(ctx.routes, ctx.projectConfig)
2108
2694
  }
2109
2695
  };
2110
2696
  }
@@ -2628,9 +3214,9 @@ function filterSpecByTags(spec, tags) {
2628
3214
  };
2629
3215
  }
2630
3216
  /** Extract endpoint operations from an OpenAPI spec as page sections. */
2631
- function extractOpenAPISections(spec, filterTags) {
3217
+ function extractOpenAPISections(spec, filterTags, operationOrder) {
2632
3218
  normalizeOpenAPISpecSectionIds(spec);
2633
- const sections = [];
3219
+ const entries = [];
2634
3220
  const items = [...Object.entries(spec.paths ?? {}).map(([path, pathItem]) => ({
2635
3221
  path,
2636
3222
  pathItem,
@@ -2648,16 +3234,25 @@ function extractOpenAPISections(spec, filterTags) {
2648
3234
  const tags = Array.isArray(op?.tags) ? op.tags : void 0;
2649
3235
  if (!op || !operationMatchesTags(tags, filterTags)) continue;
2650
3236
  const title = typeof op.summary === "string" ? op.summary : `${method.toUpperCase()} ${path}`;
2651
- sections.push({
2652
- id: operationSectionId(op),
2653
- title,
2654
- level: 2,
2655
- badge: kind === "webhook" ? "WEBHOOK" : method.toUpperCase(),
2656
- tags
3237
+ entries.push({
3238
+ section: {
3239
+ id: operationSectionId(op),
3240
+ title,
3241
+ level: 2,
3242
+ badge: kind === "webhook" ? "WEBHOOK" : method.toUpperCase(),
3243
+ tags
3244
+ },
3245
+ operationId: typeof op.operationId === "string" ? op.operationId : void 0
2657
3246
  });
2658
3247
  }
2659
3248
  }
2660
- return sections;
3249
+ if (!operationOrder?.length) return entries.map(({ section }) => section);
3250
+ const orderById = new Map(operationOrder.map((operationId, index) => [operationId, index]));
3251
+ return entries.map((entry, index) => ({
3252
+ ...entry,
3253
+ index,
3254
+ order: entry.operationId ? orderById.get(entry.operationId) : void 0
3255
+ })).sort((left, right) => (left.order ?? operationOrder.length) - (right.order ?? operationOrder.length) || left.index - right.index).map(({ section }) => section);
2661
3256
  }
2662
3257
  async function readOpenAPISpec(filePath, contentProcessor, projectRoot) {
2663
3258
  try {
@@ -2704,7 +3299,7 @@ function collectOpenAPIPageIntents(config) {
2704
3299
  continue;
2705
3300
  }
2706
3301
  const intent = routeIntentFromPagesItem(item);
2707
- if (intent.kind === "openapi" && (intent.path || intent.tagFilter?.length)) intents.push(intent);
3302
+ if (intent.kind === "openapi" && (intent.path || intent.tagFilter?.length || intent.operationOrder?.length)) intents.push(intent);
2708
3303
  }
2709
3304
  }
2710
3305
  function visitPages(pages) {
@@ -2725,7 +3320,8 @@ function cloneOpenAPIRoute(route, overrides = {}) {
2725
3320
  source: { ...route.source },
2726
3321
  openapi: route.openapi ? {
2727
3322
  ...route.openapi,
2728
- tagFilter: route.openapi.tagFilter ? [...route.openapi.tagFilter] : void 0
3323
+ tagFilter: route.openapi.tagFilter ? [...route.openapi.tagFilter] : void 0,
3324
+ operationOrder: route.openapi.operationOrder ? [...route.openapi.operationOrder] : void 0
2729
3325
  } : void 0,
2730
3326
  alternates: route.alternates ? { ...route.alternates } : void 0,
2731
3327
  ...overrides
@@ -2774,9 +3370,21 @@ function expandConfiguredOpenAPIRoutes(routes, config) {
2774
3370
  const existingKeys = new Set(routes.map((route) => `${route.locale ?? ""}:${route.basePath ?? route.path}`));
2775
3371
  for (const intent of openAPIIntents) {
2776
3372
  const tagFilter = intent.tagFilter?.filter(Boolean);
3373
+ const operationOrder = intent.operationOrder?.filter(Boolean);
2777
3374
  const sourceBasePath = openAPIPagePathFromRef(intent.ref);
2778
3375
  const targetBasePath = openAPIPagePathFromRef(intent.ref, tagFilter, intent.path);
2779
- if (sourceBasePath === targetBasePath && !tagFilter?.length) continue;
3376
+ if (sourceBasePath === targetBasePath && !tagFilter?.length) {
3377
+ if (!operationOrder?.length) continue;
3378
+ for (let index = 0; index < routes.length; index++) {
3379
+ const route = routes[index];
3380
+ if (route.kind !== "openapi" || (route.basePath ?? route.path) !== sourceBasePath) continue;
3381
+ routes[index] = cloneOpenAPIRoute(route, { openapi: {
3382
+ ...route.openapi,
3383
+ operationOrder: [...operationOrder]
3384
+ } });
3385
+ }
3386
+ continue;
3387
+ }
2780
3388
  for (const route of routes) {
2781
3389
  if (route.kind !== "openapi" || (route.basePath ?? route.path) !== sourceBasePath) continue;
2782
3390
  const key = `${route.locale ?? ""}:${targetBasePath}`;
@@ -2789,7 +3397,8 @@ function expandConfiguredOpenAPIRoutes(routes, config) {
2789
3397
  module: { pageVirtualModuleId: pageVirtualModuleId(path) },
2790
3398
  openapi: {
2791
3399
  ...route.openapi,
2792
- tagFilter: tagFilter?.length ? tagFilter : void 0
3400
+ tagFilter: tagFilter?.length ? tagFilter : void 0,
3401
+ operationOrder: operationOrder?.length ? [...operationOrder] : void 0
2793
3402
  }
2794
3403
  }));
2795
3404
  }
@@ -2831,10 +3440,11 @@ function generateOpenAPISpecModule(spec) {
2831
3440
  return `export default ${JSON.stringify(spec)};`;
2832
3441
  }
2833
3442
  function generateOpenAPIPageModule(opts) {
3443
+ const routeData = opts.operationOrder?.length ? { operationOrder: opts.operationOrder } : {};
2834
3444
  return `import { createOpenApiRouteComponent } from '@clarify-labs/renderer';
2835
3445
  import spec from ${JSON.stringify(opts.specModuleId)};
2836
3446
 
2837
- export const routeData = {};
3447
+ export const routeData = ${JSON.stringify(routeData)};
2838
3448
 
2839
3449
  export default createOpenApiRouteComponent({ ...routeData, spec });
2840
3450
  `;
@@ -2894,7 +3504,7 @@ var OpenAPIPluginState = class {
2894
3504
  route.meta.title = spec.info?.title ?? route.meta.title;
2895
3505
  route.meta.description = spec.info?.description ?? route.meta.description;
2896
3506
  route.meta.updatedAt = updatedAt;
2897
- route.meta.sections = extractOpenAPISections(spec, route.openapi.tagFilter);
3507
+ route.meta.sections = extractOpenAPISections(spec, route.openapi.tagFilter, route.openapi.operationOrder);
2898
3508
  const pageSpec = route.openapi.tagFilter?.length ? filterSpecByTags(specWithMetadata, route.openapi.tagFilter) : specWithMetadata;
2899
3509
  const routeSpecModuleId = routeSpecModuleIdFromSourceSpecId(sourceSpecId, route.openapi.tagFilter);
2900
3510
  route.openapi.routeSpecModuleId = routeSpecModuleId;
@@ -2928,7 +3538,10 @@ var OpenAPIPluginState = class {
2928
3538
  if (!route.openapi?.sourceSpecId) continue;
2929
3539
  const routeSpecModuleId = route.openapi.routeSpecModuleId ?? route.openapi.sourceSpecId;
2930
3540
  if (!this.specModules.has(routeSpecModuleId)) continue;
2931
- modules.set(route.module.pageVirtualModuleId, generateOpenAPIPageModule({ specModuleId: specVirtualModuleId(routeSpecModuleId) }));
3541
+ modules.set(route.module.pageVirtualModuleId, generateOpenAPIPageModule({
3542
+ specModuleId: specVirtualModuleId(routeSpecModuleId),
3543
+ operationOrder: route.openapi.operationOrder
3544
+ }));
2932
3545
  }
2933
3546
  return modules;
2934
3547
  }
@@ -3418,7 +4031,7 @@ async function resolveRoutePages(routes, plugins, ctx) {
3418
4031
  pageByRouteIndex.set(routeIndex, page);
3419
4032
  }
3420
4033
  if (pageByRouteIndex.size !== routes.length) throw new Error("[clarify] pages:resolved hooks must return exactly one page for each route");
3421
- return routes.map((route, routeIndex) => {
4034
+ const updatedRoutes = routes.map((route, routeIndex) => {
3422
4035
  const page = pageByRouteIndex.get(routeIndex);
3423
4036
  return {
3424
4037
  ...route,
@@ -3429,6 +4042,7 @@ async function resolveRoutePages(routes, plugins, ctx) {
3429
4042
  }
3430
4043
  };
3431
4044
  });
4045
+ return Promise.all(updatedRoutes.map((route) => reanalyzeMarkdownRoute(route, ctx.projectRoot)));
3432
4046
  }
3433
4047
  function buildNavigationTree(routes, ctx) {
3434
4048
  const tabs = ctx.projectConfig.navigation?.tabs;
@@ -4026,8 +4640,9 @@ var ClarifyEngine = class {
4026
4640
  * - `skipModules`: skip `buildModules()` (check command).
4027
4641
  * - `skipHints`: skip `logStartupHints()` (check command).
4028
4642
  *
4029
- * Idempotent: calling prepare() again with the same env is a no-op unless
4030
- * `force` is set.
4643
+ * Configuration initialization is cached unless `force` is set. Discovery
4644
+ * and virtual module generation intentionally run on every call so callers
4645
+ * observe content changes without rebuilding the Engine instance.
4031
4646
  */
4032
4647
  async prepare(env = {
4033
4648
  command: this.runtime.command,
@@ -4147,10 +4762,11 @@ var ClarifyEngine = class {
4147
4762
  assertNoContentDiagnostics(this.routes);
4148
4763
  const outputDir = this.runtime.outputDirectory ?? this.generateOptions.outputDirectory;
4149
4764
  if (!outputDir) throw new Error("[clarify] outputDirectory is required before SSG runs");
4150
- const ssrOutputDir = (0, node_path.join)(outputDir, ".ssr");
4765
+ let ssrOutputDir;
4151
4766
  let tempEntryPath;
4152
4767
  try {
4153
4768
  tempEntryPath = createTempEntryFile(SSR_ENTRY_CODE);
4769
+ ssrOutputDir = createClarifyTempDir("ssr-output");
4154
4770
  await (this.runtime.buildSSRBundle ?? ((options) => buildSSRBundle(options.root, options.ssrEntry, options.ssrOutDir, options.plugins)))({
4155
4771
  root: this.root,
4156
4772
  ssrEntry: tempEntryPath,
@@ -4163,9 +4779,8 @@ var ClarifyEngine = class {
4163
4779
  console.error("[clarify] SSG failed:", err);
4164
4780
  throw err;
4165
4781
  } finally {
4166
- if (tempEntryPath) try {
4167
- (0, node_fs.rmSync)(tempEntryPath, { force: true });
4168
- } catch {}
4782
+ removeClarifyTempDir(tempEntryPath ? (0, node_path.resolve)(tempEntryPath, "..") : void 0);
4783
+ removeClarifyTempDir(ssrOutputDir);
4169
4784
  }
4170
4785
  });
4171
4786
  await this.runBuildDone();
@@ -4276,4 +4891,4 @@ Object.defineProperty(exports, "searchMcpIndex", {
4276
4891
  }
4277
4892
  });
4278
4893
 
4279
- //# sourceMappingURL=engine-CNnlt59g.cjs.map
4894
+ //# sourceMappingURL=engine-CAis1rme.cjs.map