@mintlify/cli 4.0.1742 → 4.0.1744

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.
@@ -131,20 +131,14 @@ const parseLevel = (file: string, content: string, baseDir: string) => {
131
131
 
132
132
  /** Applies every fix that doesn't overlap an earlier fix in the same file, writing each Markdown file only when its fixed content parses at least as well as before. */
133
133
  export const applyFixes = async (site: Site, diagnostics: Diagnostic[]): Promise<Change[]> => {
134
- const contents = new Map<string, string>();
135
- const read = async (file: string) => {
136
- if (!contents.has(file)) {
137
- contents.set(file, await fs.readFile(path.join(site.baseDir, file), 'utf-8'));
138
- }
139
- return contents.get(file) ?? '';
140
- };
134
+ const read = (file: string) => site.read(file) ?? '';
141
135
  const chosen = new Map<string, { fix: FileEdit; change: Change }[]>();
142
136
  const overlaps = ({ file, start, end }: FileEdit) =>
143
137
  (chosen.get(file) ?? []).some(({ fix }) => start < fix.end && fix.start < end);
144
138
 
145
139
  for (const { rule, fix } of diagnostics) {
146
140
  if (!fix || overlaps(fix)) continue;
147
- const content = await read(fix.file);
141
+ const content = read(fix.file);
148
142
  const change = {
149
143
  rule,
150
144
  file: fix.file,
@@ -167,7 +161,7 @@ export const applyFixes = async (site: Site, diagnostics: Diagnostic[]): Promise
167
161
 
168
162
  const applied: Change[] = [];
169
163
  for (const [file, picks] of chosen) {
170
- const content = await read(file);
164
+ const content = read(file);
171
165
  const updated = applyEdits(content, selectEdits(picks.map(({ fix }) => fix)));
172
166
  if (/\.mdx?$/.test(file) && level(file, updated) < level(file, content)) {
173
167
  continue;
@@ -191,7 +185,7 @@ export const checkSite = async (
191
185
  options: { selected?: ReadonlySet<string>; localSchema: boolean; fix: boolean }
192
186
  ) => {
193
187
  const fixed: Change[] = [];
194
- const site = await loadSite(baseDir, options);
188
+ let site = await loadSite(baseDir, options);
195
189
  const { selected } = options;
196
190
  if (selected && !site.files.some(({ file }) => selected.has(file))) {
197
191
  throw new UsageError(
@@ -207,10 +201,10 @@ export const checkSite = async (
207
201
  const changes = await applyFixes(site, pending);
208
202
  if (changes.length === 0) break;
209
203
  fixed.push(...changes);
204
+ site = await loadSite(baseDir, options);
210
205
  const changed = new Set(changes.map(({ file }) => file));
211
206
  pending = sortDiagnostics(await checkFiles({ ...site, selected: changed }, fileRulesOf(rules)));
212
207
  }
213
208
  if (fixed.length === 0) return { site, diagnostics, fixed };
214
- const fixedSite = await loadSite(baseDir, options);
215
- return { site: fixedSite, diagnostics: await runChecks(fixedSite, rules), fixed };
209
+ return { site, diagnostics: await runChecks(site, rules), fixed };
216
210
  };
@@ -6,10 +6,15 @@ import {
6
6
  listContentFiles,
7
7
  } from '@mintlify/prebuild';
8
8
  import type { Root } from 'mdast';
9
- import fs from 'node:fs/promises';
10
- import path from 'node:path';
11
9
 
12
- import { applyEdits, parseContent, relocate, selectEdits, unmapEdit } from './mdx.js';
10
+ import {
11
+ applyEdits,
12
+ collectAnchorLinks,
13
+ parseContent,
14
+ relocate,
15
+ selectEdits,
16
+ unmapEdit,
17
+ } from './mdx.js';
13
18
  import { PARSE_ERROR } from './rules/parseError.js';
14
19
  import type {
15
20
  Diagnostic,
@@ -22,22 +27,6 @@ import type {
22
27
  SiteFile,
23
28
  } from './types.js';
24
29
 
25
- const CONCURRENCY = 16;
26
-
27
- const mapLimit = async <T, R>(items: T[], task: (item: T) => Promise<R>): Promise<R[]> => {
28
- const results = new Array<R>(items.length);
29
- let next = 0;
30
- await Promise.all(
31
- Array.from({ length: Math.min(CONCURRENCY, items.length) }, async () => {
32
- while (next < items.length) {
33
- const index = next++;
34
- results[index] = await task(items[index] as T);
35
- }
36
- })
37
- );
38
- return results;
39
- };
40
-
41
30
  const CHECKED_CATEGORIES = new Set<string | null>(['page', 'snippet', 'snippet-v2']);
42
31
 
43
32
  export const readSiteFiles = async (baseDir: string): Promise<SiteFile[]> => {
@@ -80,6 +69,8 @@ export const toParseError = (error: unknown): ParseError => {
80
69
  };
81
70
  };
82
71
 
72
+ type Extras = Pick<FileContext, 'readFile' | 'anchorLinks'>;
73
+
83
74
  const parseErrorDiagnostic = (file: string, error: ParseError): Diagnostic => ({
84
75
  ...error,
85
76
  rule: PARSE_ERROR.id,
@@ -99,8 +90,13 @@ const tryParseContent = (content: string): Root | undefined => {
99
90
  }
100
91
  };
101
92
 
102
- export const parseFile = (file: string, content: string, baseDir: string): Parsed => {
103
- const context = { file, content, baseDir, frontmatter: readFrontmatter(content) };
93
+ export const parseFile = (
94
+ file: string,
95
+ content: string,
96
+ baseDir: string,
97
+ extras: Extras = {}
98
+ ): Parsed => {
99
+ const context = { file, content, baseDir, frontmatter: readFrontmatter(content), ...extras };
104
100
  const tree = tryParseContent(content);
105
101
  if (tree) return { context: { ...context, tree }, approximate: false };
106
102
  try {
@@ -152,6 +148,7 @@ const checkFile = (
152
148
  { file, kind }: SiteFile,
153
149
  content: string,
154
150
  baseDir: string,
151
+ extras: Extras,
155
152
  variables?: Record<string, string>
156
153
  ): Diagnostic[] => {
157
154
  const rules = kind === 'snippet' ? allRules.filter((rule) => !rule.pagesOnly) : allRules;
@@ -177,7 +174,7 @@ const checkFile = (
177
174
  toDiagnostic(rule, file, finding, stages, SOURCE_STAGE)
178
175
  );
179
176
 
180
- const parsed = parseFile(file, afterRaw, baseDir);
177
+ const parsed = parseFile(file, afterRaw, baseDir, extras);
181
178
  if ('error' in parsed) {
182
179
  diagnostics.unshift(
183
180
  parseErrorDiagnostic(file, {
@@ -196,17 +193,30 @@ const checkFile = (
196
193
  return diagnostics;
197
194
  };
198
195
 
196
+ const collectSiteAnchorLinks = async (site: Site): Promise<ReadonlySet<string>> => {
197
+ const pages = await Promise.all(
198
+ site.files.map(({ file }) => collectAnchorLinks(file, site.read(file) ?? ''))
199
+ );
200
+ return new Set(pages.flat());
201
+ };
202
+
199
203
  export const checkFiles = async (site: Site, rules: FileRule[]): Promise<Diagnostic[]> => {
204
+ const anchorRules = new Set(rules.filter((rule) => rule.needsAnchorLinks).map(({ id }) => id));
200
205
  const targets = site.files.filter(({ file }) => !site.selected || site.selected.has(file));
201
- const results = await mapLimit(targets, async (entry) => {
202
- let content: string;
203
- try {
204
- content = await fs.readFile(path.join(site.baseDir, entry.file), 'utf-8');
205
- } catch (error) {
206
- const message = error instanceof Error ? error.message : 'could not read this file';
207
- return [parseErrorDiagnostic(entry.file, { message })];
206
+ const checkTarget = (entry: SiteFile, extras: Extras) => {
207
+ const content = site.read(entry.file);
208
+ if (content === undefined) {
209
+ return [parseErrorDiagnostic(entry.file, { message: 'could not read this file' })];
208
210
  }
209
- return checkFile(rules, entry, content, site.baseDir, site.variables);
211
+ return checkFile(rules, entry, content, site.baseDir, extras, site.variables);
212
+ };
213
+ const needsLinks = (diagnostics: Diagnostic[]) =>
214
+ diagnostics.some(({ rule }) => anchorRules.has(rule));
215
+ const checked = targets.map((entry) => checkTarget(entry, { readFile: site.read }));
216
+ if (!checked.some(needsLinks)) return checked.flat();
217
+ const extras = { readFile: site.read, anchorLinks: await collectSiteAnchorLinks(site) };
218
+ return targets.flatMap((entry, index) => {
219
+ const diagnostics = checked[index] ?? [];
220
+ return needsLinks(diagnostics) ? checkTarget(entry, extras) : diagnostics;
210
221
  });
211
- return results.flat();
212
222
  };
@@ -4,7 +4,12 @@ import {
4
4
  getCodeRanges,
5
5
  HEADING_ID_RE,
6
6
  JSXParser,
7
+ pagePathToSlug,
8
+ replaceSlashIndex,
9
+ safeCleanHeadingId,
10
+ toPosixPath,
7
11
  } from '@mintlify/common';
12
+ import { decorateGraphNodeFromPageContent, Node, PathType } from '@mintlify/link-rot';
8
13
  import type { Root } from 'mdast';
9
14
 
10
15
  import type { Edit } from './types.js';
@@ -250,3 +255,36 @@ export const relocate = (
250
255
  const mapped = toPosition(original.content, offset);
251
256
  return position.column === undefined ? { line: mapped.line } : mapped;
252
257
  };
258
+
259
+ const pagePath = (file: string) => replaceSlashIndex(pagePathToSlug(file)).replace(/^\.$/, '');
260
+
261
+ export const collectAnchorLinks = async (file: string, content: string): Promise<string[]> => {
262
+ const node = new Node(file);
263
+ try {
264
+ await decorateGraphNodeFromPageContent(node, content);
265
+ } catch {
266
+ return [];
267
+ }
268
+ return node.paths.flatMap((link) =>
269
+ link.pathType === PathType.INTERNAL && link.anchorLink
270
+ ? [
271
+ `${pagePath(toPosixPath(link.toString()))}#${safeCleanHeadingId(link.anchorLink.slice(1))}`,
272
+ ]
273
+ : []
274
+ );
275
+ };
276
+
277
+ export const linkedAnchor = (
278
+ links: ReadonlySet<string> | undefined,
279
+ file: string,
280
+ slug: string
281
+ ): string | undefined => {
282
+ const prefix = `${pagePath(file)}#`;
283
+ for (const link of links ?? []) {
284
+ if (!link.startsWith(prefix)) continue;
285
+ const anchor = link.slice(prefix.length);
286
+ const suffix = anchor.startsWith(`${slug}-`) && /^\d/.test(anchor.slice(slug.length + 1));
287
+ if (anchor === slug || suffix) return anchor;
288
+ }
289
+ return undefined;
290
+ };
@@ -0,0 +1,78 @@
1
+ import {
2
+ AVOIDED_PAGE_MODES,
3
+ getTableOfContentsTitle,
4
+ safeCleanHeadingId,
5
+ slugify,
6
+ } from '@mintlify/common';
7
+ import type { Heading, Root } from 'mdast';
8
+ import remarkSmartypants from 'remark-smartypants';
9
+ import { unified } from 'unified';
10
+
11
+ import { hasCustomHeadingId, linkedAnchor } from '../../mdx.js';
12
+ import type { FileRule } from '../../types.js';
13
+
14
+ const normalize = (value: string) => value.trim().replace(/\s+/g, ' ');
15
+
16
+ const smartypants = unified().use(remarkSmartypants);
17
+
18
+ const firstHeading = (tree: Root) =>
19
+ tree.children.find((node): node is Heading => node.type === 'heading');
20
+
21
+ const renderedText = (heading: Heading) => {
22
+ const clone = structuredClone(heading);
23
+ const root: Root = { type: 'root', children: [clone] };
24
+ smartypants.runSync(root);
25
+ return getTableOfContentsTitle(clone);
26
+ };
27
+
28
+ export const duplicateH1: FileRule = {
29
+ id: 'duplicate-h1',
30
+ group: 'content',
31
+ severity: 'warning',
32
+ scope: 'file',
33
+ description: 'remove a first heading that repeats the frontmatter title',
34
+ pagesOnly: true,
35
+ needsAnchorLinks: true,
36
+ check: ({ tree, frontmatter, content, file, anchorLinks }) => {
37
+ const { title, mode } = frontmatter;
38
+ if (typeof title !== 'string' || AVOIDED_PAGE_MODES.includes(String(mode))) return [];
39
+ const first = firstHeading(tree);
40
+ if (!first || first.depth !== 1) return [];
41
+ if (normalize(getTableOfContentsTitle(first)) !== normalize(title)) return [];
42
+ const start = first.position?.start.offset ?? 0;
43
+ const end = first.position?.end.offset ?? start;
44
+ const location = {
45
+ line: first.position?.start.line,
46
+ column: first.position?.start.column,
47
+ };
48
+ const lineEnd = content.indexOf('\n', start);
49
+ if (hasCustomHeadingId(content.slice(start, lineEnd === -1 ? undefined : lineEnd))) {
50
+ return [
51
+ {
52
+ ...location,
53
+ message:
54
+ 'first heading duplicates the frontmatter title; it has a custom {#id}, so remove it by hand and update links to it',
55
+ },
56
+ ];
57
+ }
58
+ const anchor = [renderedText(first), getTableOfContentsTitle(first)]
59
+ .map((text) => linkedAnchor(anchorLinks, file, safeCleanHeadingId(slugify(text))))
60
+ .find((linked) => linked !== undefined);
61
+ if (anchor !== undefined) {
62
+ return [
63
+ {
64
+ ...location,
65
+ message: `first heading duplicates the frontmatter title; links point to #${anchor}, so remove it by hand and update them`,
66
+ },
67
+ ];
68
+ }
69
+ const trailing = /^\n*/.exec(content.slice(end))?.[0].length ?? 0;
70
+ return [
71
+ {
72
+ ...location,
73
+ message: 'first heading duplicates the frontmatter title',
74
+ edit: { start, end: end + trailing, text: '' },
75
+ },
76
+ ];
77
+ },
78
+ };
@@ -0,0 +1,144 @@
1
+ import { maskCode, maskFrontmatter, maskNonMarkup, toPosition } from '../../mdx.js';
2
+ import type { FileRule, FileFinding } from '../../types.js';
3
+
4
+ type Pattern = {
5
+ regex: RegExp;
6
+ scope?: 'prose' | 'links' | 'prose-and-esm' | 'everything';
7
+ message: (match: RegExpMatchArray) => string;
8
+ };
9
+
10
+ const CALLOUTS: Record<string, string> = {
11
+ note: '<Note>',
12
+ info: '<Info>',
13
+ tip: '<Tip>',
14
+ hint: '<Tip>',
15
+ success: '<Check>',
16
+ important: '<Note>',
17
+ warning: '<Warning>',
18
+ caution: '<Warning>',
19
+ danger: '<Warning>',
20
+ error: '<Warning>',
21
+ details: '<Accordion>',
22
+ };
23
+
24
+ const callout = (name: string | undefined) =>
25
+ CALLOUTS[name?.toLowerCase() ?? ''] ?? 'a callout (<Note>, <Info>, <Tip> or <Warning>)';
26
+
27
+ const LIQUID_TAGS: Record<string, string> = {
28
+ hint: 'a callout (<Note>, <Info>, <Tip> or <Warning>)',
29
+ tabs: '<Tabs>',
30
+ tab: '<Tab title="…">',
31
+ stepper: '<Steps>',
32
+ step: '<Step title="…">',
33
+ embed: 'a link or <Frame>',
34
+ 'content-ref': '<Card>',
35
+ include: 'a snippet import',
36
+ code: 'a fenced code block',
37
+ file: 'a link to the file',
38
+ swagger: 'an OpenAPI page',
39
+ openapi: 'an OpenAPI page',
40
+ };
41
+
42
+ const PATTERNS: Pattern[] = [
43
+ {
44
+ regex: /\{%-?\s*(?!end)([\w-]+)[^%\n]*?-?%\}/g,
45
+ message: ([tag, name]) => {
46
+ const style = name === 'hint' ? /style=["']?(\w+)/.exec(tag)?.[1] : undefined;
47
+ const target = style
48
+ ? callout(style)
49
+ : (LIQUID_TAGS[name ?? ''] ?? 'Markdown or a Mintlify component');
50
+ return `${tag} is a GitBook or Liquid tag, which Mintlify does not support; use ${target}`;
51
+ },
52
+ },
53
+ {
54
+ regex: /\{\{[<%]\s*([\w/-]+)[^}\n]*[>%]\}\}/g,
55
+ message: ([tag]) =>
56
+ `${tag} is a Hugo shortcode, which Mintlify does not support; use Markdown or a Mintlify component`,
57
+ },
58
+ {
59
+ regex:
60
+ /\{\{-?\s*(?:(?:site|page|layout|include|jekyll|paginator)\.[^}\n]*|[^\s|{}]+\s*\|\s*\w+[^}\n]*)-?\}\}/g,
61
+ message: ([tag]) =>
62
+ `${tag} is a Liquid variable, which Mintlify does not support; write the value, or use a snippet or a variable`,
63
+ },
64
+ {
65
+ regex: /^ {0,3}:{3,}\s*([A-Za-z][\w-]*)/gm,
66
+ message: ([, name]) =>
67
+ `:::${name} is a Docusaurus or VitePress admonition, which Mintlify does not support; use ${callout(name)}`,
68
+ },
69
+ {
70
+ regex: /^ {0,3}::(?!:)([A-Za-z][\w-]*)/gm,
71
+ message: ([, name]) =>
72
+ `::${name} is an MDC (Nuxt Content, Docus) block, which Mintlify does not support; use the matching Mintlify component`,
73
+ },
74
+ {
75
+ regex: /^ {0,3}(!!!|\?\?\?\+?)[ \t]+([A-Za-z][\w-]*)/gm,
76
+ message: ([, marker, name]) =>
77
+ `${marker} ${name} is an MkDocs admonition, which Mintlify does not support; use ${
78
+ marker === '!!!' ? callout(name) : '<Accordion>'
79
+ }`,
80
+ },
81
+ {
82
+ regex: /^ {0,3}===\+?[ \t]+["']/gm,
83
+ message: () =>
84
+ `=== "…" is an MkDocs content tab, which Mintlify does not support; use <Tabs> with <Tab title="…">`,
85
+ },
86
+ {
87
+ regex: /^[ \t]*;?[ \t]*--8<--/gm,
88
+ scope: 'everything',
89
+ message: () =>
90
+ '--8<-- is an MkDocs snippet include, which Mintlify does not support; use a snippet import or paste the content',
91
+ },
92
+ {
93
+ regex: /\{:\s*[.#\w][^}\n]*\}/g,
94
+ message: ([list]) =>
95
+ `${list} is a Kramdown attribute list, which Mintlify does not support; remove it, use a component, or use {#id} for a heading anchor`,
96
+ },
97
+ {
98
+ regex: /(?<![[{\w])\[\[([^[\]\n]+)\]\](?![(:[])/g,
99
+ message: ([link]) =>
100
+ `${link} is a wiki link, which Mintlify does not support; use a Markdown link such as [text](/path)`,
101
+ },
102
+ {
103
+ regex: /(?:\]\(|href=["'])((?:doc|ref|blog|changelog|page):[^)\s"']+)/g,
104
+ scope: 'links',
105
+ message: ([, link]) =>
106
+ `${link} is a ReadMe link, which Mintlify does not support; link to the page path instead, such as /${
107
+ link?.split(':')[1] ?? ''
108
+ }`,
109
+ },
110
+ {
111
+ regex: /["'(]@site\/[^"')\s]*/g,
112
+ scope: 'prose-and-esm',
113
+ message: ([path]) =>
114
+ `${path.slice(1)} uses the Docusaurus @site alias, which Mintlify does not resolve; use a root-relative path such as /images/… or /snippets/…`,
115
+ },
116
+ ];
117
+
118
+ export const foreignSyntax: FileRule = {
119
+ id: 'foreign-syntax',
120
+ group: 'content',
121
+ severity: 'warning',
122
+ scope: 'file',
123
+ description: "syntax from another docs framework that Mintlify doesn't render",
124
+ raw: true,
125
+ check: ({ content }) => {
126
+ const prose = maskCode(content);
127
+ const literal = maskNonMarkup(content);
128
+ const views = {
129
+ prose,
130
+ links: prose,
131
+ 'prose-and-esm': maskCode(content, { keepEsm: true }),
132
+ everything: maskFrontmatter(content),
133
+ };
134
+ const issues: FileFinding[] = [];
135
+ for (const { regex, scope = 'prose', message } of PATTERNS) {
136
+ for (const match of views[scope].matchAll(regex)) {
137
+ const start = match.index + (match[0].length - match[0].trimStart().length);
138
+ if (scope === 'prose' && literal[start] !== prose[start]) continue;
139
+ issues.push({ ...toPosition(content, start), message: message(match) });
140
+ }
141
+ }
142
+ return issues;
143
+ },
144
+ };
@@ -0,0 +1,33 @@
1
+ import { isCurrencyMath } from '@mintlify/common';
2
+ import katex from 'katex';
3
+ import { visit } from 'unist-util-visit';
4
+
5
+ import type { FileRule, FileFinding } from '../../types.js';
6
+
7
+ export const katexRule: FileRule = {
8
+ id: 'katex',
9
+ group: 'content',
10
+ severity: 'warning',
11
+ scope: 'file',
12
+ description: "math that KaTeX can't render",
13
+ check: ({ tree, content }) => {
14
+ const issues: FileFinding[] = [];
15
+ visit(tree, (node) => {
16
+ if (node.type !== 'math' && node.type !== 'inlineMath') return;
17
+ if (node.type === 'inlineMath' && isCurrencyMath(node, content)) return;
18
+ try {
19
+ katex.renderToString(node.value, {
20
+ throwOnError: true,
21
+ displayMode: node.type === 'math',
22
+ });
23
+ } catch (error) {
24
+ issues.push({
25
+ line: node.position?.start.line,
26
+ column: node.position?.start.column,
27
+ message: error instanceof Error ? error.message : 'invalid math',
28
+ });
29
+ }
30
+ });
31
+ return issues;
32
+ },
33
+ };
@@ -0,0 +1,130 @@
1
+ import {
2
+ collectPatternNames,
3
+ collectExportedNames,
4
+ getAST,
5
+ isBuiltInComponent,
6
+ JSX_EXTENSIONS,
7
+ visitReferences,
8
+ } from '@mintlify/common';
9
+ import { visit } from 'unist-util-visit';
10
+
11
+ import type { FileContext, FileRule, FileFinding } from '../../types.js';
12
+
13
+ const getDeclared = (context: FileContext): Set<string> => {
14
+ const declared = new Set<string>();
15
+ visit(context.tree, 'mdxjsEsm', (node) => {
16
+ for (const statement of node.data?.estree?.body ?? []) {
17
+ if (statement.type === 'ImportDeclaration') {
18
+ for (const specifier of statement.specifiers) {
19
+ collectPatternNames(specifier.local, declared);
20
+ }
21
+ } else if (statement.type === 'ExportNamedDeclaration') {
22
+ const declaration = statement.declaration;
23
+ if (declaration?.type === 'VariableDeclaration') {
24
+ for (const declarator of declaration.declarations) {
25
+ collectPatternNames(declarator.id, declared);
26
+ }
27
+ } else if (declaration) {
28
+ collectPatternNames(declaration.id, declared);
29
+ }
30
+ for (const specifier of statement.specifiers) {
31
+ collectPatternNames(specifier.local, declared);
32
+ collectPatternNames(specifier.exported, declared);
33
+ }
34
+ } else if (statement.type === 'ExportDefaultDeclaration') {
35
+ const declaration = statement.declaration;
36
+ if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') {
37
+ collectPatternNames(declaration.id, declared);
38
+ }
39
+ }
40
+ }
41
+ });
42
+ return declared;
43
+ };
44
+
45
+ const getImportPosition = (context: FileContext): number => {
46
+ const frontmatter = context.tree.children.find((child) => ['yaml', 'toml'].includes(child.type));
47
+ return frontmatter?.position?.end.offset ?? 0;
48
+ };
49
+
50
+ const getSpecifier = (snippet: string, file: string, name: string): string | undefined => {
51
+ let exported: Set<string>;
52
+ try {
53
+ exported = collectExportedNames(getAST(snippet, file));
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ if (exported.has(name)) return `{ ${name} }`;
58
+ return exported.has('default') ? name : undefined;
59
+ };
60
+
61
+ export const unknownComponent: FileRule = {
62
+ id: 'unknown-component',
63
+ group: 'content',
64
+ severity: 'warning',
65
+ scope: 'file',
66
+ description: 'JSX components that are not built in, imported or exported',
67
+ pagesOnly: true,
68
+ check: (context) => {
69
+ const declared = getDeclared(context);
70
+ const reported = new Set<string>();
71
+ const issues: FileFinding[] = [];
72
+ const insertAt = getImportPosition(context);
73
+ const prefix = insertAt === 0 ? '' : '\n\n';
74
+ const following = /^\n*/.exec(context.content.slice(insertAt))?.[0].length ?? 0;
75
+ const suffix = '\n'.repeat(Math.max(0, 2 - following));
76
+
77
+ const check = (
78
+ tag: string | null | undefined,
79
+ position: { line?: number; column?: number } | undefined
80
+ ) => {
81
+ const name = tag?.split('.')[0];
82
+ if (!tag || !name || !/^[A-Z]/.test(name)) return;
83
+ if (declared.has(name) || reported.has(tag) || isBuiltInComponent(tag)) return;
84
+ reported.add(tag);
85
+
86
+ const file = JSX_EXTENSIONS.map((extension) => `snippets/${name}${extension}`).find(
87
+ (candidate) => context.readFile?.(candidate) !== undefined
88
+ );
89
+ const source = file && context.readFile?.(file);
90
+ const specifier =
91
+ file && source !== undefined && tag === name ? getSpecifier(source, file, name) : undefined;
92
+ issues.push({
93
+ line: position?.line,
94
+ column: position?.column,
95
+ message: `<${tag}> is not built in, imported or exported on this page`,
96
+ edit: specifier
97
+ ? {
98
+ start: insertAt,
99
+ end: insertAt,
100
+ text: `${prefix}import ${specifier} from "/${file}";${suffix}`,
101
+ }
102
+ : undefined,
103
+ });
104
+ };
105
+
106
+ const checkJsxInExpression = (estree: unknown, fallback?: { line?: number; column?: number }) =>
107
+ visitReferences(estree, new Set(), {
108
+ reference: (identifier) => {
109
+ if (identifier.type !== 'JSXIdentifier') return;
110
+ const loc = identifier.loc as { start?: { line: number; column: number } } | undefined;
111
+ check(String(identifier.name), loc?.start ?? fallback);
112
+ },
113
+ });
114
+
115
+ visit(context.tree, (node) => {
116
+ if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
117
+ check(node.name, node.position?.start);
118
+ for (const attribute of node.attributes) {
119
+ const value = attribute.type === 'mdxJsxAttribute' ? attribute.value : attribute;
120
+ if (value && typeof value === 'object') {
121
+ checkJsxInExpression(value.data?.estree, node.position?.start);
122
+ }
123
+ }
124
+ } else if (node.type === 'mdxFlowExpression' || node.type === 'mdxTextExpression') {
125
+ checkJsxInExpression(node.data?.estree, node.position?.start);
126
+ }
127
+ });
128
+ return issues;
129
+ },
130
+ };
@@ -1,7 +1,19 @@
1
1
  import type { Rule } from '../types.js';
2
2
  import { BUILD_RULES } from './build.js';
3
+ import { duplicateH1 } from './content/duplicateH1.js';
4
+ import { foreignSyntax } from './content/foreignSyntax.js';
5
+ import { katexRule } from './content/katex.js';
6
+ import { unknownComponent } from './content/unknownComponent.js';
3
7
  import { voidTags } from './content/voidTags.js';
4
8
  import { openApiSpec } from './openapi.js';
5
9
 
6
10
  /** Every check mint validate runs, in report order. */
7
- export const RULES: Rule[] = [voidTags, openApiSpec, ...BUILD_RULES];
11
+ export const RULES: Rule[] = [
12
+ voidTags,
13
+ duplicateH1,
14
+ unknownComponent,
15
+ katexRule,
16
+ foreignSyntax,
17
+ openApiSpec,
18
+ ...BUILD_RULES,
19
+ ];
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
4
  import { readSiteFiles } from './files.js';
@@ -22,18 +22,18 @@ export const loadSite = async (
22
22
  { selected, localSchema }: { selected?: ReadonlySet<string>; localSchema: boolean }
23
23
  ): Promise<Site> => {
24
24
  const files = await readSiteFiles(baseDir);
25
- const byPath = new Map<string, string>();
26
- const configFile = existsSync(path.join(baseDir, 'docs.json')) ? 'docs.json' : 'mint.json';
25
+ const byPath = new Map<string, string | undefined>();
26
+ const configFile = fs.existsSync(path.join(baseDir, 'docs.json')) ? 'docs.json' : 'mint.json';
27
27
 
28
28
  const read = (file: string) => {
29
29
  if (!byPath.has(file)) {
30
30
  try {
31
- byPath.set(file, readFileSync(path.join(baseDir, file), 'utf-8'));
31
+ byPath.set(file, fs.readFileSync(path.join(baseDir, file), 'utf-8'));
32
32
  } catch {
33
- byPath.set(file, '');
33
+ byPath.set(file, undefined);
34
34
  }
35
35
  }
36
- return byPath.get(file) ?? '';
36
+ return byPath.get(file);
37
37
  };
38
38
 
39
39
  return {
@@ -41,10 +41,11 @@ export const loadSite = async (
41
41
  files,
42
42
  selected,
43
43
  configFile,
44
- variables: readVariables(read(configFile)),
44
+ variables: readVariables(read(configFile) ?? ''),
45
45
  localSchema,
46
+ read,
46
47
  locate: (file, text) => {
47
- const content = read(file);
48
+ const content = read(file) ?? '';
48
49
  const offset = text ? content.indexOf(text) : -1;
49
50
  return offset === -1 ? {} : toPosition(content, offset);
50
51
  },