@writedocs/generator 0.4.11 → 0.5.0

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,473 @@
1
+ // `writedocs broken-links`: every internal link in the content - page
2
+ // bodies, the snippets they import, and writedocs.json - checked against the
3
+ // site the build would produce, without building it. Plain JavaScript,
4
+ // loaded by plain Node from an installed package (see lib/icons.js's
5
+ // comment on why not TypeScript).
6
+ //
7
+ // A link is broken when, on the built site:
8
+ // - no page, redirect or file is at its address;
9
+ // - its #anchor isn't on the target page (headings, titled components -
10
+ // Callout/Accordion/Update and friends - and elements with an `id`);
11
+ // - it names a file the build doesn't publish (anything outside public/
12
+ // other than images and fonts - see lib/styles-asset-integration.js).
13
+ //
14
+ // Addresses are worked out the way the build works them out:
15
+ // - a page's URL is its frontmatter `slug`, or else its file path with
16
+ // every segment put through github-slugger - Astro's own glob-loader
17
+ // id (getContentEntryIdAndSlug in astro/dist/content/utils.js), which
18
+ // is why `docs/API Guide/v1.2.mdx` is served at /docs/api-guide/v12/;
19
+ // - heading ids come from github-slugger too, deduped per page the way
20
+ // Astro's rehypeHeadingIds does; titled components' ids from
21
+ // lib/mdx-title-anchor-ids.js's own rule;
22
+ // - a relative link resolves the way a browser resolves it, from the
23
+ // page's URL - which ends in a slash (/docs/guides/setup/), so
24
+ // `install` on that page means /docs/guides/setup/install/, not a
25
+ // sibling page. Mintlify's URLs have no trailing slash, so migrated
26
+ // relative links are the usual casualty; the message says so.
27
+ //
28
+ // External links (http:, mailto:, ...) aren't checked.
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { createRequire } from 'node:module';
32
+ import { pathToFileURL } from 'node:url';
33
+ import matter from 'gray-matter';
34
+ import { visit } from 'unist-util-visit';
35
+ import { findAllPages } from './pages.js';
36
+ import { createJsonLocator } from './config-schema.js';
37
+ import { writedocsTempDir } from './writedocs-temp-dir.js';
38
+
39
+ // The same github-slugger Astro builds page URLs and heading ids with -
40
+ // resolved through Astro itself, so the two can't drift apart.
41
+ let sluggerModule = null;
42
+ async function loadSlugger() {
43
+ if (!sluggerModule) {
44
+ const require = createRequire(import.meta.url);
45
+ const fromAstro = createRequire(require.resolve('astro/package.json'));
46
+ sluggerModule = await import(pathToFileURL(fromAstro.resolve('github-slugger')).href);
47
+ }
48
+ return sluggerModule;
49
+ }
50
+
51
+ const processors = {};
52
+ async function parse(text, format) {
53
+ if (!processors[format]) {
54
+ const { createProcessor } = await import('@mdx-js/mdx');
55
+ processors[format] = createProcessor({ format });
56
+ }
57
+ return processors[format].parse(text);
58
+ }
59
+
60
+ // Images and fonts are published from anywhere in the project; every other
61
+ // file only from public/ (lib/styles-asset-integration.js).
62
+ const ASSET_EXTENSIONS = new Set(['.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.woff', '.woff2', '.ttf', '.otf']);
63
+ // lib/mdx-title-anchor-ids.js
64
+ const TITLED_COMPONENTS = ['Callout', 'Note', 'Info', 'Tip', 'Warning', 'Danger', 'Check', 'Accordion', 'Update'];
65
+ const titleSlug = (value) =>
66
+ value
67
+ .toLowerCase()
68
+ .replace(/[^a-z0-9]+/g, '-')
69
+ .replace(/^-+|-+$/g, '');
70
+
71
+ const BASE = 'http://writedocs.invalid';
72
+
73
+ function normalizeEntryId(id) {
74
+ const trimmed = String(id).replace(/^\/+/, '').replace(/\/+$/, '');
75
+ return trimmed === '' ? 'index' : trimmed;
76
+ }
77
+ const urlForId = (id) => (id === 'index' ? '/' : `/${id}/`);
78
+
79
+ function textOf(node) {
80
+ if (typeof node.value === 'string' && (node.type === 'text' || node.type === 'inlineCode')) return node.value;
81
+ return (node.children ?? []).map(textOf).join('');
82
+ }
83
+
84
+ function attribute(node, name) {
85
+ const attr = node.attributes?.find((a) => a.type === 'mdxJsxAttribute' && a.name === name);
86
+ return attr && typeof attr.value === 'string' ? attr.value : null;
87
+ }
88
+
89
+ /** Everything a page or snippet file links to and can be linked to. */
90
+ async function scanFile(absPath, { slug, Slugger }) {
91
+ let raw;
92
+ try {
93
+ raw = fs.readFileSync(absPath, 'utf-8').replace(/\r\n/g, '\n');
94
+ } catch {
95
+ return null;
96
+ }
97
+ let parsed;
98
+ try {
99
+ parsed = matter(raw, {});
100
+ } catch {
101
+ return null; // invalid frontmatter - `writedocs validate` reports it
102
+ }
103
+ const bodyStart = raw.lastIndexOf(parsed.content);
104
+ const lineOffset = bodyStart > 0 ? raw.slice(0, bodyStart).split('\n').length - 1 : 0;
105
+ let tree;
106
+ try {
107
+ tree = await parse(parsed.content, /\.mdx$/i.test(absPath) ? 'mdx' : 'md');
108
+ } catch {
109
+ return null; // doesn't parse - `writedocs validate` reports it
110
+ }
111
+
112
+ const links = []; // { href, line, kind: 'link' | 'image' | 'src' }
113
+ const anchors = new Set();
114
+ const imports = [];
115
+ const lineOf = (node) => (node.position?.start?.line ? node.position.start.line + lineOffset : undefined);
116
+ const headingSlugger = new Slugger();
117
+ const titleSeen = new Map();
118
+
119
+ visit(tree, (node) => {
120
+ switch (node.type) {
121
+ case 'link':
122
+ case 'definition':
123
+ links.push({ href: node.url, line: lineOf(node), kind: 'link' });
124
+ break;
125
+ case 'image':
126
+ links.push({ href: node.url, line: lineOf(node), kind: 'image' });
127
+ break;
128
+ case 'heading': {
129
+ const text = textOf(node);
130
+ if (text.trim()) anchors.add(headingSlugger.slug(text));
131
+ break;
132
+ }
133
+ case 'html': {
134
+ // Raw HTML in a .md page.
135
+ for (const m of node.value.matchAll(/\bhref\s*=\s*["']([^"']*)["']/gi)) links.push({ href: m[1], line: lineOf(node), kind: 'link' });
136
+ for (const m of node.value.matchAll(/\bsrc\s*=\s*["']([^"']*)["']/gi)) links.push({ href: m[1], line: lineOf(node), kind: 'src' });
137
+ for (const m of node.value.matchAll(/\b(?:id|name)\s*=\s*["']([^"']+)["']/gi)) anchors.add(m[1]);
138
+ break;
139
+ }
140
+ case 'mdxjsEsm':
141
+ for (const m of node.value.matchAll(/\bfrom\s*['"]([^'"]+\.mdx?)['"]/g)) imports.push(m[1]);
142
+ break;
143
+ case 'mdxJsxFlowElement':
144
+ case 'mdxJsxTextElement': {
145
+ const href = attribute(node, 'href');
146
+ if (href !== null) links.push({ href, line: lineOf(node), kind: 'link' });
147
+ if (!/^(iframe|script|embed)$/i.test(node.name ?? '')) {
148
+ for (const name of ['src', 'img']) {
149
+ const value = attribute(node, name);
150
+ if (value !== null) links.push({ href: value, line: lineOf(node), kind: 'src' });
151
+ }
152
+ }
153
+ const id = attribute(node, 'id');
154
+ if (id) anchors.add(id);
155
+ if (TITLED_COMPONENTS.includes(node.name)) {
156
+ const title = attribute(node, node.name === 'Update' ? 'label' : 'title');
157
+ const base = title ? titleSlug(title) : '';
158
+ if (base) {
159
+ const prior = titleSeen.get(base) ?? 0;
160
+ titleSeen.set(base, prior + 1);
161
+ anchors.add(prior === 0 ? base : `${base}-${prior + 1}`);
162
+ }
163
+ }
164
+ break;
165
+ }
166
+ }
167
+ });
168
+ return { data: parsed.data ?? {}, links, anchors, imports };
169
+ }
170
+
171
+ function editDistance(a, b) {
172
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
173
+ for (let i = 1; i <= a.length; i++) {
174
+ let prev = row[0];
175
+ row[0] = i;
176
+ for (let j = 1; j <= b.length; j++) {
177
+ const tmp = row[j];
178
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
179
+ prev = tmp;
180
+ }
181
+ }
182
+ return row[b.length];
183
+ }
184
+
185
+ function closest(value, candidates) {
186
+ let best = null;
187
+ let bestDistance = Infinity;
188
+ for (const c of candidates) {
189
+ const d = editDistance(value.toLowerCase(), c.toLowerCase());
190
+ if (d < bestDistance) {
191
+ best = c;
192
+ bestDistance = d;
193
+ }
194
+ }
195
+ return best !== null && bestDistance <= Math.max(2, Math.floor(value.length * 0.3)) ? best : null;
196
+ }
197
+
198
+ /** Generated OpenAPI pages' URLs, from the manifests generateApiPages()
199
+ * (src/cli/generate-api-pages.js) wrote - it runs first. */
200
+ function generatedPageUrls(contentDir) {
201
+ const urls = new Set();
202
+ const root = path.join(writedocsTempDir(contentDir), 'openapi');
203
+ (function walk(dir) {
204
+ let entries;
205
+ try {
206
+ entries = fs.readdirSync(dir, { withFileTypes: true });
207
+ } catch {
208
+ return;
209
+ }
210
+ for (const entry of entries) {
211
+ const abs = path.join(dir, entry.name);
212
+ if (entry.isDirectory()) walk(abs);
213
+ else if (entry.name === 'manifest.json') {
214
+ try {
215
+ for (const op of JSON.parse(fs.readFileSync(abs, 'utf-8'))) if (op.generated) urls.add(urlForId(normalizeEntryId(op.slug)));
216
+ } catch {}
217
+ }
218
+ }
219
+ })(root);
220
+ return urls;
221
+ }
222
+
223
+ /** Every `href` in writedocs.json, and redirect destinations. */
224
+ function configLinks(config) {
225
+ const found = [];
226
+ (function walk(node, trail) {
227
+ if (Array.isArray(node)) node.forEach((item, i) => walk(item, [...trail, i]));
228
+ else if (node && typeof node === 'object') {
229
+ for (const [key, value] of Object.entries(node)) {
230
+ if (key === 'href' && typeof value === 'string') found.push({ href: value, path: [...trail, key] });
231
+ else walk(value, [...trail, key]);
232
+ }
233
+ }
234
+ })(config, []);
235
+ (Array.isArray(config?.redirects) ? config.redirects : []).forEach((r, i) => {
236
+ if (typeof r?.destination === 'string') found.push({ href: r.destination, path: ['redirects', i, 'destination'] });
237
+ });
238
+ return found;
239
+ }
240
+
241
+ /**
242
+ * Checks every internal link in `contentDir`. `configText` is writedocs.json's
243
+ * raw text (valid JSON - the CLI checks that first). Returns
244
+ * { pages, links, external, broken } - `broken` is a list of issues shaped
245
+ * like lib/content-check.js's: { file, line?, message, suggestion? }.
246
+ */
247
+ export async function checkLinks(contentDir, configText) {
248
+ const { slug, default: Slugger } = await loadSlugger();
249
+ const config = JSON.parse(configText);
250
+
251
+ // --- What the site has -------------------------------------------------
252
+ const pageFiles = findAllPages(contentDir);
253
+ const pages = new Map(); // url -> { rel, abs, scan }
254
+ const urlOfFile = new Map(); // rel -> url
255
+ const scans = new Map(); // abs -> scanFile() result (or null)
256
+ const scanOnce = async (abs) => {
257
+ if (!scans.has(abs)) scans.set(abs, await scanFile(abs, { slug, Slugger }));
258
+ return scans.get(abs);
259
+ };
260
+ for (const rel of pageFiles) {
261
+ const abs = path.join(contentDir, rel);
262
+ const scan = await scanOnce(abs);
263
+ const id = scan?.data?.slug
264
+ ? normalizeEntryId(scan.data.slug)
265
+ : normalizeEntryId(
266
+ rel
267
+ .replace(/\.mdx?$/i, '')
268
+ .split('/')
269
+ .map((segment) => slug(segment))
270
+ .join('/')
271
+ .replace(/\/index$/, '')
272
+ );
273
+ const url = urlForId(id);
274
+ pages.set(url, { rel, abs, scan });
275
+ urlOfFile.set(rel, url);
276
+ }
277
+ const generated = generatedPageUrls(contentDir);
278
+ const redirects = new Set(
279
+ (Array.isArray(config.redirects) ? config.redirects : [])
280
+ .map((r) => (typeof r?.source === 'string' ? `/${r.source.replace(/^\/+|\/+$/g, '')}/`.replace(/^\/\/$/, '/') : null))
281
+ .filter(Boolean)
282
+ );
283
+ const special = new Set(['/llms.txt', '/llms-full.txt', '/404.html', '/404/']);
284
+ if (config.domain) ['/sitemap.xml', '/sitemap-index.xml'].forEach((p) => special.add(p));
285
+ const knownUrls = [...pages.keys(), ...generated, ...redirects];
286
+
287
+ // Anchors a page can be linked to: its own, plus those of every snippet
288
+ // it imports (they render inside it).
289
+ const snippetPath = (spec, fromAbs) =>
290
+ spec.startsWith('/') ? path.join(contentDir, spec) : path.resolve(path.dirname(fromAbs), spec);
291
+ const anchorCache = new Map();
292
+ async function anchorsOf(abs, seen = new Set()) {
293
+ if (anchorCache.has(abs)) return anchorCache.get(abs);
294
+ if (seen.has(abs)) return new Set();
295
+ seen.add(abs);
296
+ const scan = await scanOnce(abs);
297
+ const all = new Set(scan?.anchors ?? []);
298
+ for (const spec of scan?.imports ?? []) for (const a of await anchorsOf(snippetPath(spec, abs), seen)) all.add(a);
299
+ anchorCache.set(abs, all);
300
+ return all;
301
+ }
302
+
303
+ // Root-relative image/font paths something displays - see the "Any other
304
+ // file" case below. Snippets are scanned here too, so the set is complete
305
+ // before the first link is checked.
306
+ const displayedAssets = new Set();
307
+ async function collectDisplayed(abs, seen = new Set()) {
308
+ if (seen.has(abs)) return;
309
+ seen.add(abs);
310
+ const scan = await scanOnce(abs);
311
+ for (const link of scan?.links ?? []) if (link.kind !== 'link' && link.href.startsWith('/')) displayedAssets.add(link.href.split(/[?#]/)[0]);
312
+ for (const spec of scan?.imports ?? []) await collectDisplayed(snippetPath(spec, abs), seen);
313
+ }
314
+ for (const page of pages.values()) await collectDisplayed(page.abs);
315
+ (function walk(node) {
316
+ if (typeof node === 'string') {
317
+ if (node.startsWith('/') && ASSET_EXTENSIONS.has(path.posix.extname(node).toLowerCase())) displayedAssets.add(node);
318
+ } else if (Array.isArray(node)) node.forEach(walk);
319
+ else if (node && typeof node === 'object') Object.values(node).forEach(walk);
320
+ })(config);
321
+
322
+ // --- Checking one link -------------------------------------------------
323
+ const broken = [];
324
+ const reported = new Set();
325
+ let checked = 0;
326
+ let external = 0;
327
+
328
+ /** `pageUrl`: the URL of the page the link renders on (relative links
329
+ * resolve from it). `fileAbs`: the file the link is written in. */
330
+ async function check(href, { file, line, kind, pageUrl, fileAbs }) {
331
+ const value = String(href ?? '').trim();
332
+ if (!value || value === '#' || value.startsWith('{') || value.includes('{{')) return;
333
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith('//')) {
334
+ external += 1;
335
+ return;
336
+ }
337
+ checked += 1;
338
+ const report = (message, suggestion) => {
339
+ const key = `${file}\n${line}\n${value}`;
340
+ if (reported.has(key)) return;
341
+ reported.add(key);
342
+ broken.push({ file, line, message, suggestion });
343
+ };
344
+
345
+ // A Markdown image with a relative path is resolved from the file's own
346
+ // folder at build time (Astro's image pipeline), not from the URL.
347
+ if (kind === 'image' && fileAbs && !value.startsWith('/') && !value.startsWith('#')) {
348
+ const target = path.resolve(path.dirname(fileAbs), decodeURI(value.split(/[?#]/)[0]));
349
+ if (!fs.existsSync(target)) report(`Image ${value} - there's no file at ${path.relative(contentDir, target).split(path.sep).join('/')}.`);
350
+ return;
351
+ }
352
+
353
+ let url;
354
+ try {
355
+ url = new URL(value, BASE + (pageUrl ?? '/'));
356
+ } catch {
357
+ report(`${value} - not a valid link.`);
358
+ return;
359
+ }
360
+ let pathname;
361
+ try {
362
+ pathname = decodeURI(url.pathname);
363
+ } catch {
364
+ pathname = url.pathname;
365
+ }
366
+ const hash = url.hash ? decodeURIComponent(url.hash.slice(1)) : '';
367
+ const extension = path.posix.extname(pathname.replace(/\/+$/, '')).toLowerCase();
368
+ const isRelative = !value.startsWith('/') && !value.startsWith('#');
369
+
370
+ // A page (or /x/index.html).
371
+ let pageKey = null;
372
+ if (!extension || pathname.endsWith('/index.html')) {
373
+ pageKey = pathname.replace(/\/index\.html$/, '/').replace(/\/?$/, '/');
374
+ }
375
+ if (pageKey !== null) {
376
+ if (pages.has(pageKey)) {
377
+ if (!hash || hash === 'top') return;
378
+ const anchors = await anchorsOf(pages.get(pageKey).abs);
379
+ if (anchors.has(hash)) return;
380
+ const suggestion = closest(hash, [...anchors]);
381
+ const onPage = pageKey === pageUrl && value.startsWith('#') ? 'this page' : pageKey;
382
+ report(
383
+ `${value} - ${onPage} has no heading or anchor "#${hash}".`,
384
+ suggestion ? `Did you mean #${suggestion}?` : undefined
385
+ );
386
+ return;
387
+ }
388
+ // "/" always exists: the home page, or a redirect to the first page.
389
+ if (pageKey === '/' || generated.has(pageKey) || redirects.has(pageKey) || special.has(pageKey.replace(/\/$/, '') || '/')) return;
390
+ if (special.has(pathname)) return;
391
+
392
+ // Mintlify-style relative link: resolved from the file's folder
393
+ // instead of the page's URL, would it land on a page?
394
+ if (isRelative && pageUrl && pageUrl !== '/') {
395
+ const siblingBase = pageUrl.replace(/[^/]+\/$/, '');
396
+ const alt = new URL(value, BASE + siblingBase);
397
+ const altKey = decodeURI(alt.pathname).replace(/\/?$/, '/');
398
+ if (pages.has(altKey) || generated.has(altKey)) {
399
+ report(
400
+ `${value} - relative links resolve from the page's own URL (${pageUrl}), so this one points to ${pageKey}, which doesn't exist.`,
401
+ `Write ${altKey}${alt.hash} instead.`
402
+ );
403
+ return;
404
+ }
405
+ }
406
+ const suggestion = closest(pageKey, knownUrls);
407
+ report(`${value} - there's no page at ${pageKey}.`, suggestion ? `Did you mean ${suggestion}?` : undefined);
408
+ return;
409
+ }
410
+
411
+ // A link to a page's source file (docs/setup.mdx) instead of its URL.
412
+ if (extension === '.mdx' || extension === '.md') {
413
+ const bare = pathname.replace(/\.mdx?$/i, '').replace(/\/?$/, '/');
414
+ if (extension === '.md' && config.contextMenu && pages.has(bare)) return; // the page's Markdown copy
415
+ // Written as a file path, so look it up as one: from the file's own
416
+ // folder when relative.
417
+ const filePath = isRelative && fileAbs ? path.resolve(path.dirname(fileAbs), decodeURI(value.split(/[?#]/)[0])) : path.join(contentDir, pathname);
418
+ const rel = path.relative(contentDir, filePath).split(path.sep).join('/');
419
+ const byFile = urlOfFile.get(rel) ?? (pages.has(bare) ? bare : null);
420
+ report(
421
+ `${value} - links to a source file, which isn't published.`,
422
+ byFile ? `Link to the page's URL instead: ${byFile}` : undefined
423
+ );
424
+ return;
425
+ }
426
+
427
+ // Any other file.
428
+ if (special.has(pathname)) return;
429
+ const segments = pathname.replace(/^\/+/, '').split('/');
430
+ if (fs.existsSync(path.join(contentDir, 'public', ...segments))) return;
431
+ const inProject = path.join(contentDir, ...segments);
432
+ if (fs.existsSync(inProject)) {
433
+ // Outside public/, an image or font is published only when something
434
+ // displays it - an <img src>, a Markdown image, a writedocs.json
435
+ // field - not for a plain link to it (lib/styles-asset-integration.js
436
+ // copies what the built pages' `src` attributes name).
437
+ if (ASSET_EXTENSIONS.has(extension) && (kind !== 'link' || displayedAssets.has(pathname))) return;
438
+ report(
439
+ `${value} - ${pathname} is in the project, but only files in public/ are published${
440
+ ASSET_EXTENSIONS.has(extension) ? ' for a link (an image outside it is published only where a page displays it)' : ''
441
+ }.`,
442
+ `Move it to public${pathname}.`
443
+ );
444
+ return;
445
+ }
446
+ report(`${value} - there's no file at ${pathname}.`);
447
+ }
448
+
449
+ // --- The pages, their snippets, and writedocs.json ---------------------
450
+ const relOf = (abs) => path.relative(contentDir, abs).split(path.sep).join('/');
451
+ const snippetsChecked = new Set();
452
+ async function checkFile(abs, pageUrl, seen) {
453
+ const scan = await scanOnce(abs);
454
+ if (!scan) return;
455
+ const file = relOf(abs);
456
+ for (const link of scan.links) await check(link.href, { file, line: link.line, kind: link.kind, pageUrl, fileAbs: abs });
457
+ for (const spec of scan.imports) {
458
+ const snippet = snippetPath(spec, abs);
459
+ if (seen.has(snippet) || snippetsChecked.has(`${snippet}\n${pageUrl}`)) continue;
460
+ snippetsChecked.add(`${snippet}\n${pageUrl}`);
461
+ await checkFile(snippet, pageUrl, new Set([...seen, snippet]));
462
+ }
463
+ }
464
+ for (const [url, page] of pages) await checkFile(page.abs, url, new Set([page.abs]));
465
+
466
+ const locate = createJsonLocator(configText);
467
+ for (const { href, path: jsonPath } of configLinks(config)) {
468
+ await check(href, { file: 'writedocs.json', line: locate(jsonPath)?.line, kind: 'link', pageUrl: '/' });
469
+ }
470
+
471
+ broken.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
472
+ return { pages: pages.size, links: checked, external, broken };
473
+ }
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { parse as acornParse } from 'acorn';
5
+ import { report } from './cli-report.js';
5
6
  import { writedocsTempDir } from './writedocs-temp-dir.js';
6
7
  import { MINTLIFY_HOOKS } from './mdx-mintlify.js';
7
8
  import { BUILTIN_COMPONENT_NAMES } from './mdx-inject-builtins.js';
@@ -137,7 +138,7 @@ export function remarkExtractInlineReactComponents() {
137
138
  const relPath = path.relative(contentDir, file.path).split(path.sep).join('/');
138
139
  for (const use of simplified) {
139
140
  const line = source.slice(0, use.offset).split('\n').length;
140
- console.warn(`[writedocs] ${relPath}:${line} - ${simplifiedBuiltinMessage(use).message}`);
141
+ report('warn', simplifiedBuiltinMessage(use).message, `${relPath}:${line}`);
141
142
  }
142
143
 
143
144
  const pageDir = path.dirname(file.path);
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { visit } from 'unist-util-visit';
3
3
  import { parse as acornParse } from 'acorn';
4
4
  import { BUILTIN_COMPONENT_NAMES } from './mdx-inject-builtins.js';
5
+ import { report } from './cli-report.js';
5
6
 
6
7
  // Which JSX elements in an MDX file are components writedocs can't resolve
7
8
  // - neither a built-in nor something the file imports or defines itself.
@@ -120,9 +121,10 @@ export function remarkUnknownComponentFallback() {
120
121
  : '(unknown file)';
121
122
  const stubs = new Set();
122
123
  for (const { node, name, line, inCode, root } of found) {
123
- console.warn(
124
- `[writedocs] ${filePath}${line ? `:${line}` : ''} - unknown component <${name}>, showing only its content. ` +
125
- 'Remove it, or define it in a snippet.'
124
+ report(
125
+ 'warn',
126
+ `Unknown component <${name}> - showing only its content. Remove it, or define it in a snippet.`,
127
+ `${filePath}${line ? `:${line}` : ''}`
126
128
  );
127
129
  if (inCode) {
128
130
  stubs.add(root);