@writedocs/generator 0.4.7 → 0.4.9

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.
@@ -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', 'Check', '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
 
@@ -0,0 +1,96 @@
1
+ import path from 'node:path';
2
+ import { visit } from 'unist-util-visit';
3
+ import { BUILTIN_COMPONENT_NAMES } from './mdx-inject-builtins.js';
4
+
5
+ // Which JSX elements in an MDX file are components writedocs can't resolve
6
+ // - neither a built-in nor something the file imports or defines itself.
7
+ // MDX fails the whole build on one ("Expected component `X` to be
8
+ // defined"), which turned every custom component in a migrated Mintlify
9
+ // project into a build-breaking hunt, one component per build. Shared by
10
+ // the build (remarkUnknownComponentFallback below) and `writedocs
11
+ // validate` (lib/content-check.js), so both agree on exactly which
12
+ // elements are unknown. Plain JavaScript so validate can load it with
13
+ // plain Node.
14
+
15
+ // Dotted built-ins: the root and the parts it has (see
16
+ // src/components/compound.ts). `<Tree.Leaf>` is unknown even though
17
+ // `Tree` isn't.
18
+ const BUILTIN_MEMBERS = {
19
+ Tree: ['Folder', 'File'],
20
+ FileTree: ['Folder', 'File'],
21
+ Color: ['Row', 'Item'],
22
+ GitHub: ['Repo'],
23
+ };
24
+
25
+ function localBindings(tree) {
26
+ const names = new Set();
27
+ visit(tree, 'mdxjsEsm', (node) => {
28
+ for (const stmt of node.data?.estree?.body ?? []) {
29
+ if (stmt.type === 'ImportDeclaration') {
30
+ for (const s of stmt.specifiers) if (s.local?.name) names.add(s.local.name);
31
+ } else if (stmt.type === 'ExportNamedDeclaration') {
32
+ const decl = stmt.declaration;
33
+ if (decl?.type === 'VariableDeclaration') {
34
+ for (const d of decl.declarations) if (d.id?.type === 'Identifier') names.add(d.id.name);
35
+ } else if (decl?.id?.name) {
36
+ names.add(decl.id.name); // export function X / export class X
37
+ }
38
+ for (const s of stmt.specifiers ?? []) if (s.exported?.name) names.add(s.exported.name);
39
+ }
40
+ }
41
+ });
42
+ return names;
43
+ }
44
+
45
+ /** Every JSX element in an MDX tree that names a component this file can't
46
+ * resolve, in document order: [{ node, name, line, column }]. Lowercase
47
+ * names are plain HTML elements and never count. */
48
+ export function findUnknownComponents(tree) {
49
+ const bound = localBindings(tree);
50
+ const unknown = [];
51
+ visit(tree, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node) => {
52
+ if (!node.name) return; // a fragment, <>...</>
53
+ const [root, ...members] = node.name.split('.');
54
+ if (!/^[A-Z]/.test(root)) return; // <div>, <svg>, <foo.bar> - HTML or a lowercase member expression
55
+ if (bound.has(root)) return;
56
+ if (BUILTIN_COMPONENT_NAMES.includes(root)) {
57
+ if (members.length === 0) return;
58
+ if (members.length === 1 && BUILTIN_MEMBERS[root]?.includes(members[0])) return;
59
+ }
60
+ unknown.push({
61
+ node,
62
+ name: node.name,
63
+ line: node.position?.start?.line,
64
+ column: node.position?.start?.column,
65
+ });
66
+ });
67
+ return unknown;
68
+ }
69
+
70
+ /**
71
+ * Remark plugin: an unknown component renders its children and nothing
72
+ * else - the element becomes a fragment - with a warning naming the file
73
+ * and line, instead of failing the build. The site still builds and reads
74
+ * sensibly (the component's text is still there), and the warning, plus
75
+ * `writedocs validate`, say what to fix.
76
+ */
77
+ export function remarkUnknownComponentFallback() {
78
+ return (tree, file) => {
79
+ const found = findUnknownComponents(tree);
80
+ if (found.length === 0) return;
81
+ const contentDir = process.env.WRITEDOCS_CONTENT_DIR;
82
+ const filePath = file.path
83
+ ? contentDir
84
+ ? path.relative(contentDir, file.path).split(path.sep).join('/')
85
+ : file.path
86
+ : '(unknown file)';
87
+ for (const { node, name, line } of found) {
88
+ console.warn(
89
+ `[writedocs] ${filePath}${line ? `:${line}` : ''} - unknown component <${name}>, showing only its content. ` +
90
+ 'Remove it, or define it in a snippet.'
91
+ );
92
+ node.name = null;
93
+ node.attributes = [];
94
+ }
95
+ };
96
+ }
@@ -0,0 +1,78 @@
1
+ // Which files in a content directory are pages. Plain JavaScript, not
2
+ // TypeScript, so `writedocs validate` can load it with plain Node (Node
3
+ // refuses to strip types under node_modules - see lib/icons.js's comment);
4
+ // lib/config.ts re-exports findAllPages() for everything inside Astro.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import matter from 'gray-matter';
8
+
9
+ // Top-level folders of a content directory that are never scanned for
10
+ // pages - build output, dependencies, and static assets.
11
+ export const EXCLUDED_TOP_LEVEL_DIRS = new Set(['node_modules', 'dist', '.astro', '.writedocs', 'public', '.git']);
12
+
13
+ /** Recursively finds every .md/.mdx file under `contentDir` that has a
14
+ * frontmatter block, skipping the handful of build/dependency
15
+ * directories a real content directory tends to also contain (see
16
+ * EXCLUDED_TOP_LEVEL_DIRS above) - docs/ is scanned exactly like any
17
+ * other folder, no special-casing. Returns POSIX-relative paths (from
18
+ * `contentDir`) suitable to hand straight to Astro's `glob()` loader as
19
+ * a literal `pattern` array - see content.config.ts's `pages`
20
+ * collection, and [...slug].astro, which needs the identical list to
21
+ * decide whether calling `getCollection('pages')` is worth doing at all
22
+ * (see content.config.ts's own comment on why an empty collection still
23
+ * needs to exist, just backed by a no-op loader, to avoid Astro's "does
24
+ * not exist or is empty" warning). Also reused directly by
25
+ * astro.config.mjs's noindex/sitemap scan and by `writedocs validate`, so
26
+ * both always see exactly the same file set that actually becomes a page.
27
+ * Synchronous and re-run from scratch wherever it's called rather than
28
+ * cached and shared across modules - cheap enough in practice (a docs
29
+ * site's own file count) not to matter. */
30
+ export function findAllPages(contentDir) {
31
+ const results = [];
32
+ function walk(dir, relBase) {
33
+ let entries;
34
+ try {
35
+ entries = fs.readdirSync(dir, { withFileTypes: true });
36
+ } catch {
37
+ return;
38
+ }
39
+ for (const entry of entries) {
40
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
41
+ const abs = path.join(dir, entry.name);
42
+ if (entry.isDirectory()) {
43
+ if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
44
+ walk(abs, rel);
45
+ continue;
46
+ }
47
+ if (!entry.isFile() || !/\.mdx?$/i.test(entry.name)) continue;
48
+ let raw;
49
+ try {
50
+ raw = fs.readFileSync(abs, 'utf-8');
51
+ } catch {
52
+ continue;
53
+ }
54
+ let data;
55
+ try {
56
+ ({ data } = matter(raw));
57
+ } catch {
58
+ // Frontmatter that isn't valid YAML: still a page (it opens a
59
+ // frontmatter block), so the error surfaces with this file's name -
60
+ // from `writedocs validate`, or Astro's own loader in the build -
61
+ // instead of as a bare YAML error thrown from here.
62
+ if (/^---\r?\n/.test(raw)) results.push(rel);
63
+ continue;
64
+ }
65
+ if (Object.keys(data).length === 0) continue; // no frontmatter at all - not a page
66
+ results.push(rel);
67
+ }
68
+ }
69
+ walk(contentDir, '');
70
+ return results;
71
+ }
72
+
73
+ /** The id writedocs.json's navigation refers to a page by: its path from
74
+ * the content directory, without the extension or a trailing `/index`
75
+ * (same algorithm as fileIdForEntry() in lib/config.ts). */
76
+ export function fileIdForPath(relativePath) {
77
+ return relativePath.replace(/\.mdx?$/i, '').replace(/\/index$/, '');
78
+ }
@@ -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
+ }
@@ -56,6 +56,13 @@ import ParamField from "../components/ParamField.astro";
56
56
  import ResponseField from "../components/ResponseField.astro";
57
57
  import Columns from "../components/Columns.astro";
58
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";
59
66
  import ApiPlayground from "../components/ApiPlayground.astro";
60
67
  import ApiReferencePanel from "../components/ApiReferencePanel.astro";
61
68
 
@@ -438,6 +445,16 @@ const components = {
438
445
  ResponseField,
439
446
  Columns,
440
447
  Tooltip,
448
+ Update,
449
+ Tile,
450
+ Panel,
451
+ Prompt,
452
+ View,
453
+ Visibility,
454
+ Tree,
455
+ FileTree,
456
+ Color,
457
+ GitHub,
441
458
  };
442
459
  ---
443
460
 
@@ -727,7 +744,12 @@ const components = {
727
744
  const tocCol = document.querySelector<HTMLElement>(".wd-toc-col");
728
745
  if (!tocCol) return; // no toc column on this page (mode !== 'default') - nothing to dock into
729
746
 
730
- 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
+ );
731
753
  if (panels.length === 0) return;
732
754
  if (tocCol.dataset.wdExampleInit) return;
733
755
  tocCol.dataset.wdExampleInit = "true";
@@ -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.
@@ -73,7 +74,9 @@ export const GET: APIRoute = ({ props }) => {
73
74
  // it here means this route needs no separate file read/parse of its
74
75
  // own, and always matches exactly what [...slug].astro rendered from
75
76
  // (same entry, same collection query).
76
- 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 ?? '');
77
80
  const markdown = `# ${entry.data.title}\n\n${body}`;
78
81
  return new Response(markdown, {
79
82
  headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
@@ -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 "everything, concatenated" half of the llms.txt pair - see
9
10
  // llms.txt.ts (right next to this file) for the lightweight index half,
@@ -53,7 +54,8 @@ export const GET: APIRoute = async () => {
53
54
  .filter((entry) => !entry.data.url)
54
55
  .map((entry: DocsEntry) => {
55
56
  const slug = normalizeEntryId(entry.id);
56
- const body = entry.body ?? '';
57
+ // Agents' view of <Visibility> blocks, same as the .md route.
58
+ const body = applyVisibilityForAgents(entry.body ?? '');
57
59
  return { slug, title: entry.data.title, body };
58
60
  })
59
61
  .sort((a, b) => a.slug.localeCompare(b.slug));