@readme/cli 0.1.0 → 0.2.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.
- package/bin/readme.js +1 -1
- package/package.json +33 -24
- package/src/bootstrap.js +9 -13
- package/src/cli.js +17 -11
- package/src/commands/components.js +35 -18
- package/src/commands/dev.js +12 -8
- package/src/commands/eyes.js +12 -8
- package/src/commands/import.js +2190 -2048
- package/src/commands/lint.js +33 -29
- package/src/commands/oas-bundle.js +6 -2
- package/src/commands/oas-sync.js +96 -93
- package/src/commands/oas-validate.js +10 -7
- package/src/commands/pretty.js +177 -164
- package/src/commands/setup.js +24 -26
- package/src/commands/versions.js +6 -2
- package/src/dev/app/[...slug]/page.js +1 -0
- package/src/dev/app/layout.js +18 -11
- package/src/dev/app/page.js +1 -0
- package/src/dev/lib/docs.js +27 -19
- package/src/dev/next.config.mjs +2 -2
- package/src/gha.js +7 -7
- package/src/prompts/index.js +16 -6
- package/src/utils/eyes.js +143 -51
- package/src/utils/git.js +3 -3
- package/src/utils/lint.js +39 -38
- package/src/utils/llms.js +94 -91
- package/src/utils/markdown.js +256 -0
- package/src/utils/marketplace.js +12 -5
- package/src/utils/multi-spinner.js +5 -7
- package/src/utils/promise-pool.js +7 -5
- package/src/utils/reporter.js +88 -91
- package/src/utils/setup-templates.js +12 -6
- package/src/utils/styles.js +2 -1
- package/src/utils/tamagotchi.js +257 -91
- package/src/utils/tips.js +18 -9
- package/src/utils/url-segs.js +46 -41
- package/src/validators/components.js +55 -63
- package/src/validators/content.js +5 -10
- package/src/validators/duplicates.js +6 -2
- package/src/validators/frontmatter.js +19 -21
- package/src/validators/links.js +4 -8
- package/src/validators/oas-reference.js +7 -14
- package/src/validators/oas-schema.js +2 -0
- package/src/validators/ordering.js +5 -5
- package/src/validators/recipes.js +4 -12
- package/src/commands/import-oas.test.js +0 -277
- package/src/commands/import.classify.test.js +0 -172
- package/src/commands/import.landing.test.js +0 -217
- package/src/commands/import.slug-planner.test.js +0 -86
- package/src/commands/import.staging.test.js +0 -230
- package/src/commands/import.test.js +0 -382
- package/src/utils/llms.test.js +0 -302
- package/src/utils/url-segs.test.js +0 -239
package/src/utils/tips.js
CHANGED
|
@@ -5,7 +5,7 @@ const tips = [
|
|
|
5
5
|
{
|
|
6
6
|
weight: 1,
|
|
7
7
|
commands: ['lint', 'oas:validate'],
|
|
8
|
-
condition:
|
|
8
|
+
condition: ctx => !ctx.isRunningInClaude && ctx.workflowOutdated,
|
|
9
9
|
render() {
|
|
10
10
|
console.log(` 💡 ${styles.bold('Tip:')} Your GitHub Action is out of date!`);
|
|
11
11
|
console.log(` ${styles.dim('⎿')} ${styles.orange(`${styles.binName()} setup:github`)}`);
|
|
@@ -15,7 +15,7 @@ const tips = [
|
|
|
15
15
|
{
|
|
16
16
|
weight: 1,
|
|
17
17
|
commands: ['lint', 'oas:validate'],
|
|
18
|
-
condition:
|
|
18
|
+
condition: ctx => !ctx.isRunningInClaude && ctx.detectedPlatform && !ctx.hasCiWorkflow,
|
|
19
19
|
render(ctx) {
|
|
20
20
|
const label = ctx?.detectedPlatformLabel || 'CI';
|
|
21
21
|
console.log(` 💡 ${styles.bold('Tip:')} Set up ${label} to lint your docs on every PR!`);
|
|
@@ -26,17 +26,19 @@ const tips = [
|
|
|
26
26
|
{
|
|
27
27
|
weight: 1,
|
|
28
28
|
commands: ['lint', 'oas:validate'],
|
|
29
|
-
condition:
|
|
29
|
+
condition: ctx => !ctx.isRunningInClaude && ctx.hasClaude,
|
|
30
30
|
render() {
|
|
31
31
|
console.log(` 💡 ${styles.bold('Tip:')} Claude can fix these issues for you easily!`);
|
|
32
|
-
console.log(
|
|
32
|
+
console.log(
|
|
33
|
+
` ${styles.dim('⎿')} ${styles.orange(`claude "run '${styles.binName()} lint' and fix the issues"`)}`,
|
|
34
|
+
);
|
|
33
35
|
console.log();
|
|
34
36
|
},
|
|
35
37
|
},
|
|
36
38
|
{
|
|
37
39
|
weight: 1,
|
|
38
40
|
commands: ['lint', 'oas:validate'],
|
|
39
|
-
condition:
|
|
41
|
+
condition: ctx => !ctx.isRunningInClaude && !ctx.hasClaude,
|
|
40
42
|
render() {
|
|
41
43
|
console.log(` 💡 ${styles.bold('Tip:')} Install Claude to automatically fix these issues!`);
|
|
42
44
|
console.log(` ${styles.dim('⎿')} ${styles.dim('https://claude.ai/download')}`);
|
|
@@ -47,7 +49,9 @@ const tips = [
|
|
|
47
49
|
weight: 0.1,
|
|
48
50
|
condition: () => !loadPet(),
|
|
49
51
|
render() {
|
|
50
|
-
console.log(
|
|
52
|
+
console.log(
|
|
53
|
+
` 💡 ${styles.bold('Tip:')} Want a little friend? Run ${styles.orange(`${styles.binName()} play`)} to hatch one!`,
|
|
54
|
+
);
|
|
51
55
|
console.log();
|
|
52
56
|
},
|
|
53
57
|
},
|
|
@@ -56,7 +60,9 @@ const tips = [
|
|
|
56
60
|
condition: () => !!loadPet(),
|
|
57
61
|
render() {
|
|
58
62
|
const pet = loadPet();
|
|
59
|
-
console.log(
|
|
63
|
+
console.log(
|
|
64
|
+
` 💡 ${styles.bold('Tip:')} ${pet.name} misses you! Run ${styles.orange(`${styles.binName()} play`)} to check in.`,
|
|
65
|
+
);
|
|
60
66
|
console.log();
|
|
61
67
|
},
|
|
62
68
|
},
|
|
@@ -69,7 +75,7 @@ const tips = [
|
|
|
69
75
|
*/
|
|
70
76
|
export function getRandomTip(context) {
|
|
71
77
|
const cmd = context.command;
|
|
72
|
-
const eligible = tips.filter(
|
|
78
|
+
const eligible = tips.filter(t => {
|
|
73
79
|
// Filter by command if tip has a commands array
|
|
74
80
|
if (t.commands && cmd && !t.commands.includes(cmd)) return false;
|
|
75
81
|
// Tips without commands array show everywhere
|
|
@@ -83,7 +89,10 @@ export function getRandomTip(context) {
|
|
|
83
89
|
let chosen = eligible[eligible.length - 1];
|
|
84
90
|
for (const tip of eligible) {
|
|
85
91
|
rand -= tip.weight || 1;
|
|
86
|
-
if (rand <= 0) {
|
|
92
|
+
if (rand <= 0) {
|
|
93
|
+
chosen = tip;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
87
96
|
}
|
|
88
97
|
// Bind context so render() can read it without a second argument.
|
|
89
98
|
return { render: () => chosen.render(context) };
|
package/src/utils/url-segs.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
// Extensions that are implementation artifacts, not meaningful path segments.
|
|
7
|
-
const DOC_FILE_EXTENSIONS = ['md', 'mdx', 'html', 'htm', 'txt']
|
|
8
|
-
const DOC_EXT_RE = new RegExp(`(\\.(${DOC_FILE_EXTENSIONS.join('|')}))+$`, 'i')
|
|
7
|
+
const DOC_FILE_EXTENSIONS = ['md', 'mdx', 'html', 'htm', 'txt'];
|
|
8
|
+
const DOC_EXT_RE = new RegExp(`(\\.(${DOC_FILE_EXTENSIONS.join('|')}))+$`, 'i');
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Strip all chained doc-file extensions (.html.md, .html, .md, .mdx, .htm, .txt)
|
|
@@ -13,7 +13,7 @@ const DOC_EXT_RE = new RegExp(`(\\.(${DOC_FILE_EXTENSIONS.join('|')}))+$`, 'i')
|
|
|
13
13
|
* by some SSGs (e.g. `.html.md`).
|
|
14
14
|
*/
|
|
15
15
|
export function stripSegmentExtensions(s) {
|
|
16
|
-
return s.replace(DOC_EXT_RE, '')
|
|
16
|
+
return s.replace(DOC_EXT_RE, '');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -28,12 +28,12 @@ export function urlTrieSegs(url) {
|
|
|
28
28
|
const segs = new URL(url).pathname
|
|
29
29
|
.split('/')
|
|
30
30
|
.filter(Boolean)
|
|
31
|
-
.map(
|
|
32
|
-
.filter(Boolean)
|
|
33
|
-
if (segs.length > 0 && segs[segs.length - 1].toLowerCase() === 'index') segs.pop()
|
|
34
|
-
return segs
|
|
31
|
+
.map(s => stripSegmentExtensions(s))
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
if (segs.length > 0 && segs[segs.length - 1].toLowerCase() === 'index') segs.pop();
|
|
34
|
+
return segs;
|
|
35
35
|
} catch {
|
|
36
|
-
return null
|
|
36
|
+
return null;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -44,17 +44,17 @@ export function urlTrieSegs(url) {
|
|
|
44
44
|
* `/foo/index.html.md` and `/foo/` both collapse to the same slug base.
|
|
45
45
|
*/
|
|
46
46
|
export function extractUrlPathSegments(url) {
|
|
47
|
-
if (!url) return []
|
|
47
|
+
if (!url) return [];
|
|
48
48
|
try {
|
|
49
49
|
const segs = new URL(url).pathname
|
|
50
50
|
.split('/')
|
|
51
51
|
.filter(Boolean)
|
|
52
|
-
.map(
|
|
53
|
-
.filter(Boolean)
|
|
54
|
-
if (segs.length > 0 && segs[segs.length - 1].toLowerCase() === 'index') segs.pop()
|
|
55
|
-
return segs
|
|
52
|
+
.map(s => stripSegmentExtensions(s).replace(/^\d+[-_.]/, ''))
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
if (segs.length > 0 && segs[segs.length - 1].toLowerCase() === 'index') segs.pop();
|
|
55
|
+
return segs;
|
|
56
56
|
} catch {
|
|
57
|
-
return []
|
|
57
|
+
return [];
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -66,13 +66,13 @@ export function extractUrlPathSegments(url) {
|
|
|
66
66
|
*/
|
|
67
67
|
export function normalizePath(url) {
|
|
68
68
|
try {
|
|
69
|
-
const u = new URL(url)
|
|
70
|
-
let p = u.pathname.replace(/\/$/, '').toLowerCase()
|
|
71
|
-
p = p.replace(DOC_EXT_RE, '')
|
|
72
|
-
p = p.replace(/\/index$/i, '')
|
|
73
|
-
return p || '/'
|
|
69
|
+
const u = new URL(url);
|
|
70
|
+
let p = u.pathname.replace(/\/$/, '').toLowerCase();
|
|
71
|
+
p = p.replace(DOC_EXT_RE, '');
|
|
72
|
+
p = p.replace(/\/index$/i, '');
|
|
73
|
+
return p || '/';
|
|
74
74
|
} catch {
|
|
75
|
-
return String(url).toLowerCase()
|
|
75
|
+
return String(url).toLowerCase();
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -87,38 +87,43 @@ export function normalizePath(url) {
|
|
|
87
87
|
*/
|
|
88
88
|
export function llmsPageDedupeKey(url) {
|
|
89
89
|
try {
|
|
90
|
-
const u = new URL(url)
|
|
91
|
-
let p = u.pathname.replace(/\/$/, '').toLowerCase()
|
|
92
|
-
p = p.replace(DOC_EXT_RE, '')
|
|
93
|
-
p = p.replace(/\/index$/i, '')
|
|
90
|
+
const u = new URL(url);
|
|
91
|
+
let p = u.pathname.replace(/\/$/, '').toLowerCase();
|
|
92
|
+
p = p.replace(DOC_EXT_RE, '');
|
|
93
|
+
p = p.replace(/\/index$/i, '');
|
|
94
94
|
|
|
95
|
-
const params = [...u.searchParams.entries()].sort(([ak, av], [bk, bv]) =>
|
|
96
|
-
|
|
95
|
+
const params = [...u.searchParams.entries()].sort(([ak, av], [bk, bv]) =>
|
|
96
|
+
ak === bk ? av.localeCompare(bv) : ak.localeCompare(bk),
|
|
97
|
+
);
|
|
98
|
+
const query =
|
|
99
|
+
params.length > 0
|
|
100
|
+
? `?${params.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')}`
|
|
101
|
+
: '';
|
|
97
102
|
|
|
98
|
-
return `${u.origin.toLowerCase()}${p || '/'}${query}
|
|
103
|
+
return `${u.origin.toLowerCase()}${p || '/'}${query}`;
|
|
99
104
|
} catch {
|
|
100
|
-
return String(url).toLowerCase()
|
|
105
|
+
return String(url).toLowerCase();
|
|
101
106
|
}
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
export function compareCanonicalUrlPreference(a, b) {
|
|
105
|
-
const aRank = canonicalUrlPreferenceRank(a)
|
|
106
|
-
const bRank = canonicalUrlPreferenceRank(b)
|
|
107
|
-
if (aRank !== bRank) return aRank - bRank
|
|
108
|
-
return String(a || '').length - String(b || '').length
|
|
110
|
+
const aRank = canonicalUrlPreferenceRank(a);
|
|
111
|
+
const bRank = canonicalUrlPreferenceRank(b);
|
|
112
|
+
if (aRank !== bRank) return aRank - bRank;
|
|
113
|
+
return String(a || '').length - String(b || '').length;
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
function canonicalUrlPreferenceRank(url) {
|
|
112
117
|
try {
|
|
113
|
-
const pathname = new URL(url).pathname
|
|
114
|
-
const lower = pathname.toLowerCase()
|
|
115
|
-
if (
|
|
116
|
-
if (
|
|
117
|
-
if (
|
|
118
|
-
if (
|
|
119
|
-
if (/\/index\.html?$/.test(lower)) return 4
|
|
120
|
-
return 5
|
|
118
|
+
const pathname = new URL(url).pathname;
|
|
119
|
+
const lower = pathname.toLowerCase();
|
|
120
|
+
if (lower.endsWith('.md')) return 0;
|
|
121
|
+
if (lower.endsWith('.txt')) return 1;
|
|
122
|
+
if (!pathname.endsWith('/') && !/\/index\.html?$/.test(lower) && !/\.[^/]+$/.test(lower)) return 2;
|
|
123
|
+
if (pathname.endsWith('/')) return 3;
|
|
124
|
+
if (/\/index\.html?$/.test(lower)) return 4;
|
|
125
|
+
return 5;
|
|
121
126
|
} catch {
|
|
122
|
-
return 5
|
|
127
|
+
return 5;
|
|
123
128
|
}
|
|
124
129
|
}
|
|
@@ -1,44 +1,46 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import path from
|
|
3
|
-
|
|
4
|
-
import markdown from
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import markdown from '@readme/markdown';
|
|
5
|
+
|
|
6
|
+
import { parseFrontmatter } from '../utils/markdown.js';
|
|
5
7
|
|
|
6
8
|
const { mdxishTags } = markdown;
|
|
7
9
|
|
|
8
|
-
export const name =
|
|
10
|
+
export const name = 'components';
|
|
9
11
|
|
|
10
12
|
// Built-in components provided by @readme/markdown.
|
|
11
13
|
const BUILTIN_COMPONENTS = new Set([
|
|
12
14
|
// Documented in ReadMe user docs
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
'Accordion',
|
|
16
|
+
'Tabs',
|
|
17
|
+
'Tab',
|
|
18
|
+
'Cards',
|
|
19
|
+
'Card',
|
|
20
|
+
'Columns',
|
|
21
|
+
'Column',
|
|
22
|
+
'Image',
|
|
21
23
|
// Available from @readme/markdown
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
24
|
+
'Anchor',
|
|
25
|
+
'Callout',
|
|
26
|
+
'Code',
|
|
27
|
+
'CodeTabs',
|
|
28
|
+
'Embed',
|
|
29
|
+
'Glossary',
|
|
30
|
+
'Heading',
|
|
31
|
+
'HTMLBlock',
|
|
32
|
+
'Table',
|
|
33
|
+
'TableOfContents',
|
|
34
|
+
'Recipe',
|
|
35
|
+
'MCPIntro',
|
|
36
|
+
'PostmanRunButton',
|
|
37
|
+
'TailwindRoot',
|
|
38
|
+
'TailwindStyle',
|
|
39
|
+
'TutorialTile',
|
|
38
40
|
]);
|
|
39
41
|
|
|
40
42
|
// Directories that can contain MDXish content with component references.
|
|
41
|
-
const CONTENT_DIRS = new Set([
|
|
43
|
+
const CONTENT_DIRS = new Set(['docs', 'reference', 'custom_pages', 'recipes']);
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Extract component tag names from MDXish content.
|
|
@@ -48,12 +50,7 @@ const CONTENT_DIRS = new Set(["docs", "reference", "custom_pages", "recipes"]);
|
|
|
48
50
|
function extractComponentTags(content) {
|
|
49
51
|
// Strip frontmatter — mdxishTags parses raw mdxish, so frontmatter values
|
|
50
52
|
// like `foo: <NotAComponent />` would otherwise be picked up.
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
({ content: body } = matter(content));
|
|
54
|
-
} catch {
|
|
55
|
-
body = content.replace(/^---[\s\S]*?---/, "");
|
|
56
|
-
}
|
|
53
|
+
const { content: body } = parseFrontmatter(content);
|
|
57
54
|
|
|
58
55
|
try {
|
|
59
56
|
return new Set(mdxishTags(body));
|
|
@@ -82,33 +79,33 @@ function extractExportedNames(body) {
|
|
|
82
79
|
* .md files: uses the filename slug (e.g., Greeting.md → <Greeting />).
|
|
83
80
|
*/
|
|
84
81
|
function collectCustomBlockNames(gitRoot) {
|
|
85
|
-
const blocksDir = path.join(gitRoot,
|
|
82
|
+
const blocksDir = path.join(gitRoot, 'custom_blocks');
|
|
86
83
|
if (!fs.existsSync(blocksDir)) return new Set();
|
|
87
84
|
|
|
88
85
|
const names = new Set();
|
|
89
86
|
const entries = fs.readdirSync(blocksDir);
|
|
90
87
|
|
|
91
88
|
for (const entry of entries) {
|
|
92
|
-
if (!entry.endsWith(
|
|
89
|
+
if (!entry.endsWith('.md') && !entry.endsWith('.mdx')) continue;
|
|
93
90
|
|
|
94
91
|
try {
|
|
95
|
-
const content = fs.readFileSync(path.join(blocksDir, entry),
|
|
92
|
+
const content = fs.readFileSync(path.join(blocksDir, entry), 'utf-8');
|
|
96
93
|
|
|
97
|
-
if (entry.endsWith(
|
|
94
|
+
if (entry.endsWith('.mdx')) {
|
|
98
95
|
// .mdx: component names come from exports.
|
|
99
|
-
const { content: body } =
|
|
96
|
+
const { content: body } = parseFrontmatter(content);
|
|
100
97
|
for (const n of extractExportedNames(body)) {
|
|
101
98
|
names.add(n);
|
|
102
99
|
}
|
|
103
100
|
} else {
|
|
104
101
|
// .md snippet: component name is the PascalCase filename.
|
|
105
|
-
const slug = entry.replace(/\.md$/,
|
|
102
|
+
const slug = entry.replace(/\.md$/, '');
|
|
106
103
|
names.add(slug);
|
|
107
104
|
// Also register the PascalCase form so references resolve.
|
|
108
105
|
const pascalCase = slug
|
|
109
106
|
.split(/[-_]/)
|
|
110
|
-
.map(
|
|
111
|
-
.join(
|
|
107
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
108
|
+
.join('');
|
|
112
109
|
names.add(pascalCase);
|
|
113
110
|
}
|
|
114
111
|
} catch {
|
|
@@ -125,11 +122,11 @@ function collectCustomBlockNames(gitRoot) {
|
|
|
125
122
|
* - .md: filename must be capitalized (used as component name via <Name />)
|
|
126
123
|
*/
|
|
127
124
|
export function validate({ content, relativePath }) {
|
|
128
|
-
if (!relativePath.startsWith(
|
|
125
|
+
if (!relativePath.startsWith('custom_blocks/')) return null;
|
|
129
126
|
|
|
130
127
|
const filename = path.basename(relativePath);
|
|
131
|
-
const isMdx = relativePath.endsWith(
|
|
132
|
-
const isMd = relativePath.endsWith(
|
|
128
|
+
const isMdx = relativePath.endsWith('.mdx');
|
|
129
|
+
const isMd = relativePath.endsWith('.md');
|
|
133
130
|
|
|
134
131
|
if (!isMdx && !isMd) return null;
|
|
135
132
|
|
|
@@ -140,16 +137,16 @@ export function validate({ content, relativePath }) {
|
|
|
140
137
|
// digits are fine (<Helm_v4_error /> is valid); hyphens and a lowercase initial
|
|
141
138
|
// are not.
|
|
142
139
|
if (isMd) {
|
|
143
|
-
const slug = filename.replace(/\.md$/,
|
|
140
|
+
const slug = filename.replace(/\.md$/, '');
|
|
144
141
|
if (!/^[A-Z][A-Za-z0-9_$]*$/.test(slug)) {
|
|
145
142
|
const suggestion = slug
|
|
146
143
|
.split(/[-_]/)
|
|
147
|
-
.map(
|
|
148
|
-
.join(
|
|
144
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
145
|
+
.join('');
|
|
149
146
|
results.push({
|
|
150
147
|
file: relativePath,
|
|
151
148
|
rule: name,
|
|
152
|
-
severity:
|
|
149
|
+
severity: 'warning',
|
|
153
150
|
message: `Bad filename: "${slug}" can't be used as a component tag — rename to "${suggestion}.md" so it can be used as <${suggestion} />`,
|
|
154
151
|
});
|
|
155
152
|
}
|
|
@@ -157,14 +154,9 @@ export function validate({ content, relativePath }) {
|
|
|
157
154
|
}
|
|
158
155
|
|
|
159
156
|
// .mdx component files: validate export and usage example.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
try {
|
|
164
|
-
({ data, content: body } = matter(content));
|
|
165
|
-
} catch {
|
|
166
|
-
return null; // frontmatter validator handles parse errors.
|
|
167
|
-
}
|
|
157
|
+
// Invalid YAML is reported by the frontmatter validator via `error`.
|
|
158
|
+
const { data, content: body, error } = parseFrontmatter(content);
|
|
159
|
+
if (error) return null;
|
|
168
160
|
|
|
169
161
|
const componentName = data.name;
|
|
170
162
|
if (!componentName) return null; // frontmatter validator handles missing name.
|
|
@@ -187,7 +179,7 @@ export function validate({ content, relativePath }) {
|
|
|
187
179
|
results.push({
|
|
188
180
|
file: relativePath,
|
|
189
181
|
rule: name,
|
|
190
|
-
severity:
|
|
182
|
+
severity: 'warning',
|
|
191
183
|
message: `Missing example: no usage example found for "${componentName}"`,
|
|
192
184
|
});
|
|
193
185
|
}
|
|
@@ -204,14 +196,14 @@ export function validateAll(files, gitRoot) {
|
|
|
204
196
|
const available = new Set([...BUILTIN_COMPONENTS, ...customBlocks]);
|
|
205
197
|
|
|
206
198
|
for (const relPath of files) {
|
|
207
|
-
const dir = relPath.split(
|
|
199
|
+
const dir = relPath.split('/')[0];
|
|
208
200
|
if (!CONTENT_DIRS.has(dir)) continue;
|
|
209
|
-
if (!relPath.endsWith(
|
|
201
|
+
if (!relPath.endsWith('.md')) continue;
|
|
210
202
|
|
|
211
203
|
const filePath = path.join(gitRoot, relPath);
|
|
212
204
|
let content;
|
|
213
205
|
try {
|
|
214
|
-
content = fs.readFileSync(filePath,
|
|
206
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
215
207
|
} catch {
|
|
216
208
|
continue;
|
|
217
209
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
import { parseFrontmatter } from '../utils/markdown.js';
|
|
4
5
|
|
|
5
6
|
export const name = 'content';
|
|
6
7
|
|
|
@@ -9,13 +10,7 @@ export function validate({ filePath, content, relativePath }) {
|
|
|
9
10
|
if (!['docs', 'reference', 'custom_pages'].includes(dir)) return null;
|
|
10
11
|
if (!relativePath.endsWith('.md')) return null;
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
let body;
|
|
14
|
-
try {
|
|
15
|
-
({ data, content: body } = matter(content));
|
|
16
|
-
} catch {
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
13
|
+
const { data, content: body } = parseFrontmatter(content);
|
|
19
14
|
|
|
20
15
|
if (data.hidden || data.deprecated) return null;
|
|
21
16
|
if (data.api) return null;
|
|
@@ -27,7 +22,7 @@ export function validate({ filePath, content, relativePath }) {
|
|
|
27
22
|
file: relativePath,
|
|
28
23
|
rule: name,
|
|
29
24
|
severity: 'warning',
|
|
30
|
-
message:
|
|
25
|
+
message: "Redirect page has body content: pages with a link URL won't display their content",
|
|
31
26
|
};
|
|
32
27
|
}
|
|
33
28
|
return null;
|
|
@@ -36,7 +31,7 @@ export function validate({ filePath, content, relativePath }) {
|
|
|
36
31
|
// index.md files that act as parent pages (have child .md siblings) can be empty.
|
|
37
32
|
if (path.basename(filePath) === 'index.md') {
|
|
38
33
|
const siblings = fs.readdirSync(path.dirname(filePath));
|
|
39
|
-
if (siblings.some(
|
|
34
|
+
if (siblings.some(f => f.endsWith('.md') && f !== 'index.md')) {
|
|
40
35
|
return null;
|
|
41
36
|
}
|
|
42
37
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
|
|
2
3
|
import { slugForEntry } from '../utils/slug-for-entry.js';
|
|
3
4
|
|
|
4
5
|
export const name = 'duplicates';
|
|
@@ -26,12 +27,15 @@ export function validateAll(files) {
|
|
|
26
27
|
|
|
27
28
|
// TODO: figure out why ReadMeConfig causes duplicate slugs and handle properly.
|
|
28
29
|
// For now, skip any duplicate set that involves a ReadMeConfig path.
|
|
29
|
-
if (paths.some(
|
|
30
|
+
if (paths.some(p => p.includes('ReadMeConfig/'))) continue;
|
|
30
31
|
|
|
31
32
|
const slug = key.slice(key.indexOf(':') + 1);
|
|
32
33
|
const others = paths.slice(1);
|
|
33
34
|
for (const relPath of others) {
|
|
34
|
-
const otherLocations = paths
|
|
35
|
+
const otherLocations = paths
|
|
36
|
+
.filter(p => p !== relPath)
|
|
37
|
+
.map(p => path.dirname(p))
|
|
38
|
+
.join(', ');
|
|
35
39
|
results.push({
|
|
36
40
|
file: relPath,
|
|
37
41
|
rule: name,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
import { createRequire } from 'node:module';
|
|
4
|
-
import
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
5
|
import Ajv from 'ajv';
|
|
6
6
|
|
|
7
|
+
import { parseFrontmatter } from '../utils/markdown.js';
|
|
8
|
+
|
|
7
9
|
const require = createRequire(import.meta.url);
|
|
8
10
|
|
|
9
11
|
export const name = 'frontmatter';
|
|
@@ -12,7 +14,7 @@ function getNestedValue(obj, instancePath) {
|
|
|
12
14
|
const keys = instancePath.split('/').filter(Boolean);
|
|
13
15
|
let current = obj;
|
|
14
16
|
for (const key of keys) {
|
|
15
|
-
if (current == null || typeof current !== 'object') return
|
|
17
|
+
if (current == null || typeof current !== 'object') return;
|
|
16
18
|
current = current[key];
|
|
17
19
|
}
|
|
18
20
|
return current;
|
|
@@ -27,9 +29,7 @@ function levenshtein(a, b) {
|
|
|
27
29
|
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
28
30
|
for (let i = 1; i <= m; i++) {
|
|
29
31
|
for (let j = 1; j <= n; j++) {
|
|
30
|
-
dp[i][j] = a[i - 1] === b[j - 1]
|
|
31
|
-
? dp[i - 1][j - 1]
|
|
32
|
-
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
32
|
+
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
return dp[m][n];
|
|
@@ -93,14 +93,12 @@ for (const [dir, index] of Object.entries(DIR_SCHEMA_INDEX)) {
|
|
|
93
93
|
|
|
94
94
|
export function validate({ content, filePath, relativePath, fix }) {
|
|
95
95
|
// Step 1: Parse the frontmatter YAML.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
({ data } = matter(content));
|
|
99
|
-
} catch (e) {
|
|
96
|
+
const { data, error } = parseFrontmatter(content);
|
|
97
|
+
if (error) {
|
|
100
98
|
return {
|
|
101
99
|
file: relativePath,
|
|
102
100
|
rule: name,
|
|
103
|
-
message: `Invalid frontmatter: ${
|
|
101
|
+
message: `Invalid frontmatter: ${error.reason || error.message || 'unknown error'}`,
|
|
104
102
|
};
|
|
105
103
|
}
|
|
106
104
|
|
|
@@ -126,7 +124,7 @@ export function validate({ content, filePath, relativePath, fix }) {
|
|
|
126
124
|
|
|
127
125
|
if (err.keyword === 'not' && err.schema?.properties) {
|
|
128
126
|
const target = err.instancePath ? getNestedValue(data, err.instancePath) : data;
|
|
129
|
-
if (!target || !Object.keys(err.schema.properties).some(
|
|
127
|
+
if (!target || !Object.keys(err.schema.properties).some(k => k in target)) continue;
|
|
130
128
|
}
|
|
131
129
|
|
|
132
130
|
let msg;
|
|
@@ -169,9 +167,9 @@ export function validate({ content, filePath, relativePath, fix }) {
|
|
|
169
167
|
|
|
170
168
|
// Step 4: Detect typos by comparing unknown properties against known ones.
|
|
171
169
|
const missingRequired = results.filter(
|
|
172
|
-
|
|
170
|
+
r => r.severity === 'error' && r.message.includes('must have required property'),
|
|
173
171
|
);
|
|
174
|
-
const unknowns = results.filter(
|
|
172
|
+
const unknowns = results.filter(r => r._key);
|
|
175
173
|
|
|
176
174
|
const consumed = new Set();
|
|
177
175
|
|
|
@@ -223,17 +221,17 @@ export function validate({ content, filePath, relativePath, fix }) {
|
|
|
223
221
|
|
|
224
222
|
// Step 5: Apply fixes — rename unknown (non-typo) properties to x- prefixed.
|
|
225
223
|
if (fix && filePath) {
|
|
226
|
-
const typoKeys = new Set([...consumed].map(
|
|
227
|
-
const keysToFix = unknownKeys.filter(
|
|
224
|
+
const typoKeys = new Set([...consumed].map(r => r._key));
|
|
225
|
+
const keysToFix = unknownKeys.filter(key => !typoKeys.has(key) && !suggestedTypos.has(key));
|
|
228
226
|
|
|
229
227
|
if (keysToFix.length > 0) {
|
|
230
228
|
let fileContent = fs.readFileSync(filePath, 'utf-8');
|
|
231
229
|
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
230
|
+
// Same header boundaries as parseFrontmatter: leading blanks OK; empty
|
|
231
|
+
// headers (`---\n---`) leave group 1 unset so we skip the rewrite.
|
|
232
|
+
const fmMatch = fileContent.match(/^[\r\n]*---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*/);
|
|
233
|
+
if (fmMatch && fmMatch[1] != null) {
|
|
235
234
|
let frontmatter = fmMatch[1];
|
|
236
|
-
const keysToFixSet = new Set(keysToFix);
|
|
237
235
|
for (const key of keysToFix) {
|
|
238
236
|
const regex = new RegExp(`^(${key}:)`, 'gm');
|
|
239
237
|
frontmatter = frontmatter.replace(regex, `x-${key}:`);
|
|
@@ -253,6 +251,6 @@ export function validate({ content, filePath, relativePath, fix }) {
|
|
|
253
251
|
}
|
|
254
252
|
|
|
255
253
|
// Remove consumed warnings (they've been merged into the error).
|
|
256
|
-
const final = results.filter(
|
|
254
|
+
const final = results.filter(r => !consumed.has(r));
|
|
257
255
|
return final.length > 0 ? final : null;
|
|
258
256
|
}
|
package/src/validators/links.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
import { parseFrontmatter } from '../utils/markdown.js';
|
|
4
5
|
import { slugForEntry } from '../utils/slug-for-entry.js';
|
|
5
6
|
|
|
6
7
|
export const name = 'links';
|
|
@@ -36,13 +37,8 @@ export function validateAll(files, gitRoot) {
|
|
|
36
37
|
if (!CHECKED_DIRS.includes(topDir)) continue;
|
|
37
38
|
if (!/\.(md|mdx)$/.test(relPath)) continue;
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const raw = fs.readFileSync(path.join(gitRoot, relPath), 'utf-8');
|
|
42
|
-
({ content: body } = matter(raw));
|
|
43
|
-
} catch {
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
40
|
+
const raw = fs.readFileSync(path.join(gitRoot, relPath), 'utf-8');
|
|
41
|
+
const { content: body } = parseFrontmatter(raw);
|
|
46
42
|
|
|
47
43
|
for (const match of body.matchAll(DOC_LINK_RE)) {
|
|
48
44
|
const prefix = match[0].slice(1, match[0].indexOf(':'));
|