@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/astro.config.mjs +6 -0
- package/bin/writedocs.js +167 -139
- package/package.json +2 -1
- package/src/components/AppIcon.astro +14 -2
- package/src/content.config.ts +6 -127
- package/src/lib/config-schema.js +124 -0
- package/src/lib/config-schema.ts +1574 -1437
- package/src/lib/config.ts +7 -140
- package/src/lib/content-check.js +232 -0
- package/src/lib/icons.js +109 -0
- package/src/lib/mdx-inject-builtins.js +1 -1
- package/src/lib/mdx-unknown-components.js +96 -0
- package/src/lib/pages.js +78 -0
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 {
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
+
}
|
package/src/lib/icons.js
ADDED
|
@@ -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
|
+
}
|