@writedocs/generator 0.4.8 → 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.
package/src/lib/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { createRequire } from 'node:module';
3
+ import { EXCLUDED_TOP_LEVEL_DIRS } from './pages.js';
4
4
  import matter from 'gray-matter';
5
5
  import { writedocsTempDir } from './writedocs-temp-dir.js';
6
6
  import {
@@ -267,92 +267,9 @@ export type IconResolution =
267
267
  | { kind: 'image'; src: string } // an image URL or site path, rendered as <img>
268
268
  | { kind: 'text'; value: string }; // literal text/emoji, rendered as-is
269
269
 
270
- const DEFAULT_ICON_COLLECTION = 'lucide';
271
-
272
- // Where a bare icon name is looked up, in order. Lucide stays first so no
273
- // existing site's icons change; Font Awesome comes after because it's
274
- // Mintlify's default library - Mintlify content writes `icon="gear"` or
275
- // `icon="discord"`, names Lucide doesn't have, and astro-icon fails the
276
- // whole build on a name it can't find. Solid before brands, same as
277
- // Mintlify's own lookup.
278
- const BARE_ICON_COLLECTIONS = [DEFAULT_ICON_COLLECTION, 'fa6-solid', 'fa6-brands'];
279
-
280
- // Loaded lazily, once per collection, from the installed @iconify-json/*
281
- // packages (the same data astro-icon itself renders from). Resolved
282
- // against this package, not the site being built - WRITEDOCS_PACKAGE_ROOT
283
- // is set by run-astro.js; outside that subprocess, this file's own URL
284
- // still sits inside the package.
285
- interface IconifyCollection {
286
- icons?: Record<string, { body: string; width?: number; height?: number }>;
287
- aliases?: Record<string, { parent: string; width?: number; height?: number }>;
288
- width?: number;
289
- height?: number;
290
- }
291
- const iconCollections = new Map<string, IconifyCollection | null>();
292
- function loadIconCollection(collection: string): IconifyCollection | null {
293
- if (!iconCollections.has(collection)) {
294
- const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT;
295
- const require = createRequire(packageRoot ? path.join(packageRoot, 'package.json') : import.meta.url);
296
- let data: IconifyCollection | null = null;
297
- try {
298
- data = require(`@iconify-json/${collection}/icons.json`);
299
- } catch {
300
- data = null;
301
- }
302
- iconCollections.set(collection, data);
303
- }
304
- return iconCollections.get(collection) ?? null;
305
- }
306
- function collectionHasIcon(collection: string, name: string): boolean {
307
- const data = loadIconCollection(collection);
308
- return Boolean(data && (data.icons?.[name] || data.aliases?.[name]));
309
- }
310
-
311
- /** A standalone <svg> for an icon string, or null if it isn't an installed
312
- * Iconify icon (emoji/text, or a name no collection has). For the few
313
- * places that build HTML outside an Astro component and so can't use
314
- * astro-icon's <Icon> - a code block's title bar is built as HAST inside
315
- * a Shiki transformer (see shiki-code-block.js). Follows one level of
316
- * Iconify alias, which is all the installed collections use. */
317
- export function iconSvg(icon: string): string | null {
318
- const resolved = resolveIcon(icon);
319
- if (resolved.kind !== 'iconify') return null;
320
- const [collection, name] = resolved.name.split(':');
321
- const data = loadIconCollection(collection);
322
- if (!data) return null;
323
- const alias = data.aliases?.[name];
324
- const entry = data.icons?.[alias ? alias.parent : name];
325
- if (!entry) return null;
326
- const width = entry.width ?? alias?.width ?? data.width ?? 16;
327
- const height = entry.height ?? alias?.height ?? data.height ?? 16;
328
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${entry.body}</svg>`;
329
- }
330
-
331
- /** Resolves a writedocs.json `icon` string into either an Iconify icon id or
332
- * literal text, covering three forms:
333
- * - "collection:icon-name" (e.g. "mdi:server", "simple-icons:github")
334
- * - used as-is against whatever @iconify-json/* collections are
335
- * installed (see astro.config.mjs / package.json).
336
- * - a bare name using only letters/digits/hyphens (e.g. "smartphone",
337
- * "book-open") - looked up in BARE_ICON_COLLECTIONS order (lucide,
338
- * then Font Awesome solid, then brands), using the first collection
339
- * that has it. A name none of them has still resolves to "lucide:...",
340
- * so the build error names the same collection it always did.
341
- * - an image URL or path - "https://...", "//...", "/icons/x.svg",
342
- * "./x.png" - rendered as an <img>. Mintlify accepts these for any
343
- * icon; before this they were printed as literal text.
344
- * - anything else (an emoji, a symbol, arbitrary text) - rendered
345
- * verbatim, preserving the original "just paste an emoji" behavior
346
- * from before icon-library support existed. */
347
- export function resolveIcon(icon: string): IconResolution {
348
- if (/^(?:https?:)?\/\/|^\.{0,2}\//i.test(icon)) return { kind: 'image', src: icon };
349
- if (/^[a-z0-9-]+:[a-z0-9-]+$/i.test(icon)) return { kind: 'iconify', name: icon };
350
- if (/^[a-z0-9-]+$/i.test(icon)) {
351
- const collection = BARE_ICON_COLLECTIONS.find((c) => collectionHasIcon(c, icon)) ?? DEFAULT_ICON_COLLECTION;
352
- return { kind: 'iconify', name: `${collection}:${icon}` };
353
- }
354
- return { kind: 'text', value: icon };
355
- }
270
+ // The implementation lives in lib/icons.js - plain JavaScript, because
271
+ // `writedocs validate` loads it with plain Node (see that file's comment).
272
+ export { resolveIcon, iconExists, iconSvg } from './icons.js';
356
273
 
357
274
  // ---------------------------------------------------------------------
358
275
  // OpenAPI navigation expansion - turns a group's `openapi: { src, path }`
@@ -560,59 +477,9 @@ export function loadDocsConfig(contentDir: string): DocsConfig {
560
477
  // hand-authored `guides/dist-notes.mdx` isn't mistaken for the build
561
478
  // output directory `dist/` just because a folder two levels down happens
562
479
  // to also be named `dist`).
563
- const EXCLUDED_TOP_LEVEL_DIRS = new Set(['node_modules', 'dist', '.astro', '.writedocs', 'public', '.git']);
564
-
565
- /** Recursively finds every .md/.mdx file under `contentDir` that has a
566
- * frontmatter block, skipping the handful of build/dependency
567
- * directories a real content directory tends to also contain (see
568
- * EXCLUDED_TOP_LEVEL_DIRS above) - docs/ is scanned exactly like any
569
- * other folder, no special-casing. Returns POSIX-relative paths (from
570
- * `contentDir`) suitable to hand straight to Astro's `glob()` loader as
571
- * a literal `pattern` array - see content.config.ts's `pages`
572
- * collection, and [...slug].astro, which needs the identical list to
573
- * decide whether calling `getCollection('pages')` is worth doing at all
574
- * (see content.config.ts's own comment on why an empty collection still
575
- * needs to exist, just backed by a no-op loader, to avoid Astro's "does
576
- * not exist or is empty" warning). Also reused directly by
577
- * astro.config.mjs's noindex/sitemap scan, so that scan always sees
578
- * exactly the same file set that actually becomes a page - no risk of
579
- * the two drifting apart. Synchronous and re-run from scratch wherever
580
- * it's called rather than cached and shared across modules - consistent
581
- * with how loadDocsConfig() itself is already called repeatedly across
582
- * this codebase instead of threaded through as shared state, and cheap
583
- * enough in practice (a docs site's own file count) not to matter. */
584
- export function findAllPages(contentDir: string): string[] {
585
- const results: string[] = [];
586
- function walk(dir: string, relBase: string) {
587
- let entries: fs.Dirent[];
588
- try {
589
- entries = fs.readdirSync(dir, { withFileTypes: true });
590
- } catch {
591
- return;
592
- }
593
- for (const entry of entries) {
594
- const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
595
- const abs = path.join(dir, entry.name);
596
- if (entry.isDirectory()) {
597
- if (relBase === '' && EXCLUDED_TOP_LEVEL_DIRS.has(entry.name)) continue;
598
- walk(abs, rel);
599
- continue;
600
- }
601
- if (!entry.isFile() || !/\.mdx?$/i.test(entry.name)) continue;
602
- let raw: string;
603
- try {
604
- raw = fs.readFileSync(abs, 'utf-8');
605
- } catch {
606
- continue;
607
- }
608
- const { data } = matter(raw);
609
- if (Object.keys(data).length === 0) continue; // no frontmatter at all - not a page
610
- results.push(rel);
611
- }
612
- }
613
- walk(contentDir, '');
614
- return results;
615
- }
480
+ // EXCLUDED_TOP_LEVEL_DIRS and findAllPages() live in lib/pages.js - plain
481
+ // JavaScript, so `writedocs validate` can find a site's pages with plain Node.
482
+ export { findAllPages } from './pages.js';
616
483
 
617
484
  /** Any `.css`/`.js` file anywhere in the project - root or any subfolder,
618
485
  * `public/` included - auto-loaded site-wide with zero `writedocs.json`
@@ -0,0 +1,232 @@
1
+ // `writedocs validate`'s content pass: everything in a site's pages that
2
+ // would break the build or silently degrade it, found in one run instead of
3
+ // one build failure at a time. Plain JavaScript, loaded by plain Node from
4
+ // an installed package (see lib/icons.js's comment on why not TypeScript).
5
+ //
6
+ // Errors - the build would fail:
7
+ // - a page's frontmatter doesn't match the page schema (the same
8
+ // pageFrontmatterSchema the build uses) or isn't valid YAML
9
+ // - an .mdx file doesn't parse as MDX
10
+ // - writedocs.json's navigation lists a page that doesn't exist
11
+ // Warnings - the build succeeds, but not as written:
12
+ // - an unknown component (the build shows only its content - see
13
+ // lib/mdx-unknown-components.js)
14
+ // - an icon name no installed icon set has (the build leaves it out -
15
+ // see AppIcon.astro), in a component's `icon`, a page's `icon`, or
16
+ // writedocs.json
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import matter from 'gray-matter';
20
+ import { visit } from 'unist-util-visit';
21
+ import { findAllPages, fileIdForPath } from './pages.js';
22
+ import { iconExists } from './icons.js';
23
+ import { findUnknownComponents } from './mdx-unknown-components.js';
24
+ import { pageFrontmatterSchema, createJsonLocator } from './config-schema.js';
25
+
26
+ /** An issue: { file, line?, message, suggestion? } - `file` relative to the
27
+ * content directory, POSIX slashes. */
28
+ function issue(file, line, message, suggestion) {
29
+ return { file, line, message, suggestion };
30
+ }
31
+
32
+ function lineOfFrontmatterKey(frontmatterText, key) {
33
+ const lines = frontmatterText.split('\n');
34
+ const re = new RegExp(`^["']?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']?\\s*:`);
35
+ const i = lines.findIndex((l) => re.test(l));
36
+ // +2: the opening `---` line, and 1-based numbering.
37
+ return i === -1 ? 2 : i + 2;
38
+ }
39
+
40
+ function unknownIconMessage(icon) {
41
+ return {
42
+ message: `Unknown icon "${icon}" - no installed icon set has it, so the build leaves it out.`,
43
+ suggestion:
44
+ 'Use a Lucide or Font Awesome (free) name, a "collection:name" pair such as "mdi:server", an emoji, or an image path.',
45
+ };
46
+ }
47
+
48
+ let mdxProcessor = null;
49
+ async function parseMdx(text) {
50
+ if (!mdxProcessor) {
51
+ const { createProcessor } = await import('@mdx-js/mdx');
52
+ mdxProcessor = createProcessor({ format: 'mdx' });
53
+ }
54
+ return mdxProcessor.parse(text);
55
+ }
56
+
57
+ async function checkPage(contentDir, rel, errors, warnings) {
58
+ const raw = fs.readFileSync(path.join(contentDir, rel), 'utf-8').replace(/\r\n/g, '\n');
59
+ let parsed;
60
+ try {
61
+ // An options object bypasses gray-matter's cache, which keeps an empty
62
+ // result even for input that threw - findAllPages() already parsed
63
+ // this file once, so the cache would hide a YAML error here.
64
+ parsed = matter(raw, {});
65
+ } catch (err) {
66
+ // gray-matter hands js-yaml the frontmatter with its leading newline, so
67
+ // js-yaml's 0-based line is already one past the opening `---`.
68
+ const line = typeof err?.mark?.line === 'number' ? err.mark.line + 1 : 1;
69
+ errors.push(issue(rel, line, `The frontmatter isn't valid YAML: ${err.reason ?? err.message}`, 'A value containing ": " must be quoted.'));
70
+ return;
71
+ }
72
+
73
+ // The frontmatter's own text, for line numbers - read from the file, not
74
+ // gray-matter's `matter` property, which is missing on the results it
75
+ // returns from its cache (findAllPages() already parsed every page once).
76
+ const frontmatterText = raw.match(/^---[^\n]*\n([\s\S]*?)\n---/)?.[1] ?? '';
77
+
78
+ // Frontmatter against the same schema the build uses.
79
+ const result = pageFrontmatterSchema.safeParse(parsed.data);
80
+ if (!result.success) {
81
+ for (const i of result.error.issues) {
82
+ const key = String(i.path[0] ?? '');
83
+ const where = i.path.length ? `\`${i.path.join('.')}\`: ` : '';
84
+ errors.push(issue(rel, key ? lineOfFrontmatterKey(frontmatterText, key) : 2, `Frontmatter ${where}${i.message}`));
85
+ }
86
+ }
87
+ if (typeof parsed.data.icon === 'string' && !iconExists(parsed.data.icon)) {
88
+ const { message, suggestion } = unknownIconMessage(parsed.data.icon);
89
+ warnings.push(issue(rel, lineOfFrontmatterKey(frontmatterText, 'icon'), message, suggestion));
90
+ }
91
+
92
+ // The rest only applies to MDX - a .md page is plain Markdown, no JSX.
93
+ if (!/\.mdx$/i.test(rel) || !parsed.content.trim()) return;
94
+ const bodyStart = raw.lastIndexOf(parsed.content);
95
+ const lineOffset = bodyStart > 0 ? raw.slice(0, bodyStart).split('\n').length - 1 : 0;
96
+ let tree;
97
+ try {
98
+ tree = await parseMdx(parsed.content);
99
+ } catch (err) {
100
+ // Some MDX errors carry their position only inside the message, as
101
+ // "(line:column-line:column)".
102
+ const fromReason = /\((\d+):\d+(?:-\d+:\d+)?\)/.exec(err.reason ?? '')?.[1];
103
+ const line = err.line ?? err.place?.start?.line ?? err.place?.line ?? (fromReason ? Number(fromReason) : undefined);
104
+ errors.push(
105
+ issue(
106
+ rel,
107
+ line ? line + lineOffset : undefined,
108
+ // The position in the message counts from the end of the
109
+ // frontmatter; the file:line above is the real one.
110
+ `This page doesn't parse as MDX: ${String(err.reason ?? err.message).replace(/\s*\(\d+:\d+(?:-\d+:\d+)?\)$/, '')}`
111
+ )
112
+ );
113
+ return;
114
+ }
115
+ for (const { name, line } of findUnknownComponents(tree)) {
116
+ warnings.push(
117
+ issue(
118
+ rel,
119
+ line ? line + lineOffset : undefined,
120
+ `Unknown component <${name}> - the build shows only its content.`,
121
+ 'Remove it, use a writedocs component instead, or define it in a snippet.'
122
+ )
123
+ );
124
+ }
125
+ visit(tree, ['mdxJsxFlowElement', 'mdxJsxTextElement'], (node) => {
126
+ const attr = node.attributes?.find((a) => a.type === 'mdxJsxAttribute' && a.name === 'icon');
127
+ if (!attr || typeof attr.value !== 'string' || iconExists(attr.value)) return;
128
+ const { message, suggestion } = unknownIconMessage(attr.value);
129
+ const line = node.position?.start?.line;
130
+ warnings.push(issue(rel, line ? line + lineOffset : undefined, message, suggestion));
131
+ });
132
+ }
133
+
134
+ /** Every page a navigation references: strings inside arrays, and `page`
135
+ * fields (a group's own page). Returns [{ id, path }] with the JSON path
136
+ * for line lookup. */
137
+ function navigationReferences(navigation) {
138
+ const refs = [];
139
+ (function walk(node, trail) {
140
+ if (Array.isArray(node)) {
141
+ node.forEach((item, i) => {
142
+ if (typeof item === 'string') refs.push({ id: item, path: [...trail, i] });
143
+ else walk(item, [...trail, i]);
144
+ });
145
+ } else if (node && typeof node === 'object') {
146
+ for (const [key, value] of Object.entries(node)) {
147
+ if (key === 'page' && typeof value === 'string') refs.push({ id: value, path: [...trail, key] });
148
+ else if (key !== 'openapi' && key !== 'href') walk(value, [...trail, key]);
149
+ }
150
+ }
151
+ })(navigation, ['navigation']);
152
+ return refs;
153
+ }
154
+
155
+ /** Every `icon` string in writedocs.json, plus the `socials` keys (each
156
+ * one doubles as its icon - see SiteFooter.astro). */
157
+ function configIcons(config) {
158
+ const found = [];
159
+ (function walk(node, trail) {
160
+ if (Array.isArray(node)) node.forEach((item, i) => walk(item, [...trail, i]));
161
+ else if (node && typeof node === 'object') {
162
+ for (const [key, value] of Object.entries(node)) {
163
+ if (key === 'icon' && typeof value === 'string') found.push({ icon: value, path: [...trail, key] });
164
+ else walk(value, [...trail, key]);
165
+ }
166
+ }
167
+ })(config, []);
168
+ if (config?.socials && typeof config.socials === 'object') {
169
+ for (const key of Object.keys(config.socials)) found.push({ icon: key, path: ['socials', key] });
170
+ }
171
+ return found;
172
+ }
173
+
174
+ /**
175
+ * Checks a content directory. `configText` is writedocs.json's raw text, or
176
+ * null when it isn't valid JSON (then only the pages themselves are
177
+ * checked). Returns { pages, errors, warnings }.
178
+ */
179
+ export async function checkContent(contentDir, configText) {
180
+ const errors = [];
181
+ const warnings = [];
182
+ const pages = findAllPages(contentDir);
183
+ for (const rel of pages) await checkPage(contentDir, rel, errors, warnings);
184
+
185
+ let config = null;
186
+ if (configText !== null) {
187
+ try {
188
+ config = JSON.parse(configText);
189
+ } catch {
190
+ config = null;
191
+ }
192
+ }
193
+ if (config) {
194
+ const locate = createJsonLocator(configText);
195
+ const ids = new Set(pages.map(fileIdForPath));
196
+ for (const ref of navigationReferences(config.navigation)) {
197
+ if (ids.has(ref.id)) continue;
198
+ const withoutFrontmatter = ['.mdx', '.md'].some((ext) => fs.existsSync(path.join(contentDir, `${ref.id}${ext}`)));
199
+ errors.push(
200
+ issue(
201
+ 'writedocs.json',
202
+ locate(ref.path)?.line,
203
+ `The navigation lists page "${ref.id}", but there's no page with that path.`,
204
+ withoutFrontmatter
205
+ ? `${ref.id} exists but has no frontmatter, so it isn't a page - add a frontmatter block with at least a title.`
206
+ : `Create ${ref.id}.mdx, or fix the path (it's relative to the folder writedocs.json is in, without the extension).`
207
+ )
208
+ );
209
+ }
210
+ for (const { icon, path: jsonPath } of configIcons(config)) {
211
+ if (iconExists(icon)) continue;
212
+ const { message, suggestion } = unknownIconMessage(icon);
213
+ warnings.push(issue('writedocs.json', locate(jsonPath)?.line, message, suggestion));
214
+ }
215
+ }
216
+
217
+ const byLocation = (a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0);
218
+ errors.sort(byLocation);
219
+ warnings.sort(byLocation);
220
+ return { pages: pages.length, errors, warnings };
221
+ }
222
+
223
+ /** Same layout as formatValidationIssuesDetailed() for writedocs.json. */
224
+ export function formatContentIssues(issues) {
225
+ return issues
226
+ .map((i) => {
227
+ const lines = [` ${i.file}${i.line ? `:${i.line}` : ''}`, ` ${i.message}`];
228
+ if (i.suggestion) lines.push(` ${i.suggestion}`);
229
+ return lines.join('\n');
230
+ })
231
+ .join('\n\n');
232
+ }
@@ -0,0 +1,109 @@
1
+ // Icon strings - writedocs.json's `icon` fields (TabItem, DropdownItem,
2
+ // ProductItem, Card, ...) and every component/frontmatter `icon` prop - are
3
+ // plain strings with no schema-level distinction between "an emoji, paste
4
+ // it verbatim", "an image path" and "an icon-set name, look it up".
5
+ // Resolved here rather than validated in the schema, since all are valid
6
+ // uses of the same string field and the right rendering only becomes
7
+ // obvious once you look at the value's shape.
8
+ //
9
+ // Plain JavaScript, not TypeScript, on purpose: `writedocs validate` loads
10
+ // this with plain Node to check icon names, and Node refuses to strip types
11
+ // from any file under node_modules - so a .ts version would work from the
12
+ // checkout and fail for everyone who installs the package. lib/config.ts
13
+ // re-exports these for everything that runs inside Astro.
14
+ import path from 'node:path';
15
+ import { createRequire } from 'node:module';
16
+
17
+ const DEFAULT_ICON_COLLECTION = 'lucide';
18
+
19
+ // Where a bare icon name is looked up, in order. Lucide stays first so no
20
+ // existing site's icons change; Font Awesome comes after because it's
21
+ // Mintlify's default library - Mintlify content writes `icon="gear"` or
22
+ // `icon="discord"`, names Lucide doesn't have. Solid before brands, same as
23
+ // Mintlify's own lookup.
24
+ const BARE_ICON_COLLECTIONS = [DEFAULT_ICON_COLLECTION, 'fa6-solid', 'fa6-brands'];
25
+
26
+ // Loaded lazily, once per collection, from the installed @iconify-json/*
27
+ // packages (the same data astro-icon itself renders from). Resolved
28
+ // against this package, not the site being built - WRITEDOCS_PACKAGE_ROOT
29
+ // is set by run-astro.js; outside that subprocess, this file's own URL
30
+ // still sits inside the package.
31
+ const iconCollections = new Map();
32
+ function loadIconCollection(collection) {
33
+ if (!iconCollections.has(collection)) {
34
+ const packageRoot = process.env.WRITEDOCS_PACKAGE_ROOT;
35
+ const require = createRequire(packageRoot ? path.join(packageRoot, 'package.json') : import.meta.url);
36
+ let data = null;
37
+ try {
38
+ data = require(`@iconify-json/${collection}/icons.json`);
39
+ } catch {
40
+ data = null;
41
+ }
42
+ iconCollections.set(collection, data);
43
+ }
44
+ return iconCollections.get(collection) ?? null;
45
+ }
46
+ function collectionHasIcon(collection, name) {
47
+ const data = loadIconCollection(collection);
48
+ return Boolean(data && (data.icons?.[name] || data.aliases?.[name]));
49
+ }
50
+
51
+ /** Resolves an icon string into an Iconify icon id, an image, or literal
52
+ * text, covering four forms:
53
+ * - "collection:icon-name" (e.g. "mdi:server", "simple-icons:github")
54
+ * - used as-is against whatever @iconify-json/* collections are
55
+ * installed (see astro.config.mjs / package.json).
56
+ * - a bare name using only letters/digits/hyphens (e.g. "smartphone",
57
+ * "book-open") - looked up in BARE_ICON_COLLECTIONS order (lucide,
58
+ * then Font Awesome solid, then brands), using the first collection
59
+ * that has it. A name none of them has resolves to "lucide:..." -
60
+ * iconExists() is how callers tell it doesn't exist.
61
+ * - an image URL or path - "https://...", "//...", "/icons/x.svg",
62
+ * "./x.png" - rendered as an <img>. Mintlify accepts these for any
63
+ * icon.
64
+ * - anything else (an emoji, a symbol, arbitrary text) - rendered
65
+ * verbatim, preserving the original "just paste an emoji" behavior
66
+ * from before icon-library support existed.
67
+ *
68
+ * Returns { kind: 'iconify', name } | { kind: 'image', src } | { kind: 'text', value }. */
69
+ export function resolveIcon(icon) {
70
+ if (/^(?:https?:)?\/\/|^\.{0,2}\//i.test(icon)) return { kind: 'image', src: icon };
71
+ if (/^[a-z0-9-]+:[a-z0-9-]+$/i.test(icon)) return { kind: 'iconify', name: icon };
72
+ if (/^[a-z0-9-]+$/i.test(icon)) {
73
+ const collection = BARE_ICON_COLLECTIONS.find((c) => collectionHasIcon(c, icon)) ?? DEFAULT_ICON_COLLECTION;
74
+ return { kind: 'iconify', name: `${collection}:${icon}` };
75
+ }
76
+ return { kind: 'text', value: icon };
77
+ }
78
+
79
+ /** False only for an icon *name* that no installed collection has (e.g.
80
+ * "gearz", or "mdi:not-a-real-icon") - text, emoji and image icons always
81
+ * exist, as far as this can tell. astro-icon fails the whole build on a
82
+ * missing name, so AppIcon.astro checks this first and renders nothing
83
+ * (with a warning) instead; `writedocs validate` reports the same names. */
84
+ export function iconExists(icon) {
85
+ const resolved = resolveIcon(icon);
86
+ if (resolved.kind !== 'iconify') return true;
87
+ const [collection, name] = resolved.name.split(':');
88
+ return collectionHasIcon(collection, name);
89
+ }
90
+
91
+ /** A standalone <svg> for an icon string, or null if it isn't an installed
92
+ * Iconify icon (emoji/text, or a name no collection has). For the few
93
+ * places that build HTML outside an Astro component and so can't use
94
+ * astro-icon's <Icon> - a code block's title bar is built as HAST inside
95
+ * a Shiki transformer (see shiki-code-block.js). Follows one level of
96
+ * Iconify alias, which is all the installed collections use. */
97
+ export function iconSvg(icon) {
98
+ const resolved = resolveIcon(icon);
99
+ if (resolved.kind !== 'iconify') return null;
100
+ const [collection, name] = resolved.name.split(':');
101
+ const data = loadIconCollection(collection);
102
+ if (!data) return null;
103
+ const alias = data.aliases?.[name];
104
+ const entry = data.icons?.[alias ? alias.parent : name];
105
+ if (!entry) return null;
106
+ const width = entry.width ?? alias?.width ?? data.width ?? 16;
107
+ const height = entry.height ?? alias?.height ?? data.height ?? 16;
108
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}">${entry.body}</svg>`;
109
+ }
@@ -7,7 +7,7 @@ import { parse as acornParse } from 'acorn';
7
7
  // literal list rather than importing that map here, since this runs inside
8
8
  // astro.config.mjs's markdown pipeline, a different module graph than
9
9
  // [...slug].astro's own.
10
- const BUILTIN_COMPONENT_NAMES = [
10
+ export const BUILTIN_COMPONENT_NAMES = [
11
11
  'Callout',
12
12
  'Note',
13
13
  'Info',
@@ -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
+ }