@writedocs/generator 0.4.6 → 0.4.8

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.
Files changed (48) hide show
  1. package/astro.config.mjs +37 -3
  2. package/package.json +1 -1
  3. package/src/components/Accordion.astro +22 -5
  4. package/src/components/AppIcon.astro +5 -0
  5. package/src/components/Callout.astro +15 -5
  6. package/src/components/Card.astro +64 -13
  7. package/src/components/Check.astro +13 -0
  8. package/src/components/Color.astro +61 -0
  9. package/src/components/ColorItem.astro +93 -0
  10. package/src/components/ColorRow.astro +39 -0
  11. package/src/components/Columns.astro +11 -0
  12. package/src/components/Frame.astro +10 -1
  13. package/src/components/GitHubRepo.astro +156 -0
  14. package/src/components/Hint.astro +50 -4
  15. package/src/components/Panel.astro +11 -0
  16. package/src/components/ParamField.astro +25 -0
  17. package/src/components/Parameter.astro +41 -4
  18. package/src/components/Prompt.astro +143 -0
  19. package/src/components/ResponseField.astro +18 -0
  20. package/src/components/Step.astro +22 -3
  21. package/src/components/Steps.astro +22 -0
  22. package/src/components/Tab.astro +7 -1
  23. package/src/components/Tabs.astro +27 -4
  24. package/src/components/Tile.astro +65 -0
  25. package/src/components/Tooltip.astro +13 -0
  26. package/src/components/Tree.astro +213 -0
  27. package/src/components/TreeFile.astro +17 -0
  28. package/src/components/TreeFolder.astro +28 -0
  29. package/src/components/Update.astro +171 -0
  30. package/src/components/View.astro +214 -0
  31. package/src/components/Visibility.astro +11 -0
  32. package/src/components/compound.ts +21 -0
  33. package/src/components/index.ts +12 -0
  34. package/src/content.config.ts +68 -2
  35. package/src/layout/components/NavTree.astro +34 -0
  36. package/src/layout/styles/base.css +12 -0
  37. package/src/lib/config.ts +1412 -1306
  38. package/src/lib/inline-markdown.js +29 -0
  39. package/src/lib/mdx-inject-builtins.js +20 -2
  40. package/src/lib/mdx-mintlify.js +99 -0
  41. package/src/lib/mdx-title-anchor-ids.js +7 -2
  42. package/src/lib/shiki-code-block.js +140 -26
  43. package/src/lib/visibility.js +29 -0
  44. package/src/pages/[...slug].astro +110 -7
  45. package/src/pages/[...slug].md.ts +7 -1
  46. package/src/pages/llms-full.txt.ts +5 -1
  47. package/src/pages/llms.txt.ts +3 -0
  48. package/src/styles/global.css +10 -0
@@ -0,0 +1,29 @@
1
+ // A string prop rendered with the small subset of inline Markdown Mintlify
2
+ // allows in some component props (Tile's `title`, Prompt's `description`,
3
+ // Color.Row's `title`): `code`, **bold**, and *italic* / _italic_. The
4
+ // input is HTML-escaped first, so this is always safe to hand to set:html -
5
+ // nothing in a prop value can produce markup other than these three.
6
+
7
+ function escapeHtml(value) {
8
+ return value
9
+ .replace(/&/g, '&')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;')
12
+ .replace(/"/g, '&quot;')
13
+ .replace(/'/g, '&#39;');
14
+ }
15
+
16
+ export function inlineMarkdown(value) {
17
+ if (!value) return '';
18
+ // Code spans first, and set aside, so ** or _ inside them stay literal.
19
+ const codeSpans = [];
20
+ let html = escapeHtml(String(value)).replace(/`([^`]+)`/g, (_, code) => {
21
+ codeSpans.push(`<code>${code}</code>`);
22
+ return `\u0000${codeSpans.length - 1}\u0000`;
23
+ });
24
+ html = html
25
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
26
+ .replace(/(^|[^\w*])\*([^*]+)\*(?!\w)/g, '$1<em>$2</em>')
27
+ .replace(/(^|[^\w])_([^_]+)_(?!\w)/g, '$1<em>$2</em>');
28
+ return html.replace(/\u0000(\d+)\u0000/g, (_, i) => codeSpans[Number(i)]);
29
+ }
@@ -34,6 +34,21 @@ const BUILTIN_COMPONENT_NAMES = [
34
34
  'Icon',
35
35
  'RequestExample',
36
36
  'ResponseExample',
37
+ 'Check',
38
+ 'ParamField',
39
+ 'ResponseField',
40
+ 'Columns',
41
+ 'Tooltip',
42
+ 'Update',
43
+ 'Tile',
44
+ 'Panel',
45
+ 'Prompt',
46
+ 'View',
47
+ 'Visibility',
48
+ 'Tree',
49
+ 'FileTree',
50
+ 'Color',
51
+ 'GitHub',
37
52
  ];
38
53
 
39
54
  const PACKAGE_SPECIFIER = 'writedocs/components';
@@ -72,8 +87,11 @@ export function remarkInjectBuiltinComponents() {
72
87
  const needed = new Set();
73
88
  visit(tree, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node) => {
74
89
  if (!node.name) return;
75
- if (BUILTIN_COMPONENT_NAMES.includes(node.name) && !locallyBound.has(node.name)) {
76
- needed.add(node.name);
90
+ // A dotted name (<Tree.File>, <GitHub.Repo>) needs its root imported -
91
+ // the parts hang off it (see src/components/compound.ts).
92
+ const root = node.name.split('.')[0];
93
+ if (BUILTIN_COMPONENT_NAMES.includes(root) && !locallyBound.has(root)) {
94
+ needed.add(root);
77
95
  }
78
96
  });
79
97
 
@@ -0,0 +1,99 @@
1
+ import { visit } from 'unist-util-visit';
2
+
3
+ // Remark plugins for Mintlify components whose input can't be read from a
4
+ // component's own props/slot at render time. Registered in
5
+ // astro.config.mjs's markdown processor, next to the other remark plugins.
6
+
7
+ const TREE_NAMES = new Set(['Tree', 'FileTree']);
8
+
9
+ function textOf(node) {
10
+ if (!node) return '';
11
+ if (node.type === 'text' || node.type === 'inlineCode') return node.value;
12
+ return (node.children ?? []).map(textOf).join('');
13
+ }
14
+
15
+ function jsxAttr(name, value) {
16
+ return { type: 'mdxJsxAttribute', name, value };
17
+ }
18
+
19
+ function listToTreeNodes(list) {
20
+ return list.children
21
+ .filter((item) => item.type === 'listItem')
22
+ .map((item) => {
23
+ const label = textOf(item.children.find((c) => c.type === 'paragraph')).trim();
24
+ const nested = item.children.find((c) => c.type === 'list');
25
+ const isFolder = label.endsWith('/') || Boolean(nested);
26
+ const name = label.replace(/\/+$/, '');
27
+ if (!isFolder) {
28
+ return { type: 'mdxJsxFlowElement', name: 'Tree.File', attributes: [jsxAttr('name', name)], children: [] };
29
+ }
30
+ const children = nested ? listToTreeNodes(nested) : [];
31
+ const attributes = [jsxAttr('name', name)];
32
+ // "Folders that contain nested items expand by default" - Mintlify's
33
+ // rule for the list syntax.
34
+ if (children.length > 0) attributes.push(jsxAttr('defaultOpen', null));
35
+ return { type: 'mdxJsxFlowElement', name: 'Tree.Folder', attributes, children };
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Mintlify's Markdown-list form of <Tree>/<FileTree>:
41
+ *
42
+ * <FileTree>
43
+ *
44
+ * - docs/
45
+ * - index.mdx
46
+ * - docs.config.ts
47
+ *
48
+ * </FileTree>
49
+ *
50
+ * A trailing slash or nested items make an entry a folder; formatting in a
51
+ * name (`code`, **bold**) is dropped. Rewritten here into the same
52
+ * <Tree.Folder>/<Tree.File> elements the component form uses, so Tree.astro
53
+ * only ever renders one shape.
54
+ */
55
+ export function remarkMintlifyTreeLists() {
56
+ return (tree) => {
57
+ visit(tree, 'mdxJsxFlowElement', (node) => {
58
+ if (!TREE_NAMES.has(node.name)) return;
59
+ if (!node.children.some((c) => c.type === 'list')) return;
60
+ node.children = node.children.flatMap((c) => (c.type === 'list' ? listToTreeNodes(c) : [c]));
61
+ });
62
+ };
63
+ }
64
+
65
+ function dedent(text) {
66
+ const lines = text.split('\n');
67
+ const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0].length);
68
+ const min = indents.length ? Math.min(...indents) : 0;
69
+ return lines
70
+ .map((l) => l.slice(min))
71
+ .join('\n')
72
+ .trim();
73
+ }
74
+
75
+ /**
76
+ * Mintlify's <Prompt> copies (and sends to Cursor) the prompt's own source
77
+ * text - Markdown included, e.g. "- Use second-person voice" - not the
78
+ * rendered HTML's plain text. A component only receives the rendered slot,
79
+ * so the source is read here, from the file between the element's first
80
+ * and last child, and passed down as a hidden `_text` prop.
81
+ */
82
+ export function remarkMintlifyPromptText() {
83
+ return (tree, file) => {
84
+ const source = String(file.value ?? '');
85
+ visit(tree, 'mdxJsxFlowElement', (node) => {
86
+ if (node.name !== 'Prompt' || node.children.length === 0) return;
87
+ if (node.attributes.some((a) => a.type === 'mdxJsxAttribute' && a.name === '_text')) return;
88
+ const start = node.children[0].position?.start?.offset;
89
+ const end = node.children[node.children.length - 1].position?.end?.offset;
90
+ if (typeof start !== 'number' || typeof end !== 'number') return;
91
+ // Back up to the start of the first child's line, so its own
92
+ // indentation is part of what dedent() measures - but only over
93
+ // whitespace, never into the <Prompt ...> tag on the same line.
94
+ const lineStart = source.lastIndexOf('\n', start - 1) + 1;
95
+ const from = /^[ \t]*$/.test(source.slice(lineStart, start)) ? lineStart : start;
96
+ node.attributes.push(jsxAttr('_text', dedent(source.slice(from, end))));
97
+ });
98
+ };
99
+ }
@@ -8,7 +8,11 @@ import { visit } from 'unist-util-visit';
8
8
  // (Card, Tabs, AccordionGroup, etc. don't). AccordionGroup is deliberately
9
9
  // excluded - it has no `title` of its own, just wraps Accordion children
10
10
  // that each get their own entry here independently.
11
- const TITLED_COMPONENT_NAMES = ['Callout', 'Note', 'Info', 'Tip', 'Warning', 'Danger', 'Accordion'];
11
+ const TITLED_COMPONENT_NAMES = ['Callout', 'Note', 'Info', 'Tip', 'Warning', 'Danger', 'Check', 'Accordion', 'Update'];
12
+
13
+ // The attribute each one's anchor text comes from - `title`, except
14
+ // Mintlify's <Update>, whose anchor is its `label`.
15
+ const ANCHOR_ATTRIBUTE = { Update: 'label' };
12
16
 
13
17
  // Deliberately the same slugify Callout.astro/Accordion.astro themselves
14
18
  // fall back to (see their own comments) - kept as a literal duplicate
@@ -67,8 +71,9 @@ export function remarkTitleAnchorIds() {
67
71
  const seen = new Map();
68
72
  visit(tree, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node) => {
69
73
  if (!node.name || !TITLED_COMPONENT_NAMES.includes(node.name)) return;
74
+ const attrName = ANCHOR_ATTRIBUTE[node.name] ?? 'title';
70
75
  const titleAttr = node.attributes?.find(
71
- (attr) => attr.type === 'mdxJsxAttribute' && attr.name === 'title'
76
+ (attr) => attr.type === 'mdxJsxAttribute' && attr.name === attrName
72
77
  );
73
78
  if (!titleAttr || typeof titleAttr.value !== 'string') return;
74
79
 
@@ -21,33 +21,124 @@
21
21
  // at all, so this only ever touches genuine ```-fenced MDX content and
22
22
  // never collides with the API playground's own bespoke copy buttons.
23
23
 
24
- const META_TITLE_RE = /title\s*=\s*(["'])((?:(?!\1).)*)\1/;
24
+ import { iconSvg } from './config.ts';
25
25
 
26
- function parseMeta(raw) {
27
- return {
28
- title: raw.match(META_TITLE_RE)?.[2] ?? null,
29
- wrap: /\bwrap\b/.test(raw),
30
- lines: /\b(?:lines|showLineNumbers)\b/.test(raw),
31
- expandable: /\bexpandable\b/.test(raw),
26
+ // The fence's info string after the language, split into tokens: a
27
+ // `key=value` option (value quoted, in braces, or bare), a bare `{1,3-5}`
28
+ // range, a `/word/` pattern, or any other bare word.
29
+ const META_TOKEN_RE = /([A-Za-z][\w-]*)=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\}|(\S+))|\{([^}]*)\}|(\/(?:\\.|[^/\s])+\/)(?=\s|$)|(\S+)/g;
30
+
31
+ // Bare words that are options, not title text. Case-sensitive on purpose:
32
+ // Mintlify's own docs write ```js Wrap example wrap, where the capitalized
33
+ // "Wrap" is part of the title and only the trailing "wrap" is the flag.
34
+ const META_FLAGS = new Set(['wrap', 'lines', 'showLineNumbers', 'expandable', 'nocopy', 'twoslash']);
35
+
36
+ function parseRanges(value) {
37
+ return value
38
+ .split(',')
39
+ .map((part) => part.trim())
40
+ .filter(Boolean)
41
+ .flatMap((part) => {
42
+ const [start, end] = part.split('-').map((n) => Number.parseInt(n, 10));
43
+ if (Number.isNaN(start)) return [];
44
+ if (end === undefined || Number.isNaN(end)) return [start];
45
+ return Array.from({ length: end - start + 1 }, (_, i) => start + i);
46
+ });
47
+ }
48
+
49
+ /** Parses a fence's info string in both syntaxes writedocs accepts:
50
+ * its own (```js title="a.js" {1,3} wrap) and Mintlify's
51
+ * (```js a.js highlight={1,3} focus={2} icon="js" nocopy wrap) - the
52
+ * Mintlify title is the run of plain words right after the language,
53
+ * before the first option. `title="..."` wins over an inline title.
54
+ *
55
+ * `shikiMeta` is what the stock @shikijs/transformers meta transformers
56
+ * get to see instead of the raw string (see preprocess below): only the
57
+ * highlight range and the /word/ patterns. Read raw, those transformers
58
+ * would take focus={2} as a highlight range (they grab the first {...}
59
+ * anywhere) and a / inside a title or path as a word to highlight. */
60
+ export function parseMeta(raw) {
61
+ const meta = {
62
+ title: null,
63
+ icon: null,
64
+ wrap: false,
65
+ lines: false,
66
+ expandable: false,
67
+ nocopy: false,
68
+ twoslash: false,
69
+ highlight: [],
70
+ focus: [],
71
+ words: [],
32
72
  };
73
+ const titleWords = [];
74
+ let sawOption = false;
75
+ for (const m of raw.matchAll(META_TOKEN_RE)) {
76
+ const [, key, dq, sq, braced, bare, bareRange, word, other] = m;
77
+ if (key !== undefined) {
78
+ sawOption = true;
79
+ const value = dq ?? sq ?? braced ?? bare ?? '';
80
+ if (key === 'title') meta.title = value;
81
+ else if (key === 'icon') meta.icon = value;
82
+ else if (key === 'highlight') meta.highlight.push(...parseRanges(value));
83
+ else if (key === 'focus') meta.focus.push(...parseRanges(value));
84
+ // Mintlify's `nocopy="false"` (explicitly keep the copy button).
85
+ else if (META_FLAGS.has(key)) meta[key === 'showLineNumbers' ? 'lines' : key] = value !== 'false';
86
+ continue;
87
+ }
88
+ if (bareRange !== undefined) {
89
+ sawOption = true;
90
+ meta.highlight.push(...parseRanges(bareRange));
91
+ continue;
92
+ }
93
+ if (word !== undefined) {
94
+ sawOption = true;
95
+ meta.words.push(word);
96
+ continue;
97
+ }
98
+ if (META_FLAGS.has(other)) {
99
+ sawOption = true;
100
+ meta[other === 'showLineNumbers' ? 'lines' : other] = true;
101
+ continue;
102
+ }
103
+ if (!sawOption) titleWords.push(other);
104
+ }
105
+ if (meta.title === null && titleWords.length > 0) meta.title = titleWords.join(' ');
106
+ const shikiParts = [];
107
+ if (meta.highlight.length > 0) shikiParts.push(`{${meta.highlight.join(',')}}`);
108
+ shikiParts.push(...meta.words);
109
+ meta.shikiMeta = shikiParts.join(' ');
110
+ return meta;
33
111
  }
34
112
 
35
113
  export function codeBlockTransformer() {
36
114
  return {
37
115
  name: 'writedocs:code-block',
38
- // Runs once per block, before `root` - stashes the parsed meta
39
- // flags on `this` (the shared per-block transformer context; see
40
- // @shikijs/core's tokensToHast, which calls both `pre` and `root`
41
- // with the same `context` object for a given block, but a *fresh*
42
- // one for each block) so `root` below doesn't have to re-parse it.
116
+ // preprocess runs before every other hook of every transformer (see
117
+ // @shikijs/core's codeToTokens), so this is where the raw info string
118
+ // is parsed once and swapped for the cleaned-up `shikiMeta` the stock
119
+ // meta-highlight transformers read - see parseMeta() above. The full
120
+ // parse is kept on the same options object for the hooks below.
121
+ preprocess(code, options) {
122
+ if (options.meta) {
123
+ options.meta.__wdMeta = parseMeta(options.meta.__raw ?? '');
124
+ options.meta.__raw = options.meta.__wdMeta.shikiMeta;
125
+ }
126
+ return code;
127
+ },
128
+ line(node, lineNumber) {
129
+ if (this.options.meta?.__wdMeta?.focus.includes(lineNumber)) this.addClassToHast(node, 'focused');
130
+ return node;
131
+ },
132
+ // Same classes transformerNotationFocus (`// [!code focus]`) adds, so
133
+ // `focus={...}` reuses [...slug].astro's existing dimming CSS.
43
134
  pre(node) {
44
- this.wdMeta = parseMeta(this.options.meta?.__raw ?? '');
135
+ if (this.options.meta?.__wdMeta?.focus.length) this.addClassToHast(node, 'has-focused');
45
136
  return node;
46
137
  },
47
138
  root(hast) {
48
139
  const pre = hast.children.find((child) => child.type === 'element' && child.tagName === 'pre');
49
140
  if (!pre) return hast;
50
- const meta = this.wdMeta ?? parseMeta(this.options.meta?.__raw ?? '');
141
+ const meta = this.options.meta?.__wdMeta ?? parseMeta(this.options.meta?.__raw ?? '');
51
142
 
52
143
  const wrapperClass = ['wd-code-block'];
53
144
  if (meta.wrap) wrapperClass.push('wd-code-wrap');
@@ -56,25 +147,48 @@ export function codeBlockTransformer() {
56
147
 
57
148
  const children = [];
58
149
  if (meta.title) {
150
+ const titleChildren = [];
151
+ // Drawn as a CSS mask over currentColor, so the icon takes the
152
+ // title bar's own text color in both themes. An icon string that
153
+ // isn't an installed icon (emoji, unknown name) is skipped rather
154
+ // than failing the build.
155
+ const svg = meta.icon ? iconSvg(meta.icon) : null;
156
+ if (svg) {
157
+ titleChildren.push({
158
+ type: 'element',
159
+ tagName: 'span',
160
+ properties: {
161
+ class: 'wd-code-title-icon',
162
+ 'aria-hidden': 'true',
163
+ style: `--wd-code-icon: url("data:image/svg+xml,${encodeURIComponent(svg)}")`,
164
+ },
165
+ children: [],
166
+ });
167
+ }
168
+ titleChildren.push({ type: 'text', value: meta.title });
59
169
  children.push({
60
170
  type: 'element',
61
171
  tagName: 'div',
62
172
  properties: { class: 'wd-code-title' },
63
- children: [{ type: 'text', value: meta.title }],
173
+ children: titleChildren,
64
174
  });
65
175
  }
66
176
  children.push(pre);
67
- children.push({
68
- type: 'element',
69
- tagName: 'button',
70
- properties: {
71
- type: 'button',
72
- class: 'wd-code-copy-btn',
73
- 'data-role': 'copy-code',
74
- 'aria-label': 'Copy code',
75
- },
76
- children: [{ type: 'text', value: '⧉' }],
77
- });
177
+ // Mintlify's `nocopy` - for content where copying makes no sense
178
+ // (ASCII diagrams, sample output).
179
+ if (!meta.nocopy) {
180
+ children.push({
181
+ type: 'element',
182
+ tagName: 'button',
183
+ properties: {
184
+ type: 'button',
185
+ class: 'wd-code-copy-btn',
186
+ 'data-role': 'copy-code',
187
+ 'aria-label': 'Copy code',
188
+ },
189
+ children: [{ type: 'text', value: '⧉' }],
190
+ });
191
+ }
78
192
  if (meta.expandable) {
79
193
  children.push({
80
194
  type: 'element',
@@ -0,0 +1,29 @@
1
+ // Mintlify's <Visibility for="humans|agents"> in the page source text that
2
+ // writedocs serves to agents - the per-page `.md` route and llms-full.txt,
3
+ // which both hand out a page's raw MDX body rather than its rendered HTML.
4
+ // The rendered site shows `humans` content and hides `agents` content (see
5
+ // Visibility.astro); the text versions do the opposite: `humans` blocks are
6
+ // removed, and `agents` blocks are unwrapped so only their contents remain.
7
+ // A <Visibility> with no `for` counts as `humans`, like the component.
8
+ //
9
+ // Works on source text, not an AST, since that's what these routes serve.
10
+ // Nested <Visibility> blocks aren't supported (Mintlify doesn't document
11
+ // nesting either) - the regex pairs each opening tag with the first
12
+ // closing tag after it.
13
+
14
+ const VISIBILITY_BLOCK_RE =
15
+ /<Visibility(?:\s+for\s*=\s*(?:"(humans|agents)"|'(humans|agents)'|\{\s*["'](humans|agents)["']\s*\}))?\s*>([\s\S]*?)<\/Visibility>/g;
16
+
17
+ function dedent(text) {
18
+ const lines = text.replace(/^\n+|\s+$/g, '').split('\n');
19
+ const indents = lines.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0].length);
20
+ const min = indents.length ? Math.min(...indents) : 0;
21
+ return lines.map((l) => l.slice(min)).join('\n');
22
+ }
23
+
24
+ export function applyVisibilityForAgents(body) {
25
+ return body.replace(VISIBILITY_BLOCK_RE, (_, dq, sq, expr, inner) => {
26
+ const audience = dq ?? sq ?? expr ?? 'humans';
27
+ return audience === 'agents' ? dedent(inner) : '';
28
+ });
29
+ }
@@ -51,6 +51,18 @@ import Badge from "../components/Badge.astro";
51
51
  import Icon from "../components/Icon.astro";
52
52
  import RequestExample from "../components/RequestExample.astro";
53
53
  import ResponseExample from "../components/ResponseExample.astro";
54
+ import Check from "../components/Check.astro";
55
+ import ParamField from "../components/ParamField.astro";
56
+ import ResponseField from "../components/ResponseField.astro";
57
+ import Columns from "../components/Columns.astro";
58
+ import Tooltip from "../components/Tooltip.astro";
59
+ import Update from "../components/Update.astro";
60
+ import Tile from "../components/Tile.astro";
61
+ import Panel from "../components/Panel.astro";
62
+ import Prompt from "../components/Prompt.astro";
63
+ import View from "../components/View.astro";
64
+ import Visibility from "../components/Visibility.astro";
65
+ import { Tree, FileTree, Color, GitHub } from "../components/compound";
54
66
  import ApiPlayground from "../components/ApiPlayground.astro";
55
67
  import ApiReferencePanel from "../components/ApiReferencePanel.astro";
56
68
 
@@ -156,6 +168,15 @@ export async function getStaticPaths() {
156
168
 
157
169
  const pageRoutes = sections.flatMap((section, sectionIndex) => {
158
170
  const flatNav = flattenNav(section.pages);
171
+ // Prev/next skip pages that aren't in the sidebar (frontmatter
172
+ // `hidden`) or aren't pages at all (frontmatter `url`, an external
173
+ // link) - the reader should land on the next page they could also
174
+ // have clicked in the sidebar. Inlined for the same reason as the
175
+ // error branch below.
176
+ const isPaginationStop = (slug: string) => {
177
+ const data = entryByFileId.get(slug)?.data;
178
+ return Boolean(data) && !data!.hidden && !data!.url;
179
+ };
159
180
  return flatNav.map((navEntry, i) => {
160
181
  const entry = entryByFileId.get(navEntry.slug);
161
182
  if (!entry) {
@@ -184,9 +205,17 @@ export async function getStaticPaths() {
184
205
  );
185
206
  }
186
207
  claimedFileIds.add(navEntry.slug);
187
- const prev = i > 0 ? flatNav[i - 1] : null;
188
- const next = i < flatNav.length - 1 ? flatNav[i + 1] : null;
208
+ const prev = flatNav.slice(0, i).findLast((e) => isPaginationStop(e.slug)) ?? null;
209
+ const next = flatNav.slice(i + 1).find((e) => isPaginationStop(e.slug)) ?? null;
189
210
  const urlSlug = normalizeEntryId(entry.id);
211
+ // A frontmatter `url` page (Mintlify's external link) exists only
212
+ // to put that link in the sidebar - its own URL redirects there.
213
+ if (entry.data.url) {
214
+ return {
215
+ params: { slug: urlSlug === "index" ? undefined : urlSlug },
216
+ props: { redirectTo: entry.data.url },
217
+ };
218
+ }
190
219
  return {
191
220
  params: { slug: urlSlug === "index" ? undefined : urlSlug },
192
221
  props: { entry, prev, next, sectionIndex },
@@ -276,6 +305,9 @@ const entries: DocsEntry[] = [...pagesEntries, ...generatedDocsEntries];
276
305
  // themselves so hrefForSlug can resolve a writedocs.json file-id reference to
277
306
  // wherever that page actually got routed.
278
307
  const titleByFileId = new Map<string, string>();
308
+ // Navigation labels (sidebar, topbar dropdowns): a page's `sidebarTitle`
309
+ // when it has one - Mintlify's short nav label - else its `title`.
310
+ const navTitleByFileId = new Map<string, string>();
279
311
  const entryByFileId = new Map<string, DocsEntry>();
280
312
  // The HTTP method badge NavTree.astro shows next to an API operation
281
313
  // page in the sidebar - read straight off the page's own `openapi:
@@ -286,13 +318,19 @@ const methodByFileId = new Map<string, string>();
286
318
  for (const e of entries) {
287
319
  const fileId = fileIdForEntry(contentDir, packageRoot, e);
288
320
  titleByFileId.set(fileId, e.data.title);
321
+ navTitleByFileId.set(fileId, e.data.sidebarTitle ?? e.data.title);
289
322
  entryByFileId.set(fileId, e);
290
- if (e.data.openapi) {
323
+ // `hideApiMarker` (Mintlify's) drops the badge for this one page.
324
+ if (e.data.openapi && !e.data.hideApiMarker) {
291
325
  methodByFileId.set(fileId, e.data.openapi.trim().split(/\s+/, 1)[0].toUpperCase());
292
326
  }
293
327
  }
294
328
  const titleForSlug = (slug: string) => titleByFileId.get(slug) ?? slug;
329
+ const navTitleForSlug = (slug: string) => navTitleByFileId.get(slug) ?? slug;
295
330
  const methodForSlug = (slug: string) => methodByFileId.get(slug) ?? null;
331
+ // Frontmatter that changes how a page appears in navigation (hidden, url,
332
+ // icon, tag, deprecated) - see buildNavTree() in lib/config.ts.
333
+ const navMetaForSlug = (slug: string) => entryByFileId.get(slug)?.data;
296
334
  const hrefForSlug = (slug: string) => {
297
335
  const raw = entryByFileId.get(slug)?.id ?? slug;
298
336
  const effective = normalizeEntryId(raw);
@@ -303,7 +341,7 @@ const hrefForSlug = (slug: string) => {
303
341
  // and what every "is this the active page" comparison below needs, since
304
342
  // navTree/globalDropdowns are built from writedocs.json's file-id space.
305
343
  const currentFileId = fileIdForEntry(contentDir, packageRoot, entry);
306
- const navTree = buildNavTree(activeSection.pages, titleForSlug, methodForSlug);
344
+ const navTree = buildNavTree(activeSection.pages, navTitleForSlug, methodForSlug, navMetaForSlug);
307
345
  // The ancestor sidebar-group trail above the auto <h1> (Breadcrumbs.astro)
308
346
  // - empty for a page sitting at the top level of its Section with no
309
347
  // enclosing group, in which case [...slug].astro below skips rendering
@@ -326,8 +364,9 @@ const selectors = buildSelectors(activeSection.path, hrefForSlug, activePagePosi
326
364
  const globalDropdowns = buildGlobalDropdowns(
327
365
  resolveGlobalDropdowns(config.navigation),
328
366
  currentFileId,
329
- titleForSlug,
367
+ navTitleForSlug,
330
368
  hrefForSlug,
369
+ navMetaForSlug,
331
370
  );
332
371
 
333
372
  // Meta tags: this page's own frontmatter `seo` overrides writedocs.json's
@@ -401,6 +440,21 @@ const components = {
401
440
  Icon,
402
441
  RequestExample,
403
442
  ResponseExample,
443
+ Check,
444
+ ParamField,
445
+ ResponseField,
446
+ Columns,
447
+ Tooltip,
448
+ Update,
449
+ Tile,
450
+ Panel,
451
+ Prompt,
452
+ View,
453
+ Visibility,
454
+ Tree,
455
+ FileTree,
456
+ Color,
457
+ GitHub,
404
458
  };
405
459
  ---
406
460
 
@@ -430,13 +484,25 @@ const components = {
430
484
  <article class={`wd-article ${pageMode === "wide" ? "wd-article-wide" : ""}`} data-pagefind-body>
431
485
  {breadcrumbs.length > 0 && <Breadcrumbs items={breadcrumbs} />}
432
486
  <div class="wd-article-header">
433
- <h1 data-pagefind-meta="title">{entry.data.title}</h1>
487
+ {
488
+ // Frontmatter `deprecated` puts a label beside the <h1>, not in
489
+ // it - the <h1>'s text is the search index's page title.
490
+ entry.data.deprecated ? (
491
+ <div class="wd-article-title">
492
+ <h1 data-pagefind-meta="title">{entry.data.title}</h1>
493
+ <span class="wd-deprecated-badge">Deprecated</span>
494
+ </div>
495
+ ) : (
496
+ <h1 data-pagefind-meta="title">{entry.data.title}</h1>
497
+ )
498
+ }
434
499
  {showCopyPageMenu && (
435
500
  <CopyPageMenu currentPath={currentPath} siteUrl={siteUrl} contextMenu={config.contextMenu!} />
436
501
  )}
437
502
  </div>
438
503
  <Content components={components} />
439
504
  {entry.data.openapi && <ApiPlayground operation={entry.data.openapi} contentDir={contentDir} />}
505
+ {!entry.data.hideFooterPagination && (
440
506
  <nav class="wd-prevnext" data-pagefind-ignore>
441
507
  {prev && (
442
508
  <a class="wd-prevnext-card wd-prevnext-prev" href={hrefForSlug(prev.slug)}>
@@ -485,6 +551,7 @@ const components = {
485
551
  </a>
486
552
  )}
487
553
  </nav>
554
+ )}
488
555
  </article>
489
556
  )
490
557
  }
@@ -677,7 +744,12 @@ const components = {
677
744
  const tocCol = document.querySelector<HTMLElement>(".wd-toc-col");
678
745
  if (!tocCol) return; // no toc column on this page (mode !== 'default') - nothing to dock into
679
746
 
680
- const panels = Array.from(root.querySelectorAll<HTMLElement>(".wd-example-panel"));
747
+ // Top-level panels only: Mintlify's <Panel> (Panel.astro) is itself a
748
+ // .wd-example-panel, and a RequestExample/ResponseExample inside it
749
+ // moves with it rather than on its own.
750
+ const panels = Array.from(root.querySelectorAll<HTMLElement>(".wd-example-panel")).filter(
751
+ (panel) => !panel.parentElement?.closest(".wd-example-panel"),
752
+ );
681
753
  if (panels.length === 0) return;
682
754
  if (tocCol.dataset.wdExampleInit) return;
683
755
  tocCol.dataset.wdExampleInit = "true";
@@ -777,6 +849,24 @@ const components = {
777
849
  .wd-article-header h1 {
778
850
  margin: 0;
779
851
  }
852
+ /* Frontmatter `deprecated: true` (Mintlify's) - same amber as the
853
+ sidebar's own deprecated tag (NavTree.astro) and Parameter's
854
+ deprecated badge. */
855
+ .wd-article-title {
856
+ display: flex;
857
+ align-items: center;
858
+ flex-wrap: wrap;
859
+ gap: 0.6rem;
860
+ }
861
+ .wd-deprecated-badge {
862
+ display: inline-block;
863
+ padding: 0.15rem 0.55rem;
864
+ border-radius: 999px;
865
+ font-size: 0.8rem;
866
+ font-weight: 700;
867
+ color: #d97706;
868
+ background: color-mix(in srgb, #d97706 15%, transparent);
869
+ }
780
870
  .wd-prevnext {
781
871
  display: flex;
782
872
  justify-content: space-between;
@@ -991,6 +1081,19 @@ const components = {
991
1081
  background: var(--wd-surface);
992
1082
  border-bottom: 1px solid var(--wd-border);
993
1083
  }
1084
+ /* A fence's `icon="..."` (shiki-code-block.js) - the icon's own SVG
1085
+ arrives as a data URL in --wd-code-icon and is used as a mask, so it
1086
+ paints in the title's text color instead of the SVG's own fill. */
1087
+ .wd-code-title-icon {
1088
+ display: inline-block;
1089
+ width: 1em;
1090
+ height: 1em;
1091
+ margin-right: 0.45rem;
1092
+ vertical-align: -0.125em;
1093
+ background: currentColor;
1094
+ -webkit-mask: var(--wd-code-icon) center / contain no-repeat;
1095
+ mask: var(--wd-code-icon) center / contain no-repeat;
1096
+ }
994
1097
  .wd-code-copy-btn {
995
1098
  position: absolute;
996
1099
  top: 0.6rem;
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import type { APIRoute } from 'astro';
5
5
  import { loadDocsConfig, normalizeEntryId, findAllPages } from '../lib/config';
6
6
  import { writedocsTempDir } from '../lib/writedocs-temp-dir.js';
7
+ import { applyVisibilityForAgents } from '../lib/visibility.js';
7
8
 
8
9
  // The raw-Markdown twin of [...slug].astro: every content page is also
9
10
  // reachable at the exact same slug with a literal ".md" suffix (e.g.
@@ -52,6 +53,9 @@ export async function getStaticPaths() {
52
53
  // as an LLM-facing "view this page as text" export, so they're left
53
54
  // out of this route entirely rather than serving something misleading.
54
55
  .filter((entry) => !entry.data.openapi)
56
+ // A frontmatter `url` page's HTML route is a redirect to that link -
57
+ // there's no page content to serve as Markdown.
58
+ .filter((entry) => !entry.data.url)
55
59
  .map((entry) => ({
56
60
  params: { slug: normalizeEntryId(entry.id) },
57
61
  props: { entry },
@@ -70,7 +74,9 @@ export const GET: APIRoute = ({ props }) => {
70
74
  // it here means this route needs no separate file read/parse of its
71
75
  // own, and always matches exactly what [...slug].astro rendered from
72
76
  // (same entry, same collection query).
73
- const body = entry.body ?? '';
77
+ // Mintlify's <Visibility>: this is the agents' view of the page - see
78
+ // lib/visibility.js.
79
+ const body = applyVisibilityForAgents(entry.body ?? '');
74
80
  const markdown = `# ${entry.data.title}\n\n${body}`;
75
81
  return new Response(markdown, {
76
82
  headers: { 'Content-Type': 'text/markdown; charset=utf-8' },