@readme/cli 0.1.0 → 0.3.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.
Files changed (57) hide show
  1. package/bin/git-format.js +80 -0
  2. package/bin/readme.js +1 -1
  3. package/package.json +54 -26
  4. package/src/bootstrap.js +9 -13
  5. package/src/cli.js +17 -11
  6. package/src/commands/components.js +35 -18
  7. package/src/commands/dev.js +12 -8
  8. package/src/commands/eyes.js +12 -8
  9. package/src/commands/import.js +2200 -2049
  10. package/src/commands/lint.js +33 -29
  11. package/src/commands/oas-bundle.js +6 -2
  12. package/src/commands/oas-sync.js +271 -161
  13. package/src/commands/oas-validate.js +10 -7
  14. package/src/commands/pretty.js +188 -165
  15. package/src/commands/setup.js +24 -26
  16. package/src/commands/versions.js +6 -2
  17. package/src/dev/app/[...slug]/page.js +1 -0
  18. package/src/dev/app/layout.js +18 -11
  19. package/src/dev/app/page.js +1 -0
  20. package/src/dev/lib/docs.js +27 -19
  21. package/src/dev/next.config.mjs +2 -2
  22. package/src/gha.js +7 -7
  23. package/src/prompts/index.js +16 -6
  24. package/src/utils/containment.js +87 -0
  25. package/src/utils/eyes.js +143 -51
  26. package/src/utils/git.js +3 -3
  27. package/src/utils/lint.js +39 -38
  28. package/src/utils/llms.js +94 -91
  29. package/src/utils/markdown.js +256 -0
  30. package/src/utils/marketplace.js +12 -5
  31. package/src/utils/multi-spinner.js +5 -7
  32. package/src/utils/promise-pool.js +7 -5
  33. package/src/utils/reporter.js +88 -94
  34. package/src/utils/setup-templates.js +12 -6
  35. package/src/utils/styles.js +2 -1
  36. package/src/utils/tamagotchi.js +257 -91
  37. package/src/utils/tips.js +18 -9
  38. package/src/utils/url-segs.js +46 -41
  39. package/src/validators/components.js +55 -63
  40. package/src/validators/content.js +16 -14
  41. package/src/validators/duplicates.js +6 -2
  42. package/src/validators/frontmatter.js +21 -29
  43. package/src/validators/links.js +4 -8
  44. package/src/validators/oas-reference.js +25 -18
  45. package/src/validators/oas-schema.js +2 -0
  46. package/src/validators/ordering.js +5 -5
  47. package/src/validators/recipes.js +4 -12
  48. package/vendor/git-format/CLAUDE.md +352 -0
  49. package/vendor/git-format/frontmatter.schema.json +199 -0
  50. package/src/commands/import-oas.test.js +0 -277
  51. package/src/commands/import.classify.test.js +0 -172
  52. package/src/commands/import.landing.test.js +0 -217
  53. package/src/commands/import.slug-planner.test.js +0 -86
  54. package/src/commands/import.staging.test.js +0 -230
  55. package/src/commands/import.test.js +0 -382
  56. package/src/utils/llms.test.js +0 -302
  57. 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: (ctx) => !ctx.isRunningInClaude && ctx.workflowOutdated,
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: (ctx) => !ctx.isRunningInClaude && ctx.detectedPlatform && !ctx.hasCiWorkflow,
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: (ctx) => !ctx.isRunningInClaude && ctx.hasClaude,
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(` ${styles.dim('⎿')} ${styles.orange(`claude "run '${styles.binName()} lint' and fix the issues"`)}`);
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: (ctx) => !ctx.isRunningInClaude && !ctx.hasClaude,
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(` 💡 ${styles.bold('Tip:')} Want a little friend? Run ${styles.orange(`${styles.binName()} play`)} to hatch one!`);
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(` 💡 ${styles.bold('Tip:')} ${pet.name} misses you! Run ${styles.orange(`${styles.binName()} play`)} to check in.`);
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((t) => {
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) { chosen = tip; break; }
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) };
@@ -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((s) => stripSegmentExtensions(s))
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((s) => stripSegmentExtensions(s).replace(/^\d+[-_.]/, ''))
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]) => (ak === bk ? av.localeCompare(bv) : ak.localeCompare(bk)))
96
- const query = params.length > 0 ? `?${params.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')}` : ''
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 (/\.md$/.test(lower)) return 0
116
- if (/\.txt$/.test(lower)) return 1
117
- if (!/\/$/.test(pathname) && !/\/index\.html?$/.test(lower) && !/\.[^/]+$/.test(lower)) return 2
118
- if (/\/$/.test(pathname)) return 3
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 "node:fs";
2
- import path from "node:path";
3
- import matter from "gray-matter";
4
- import markdown from "@readme/markdown";
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 = "components";
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
- "Accordion",
14
- "Tabs",
15
- "Tab",
16
- "Cards",
17
- "Card",
18
- "Columns",
19
- "Column",
20
- "Image",
15
+ 'Accordion',
16
+ 'Tabs',
17
+ 'Tab',
18
+ 'Cards',
19
+ 'Card',
20
+ 'Columns',
21
+ 'Column',
22
+ 'Image',
21
23
  // Available from @readme/markdown
22
- "Anchor",
23
- "Callout",
24
- "Code",
25
- "CodeTabs",
26
- "Embed",
27
- "Glossary",
28
- "Heading",
29
- "HTMLBlock",
30
- "Table",
31
- "TableOfContents",
32
- "Recipe",
33
- "MCPIntro",
34
- "PostmanRunButton",
35
- "TailwindRoot",
36
- "TailwindStyle",
37
- "TutorialTile",
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(["docs", "reference", "custom_pages", "recipes"]);
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
- let body;
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, "custom_blocks");
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(".md") && !entry.endsWith(".mdx")) continue;
89
+ if (!entry.endsWith('.md') && !entry.endsWith('.mdx')) continue;
93
90
 
94
91
  try {
95
- const content = fs.readFileSync(path.join(blocksDir, entry), "utf-8");
92
+ const content = fs.readFileSync(path.join(blocksDir, entry), 'utf-8');
96
93
 
97
- if (entry.endsWith(".mdx")) {
94
+ if (entry.endsWith('.mdx')) {
98
95
  // .mdx: component names come from exports.
99
- const { content: body } = matter(content);
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((part) => part.charAt(0).toUpperCase() + part.slice(1))
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("custom_blocks/")) return null;
125
+ if (!relativePath.startsWith('custom_blocks/')) return null;
129
126
 
130
127
  const filename = path.basename(relativePath);
131
- const isMdx = relativePath.endsWith(".mdx");
132
- const isMd = relativePath.endsWith(".md");
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((part) => part.charAt(0).toUpperCase() + part.slice(1))
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: "warning",
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
- let data;
161
- let body;
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: "warning",
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("/")[0];
199
+ const dir = relPath.split('/')[0];
208
200
  if (!CONTENT_DIRS.has(dir)) continue;
209
- if (!relPath.endsWith(".md")) continue;
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, "utf-8");
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
- import matter from 'gray-matter';
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
- let data;
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,18 +22,25 @@ export function validate({ filePath, content, relativePath }) {
27
22
  file: relativePath,
28
23
  rule: name,
29
24
  severity: 'warning',
30
- message: 'Redirect page has body content: pages with a link URL won\'t display their content',
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;
34
29
  }
35
30
  if (trimmed.length === 0) {
36
- // index.md files that act as parent pages (have child .md siblings) can be empty.
31
+ // index.md files that act as parent pages (have child page siblings or nested page
32
+ // directories) can be empty. The backend accepts .md and .mdx in any case.
37
33
  if (path.basename(filePath) === 'index.md') {
38
- const siblings = fs.readdirSync(path.dirname(filePath));
39
- if (siblings.some((f) => f.endsWith('.md') && f !== 'index.md')) {
40
- return null;
41
- }
34
+ const dir = path.dirname(filePath);
35
+ const isPage = f => /\.mdx?$/i.test(f);
36
+ const hasChildren = fs
37
+ .readdirSync(dir, { withFileTypes: true })
38
+ .some(
39
+ e =>
40
+ (!e.isDirectory() && isPage(e.name) && !/^index\.mdx?$/i.test(e.name)) ||
41
+ (e.isDirectory() && fs.readdirSync(path.join(dir, e.name)).some(isPage)),
42
+ );
43
+ if (hasChildren) return null;
42
44
  }
43
45
 
44
46
  return {
@@ -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((p) => p.includes('ReadMeConfig/'))) continue;
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.filter((p) => p !== relPath).map((p) => path.dirname(p)).join(', ');
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,10 +1,9 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
- import matter from 'gray-matter';
2
+
5
3
  import Ajv from 'ajv';
6
4
 
7
- const require = createRequire(import.meta.url);
5
+ import frontmatterSchema from '../../vendor/git-format/frontmatter.schema.json' with { type: 'json' };
6
+ import { parseFrontmatter } from '../utils/markdown.js';
8
7
 
9
8
  export const name = 'frontmatter';
10
9
 
@@ -12,7 +11,7 @@ function getNestedValue(obj, instancePath) {
12
11
  const keys = instancePath.split('/').filter(Boolean);
13
12
  let current = obj;
14
13
  for (const key of keys) {
15
- if (current == null || typeof current !== 'object') return undefined;
14
+ if (current == null || typeof current !== 'object') return;
16
15
  current = current[key];
17
16
  }
18
17
  return current;
@@ -27,9 +26,7 @@ function levenshtein(a, b) {
27
26
  for (let j = 0; j <= n; j++) dp[0][j] = j;
28
27
  for (let i = 1; i <= m; i++) {
29
28
  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]);
29
+ 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
30
  }
34
31
  }
35
32
  return dp[m][n];
@@ -77,30 +74,25 @@ const DIR_SCHEMA_INDEX = {
77
74
  custom_blocks: 4,
78
75
  };
79
76
 
80
- // Load the schema and compile a validator for each directory type.
81
- const schemaPath = require.resolve('@readmeio/git-format/frontmatter.schema.json');
82
- const fullSchema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
83
-
77
+ // Compile a validator for each directory type from the vendored schema.
84
78
  const ajv = new Ajv({ allErrors: true, strict: false, logger: false, verbose: true });
85
79
 
86
80
  const validators = {};
87
81
  const knownProperties = {};
88
82
  for (const [dir, index] of Object.entries(DIR_SCHEMA_INDEX)) {
89
- const subSchema = { defs: fullSchema.defs, ...fullSchema.oneOf[index] };
83
+ const subSchema = { defs: frontmatterSchema.defs, ...frontmatterSchema.oneOf[index] };
90
84
  validators[dir] = ajv.compile(subSchema);
91
- knownProperties[dir] = collectPropertyNames(subSchema, fullSchema.defs);
85
+ knownProperties[dir] = collectPropertyNames(subSchema, frontmatterSchema.defs);
92
86
  }
93
87
 
94
88
  export function validate({ content, filePath, relativePath, fix }) {
95
89
  // Step 1: Parse the frontmatter YAML.
96
- let data;
97
- try {
98
- ({ data } = matter(content));
99
- } catch (e) {
90
+ const { data, error } = parseFrontmatter(content);
91
+ if (error) {
100
92
  return {
101
93
  file: relativePath,
102
94
  rule: name,
103
- message: `Invalid frontmatter: ${e.reason || e.message || 'unknown error'}`,
95
+ message: `Invalid frontmatter: ${error.reason || error.message || 'unknown error'}`,
104
96
  };
105
97
  }
106
98
 
@@ -126,7 +118,7 @@ export function validate({ content, filePath, relativePath, fix }) {
126
118
 
127
119
  if (err.keyword === 'not' && err.schema?.properties) {
128
120
  const target = err.instancePath ? getNestedValue(data, err.instancePath) : data;
129
- if (!target || !Object.keys(err.schema.properties).some((k) => k in target)) continue;
121
+ if (!target || !Object.keys(err.schema.properties).some(k => k in target)) continue;
130
122
  }
131
123
 
132
124
  let msg;
@@ -169,9 +161,9 @@ export function validate({ content, filePath, relativePath, fix }) {
169
161
 
170
162
  // Step 4: Detect typos by comparing unknown properties against known ones.
171
163
  const missingRequired = results.filter(
172
- (r) => r.severity === 'error' && r.message.includes('must have required property'),
164
+ r => r.severity === 'error' && r.message.includes('must have required property'),
173
165
  );
174
- const unknowns = results.filter((r) => r._key);
166
+ const unknowns = results.filter(r => r._key);
175
167
 
176
168
  const consumed = new Set();
177
169
 
@@ -223,17 +215,17 @@ export function validate({ content, filePath, relativePath, fix }) {
223
215
 
224
216
  // Step 5: Apply fixes — rename unknown (non-typo) properties to x- prefixed.
225
217
  if (fix && filePath) {
226
- const typoKeys = new Set([...consumed].map((r) => r._key));
227
- const keysToFix = unknownKeys.filter((key) => !typoKeys.has(key) && !suggestedTypos.has(key));
218
+ const typoKeys = new Set([...consumed].map(r => r._key));
219
+ const keysToFix = unknownKeys.filter(key => !typoKeys.has(key) && !suggestedTypos.has(key));
228
220
 
229
221
  if (keysToFix.length > 0) {
230
222
  let fileContent = fs.readFileSync(filePath, 'utf-8');
231
223
 
232
- // Only replace within the frontmatter block (between the --- delimiters).
233
- const fmMatch = fileContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
234
- if (fmMatch) {
224
+ // Same header boundaries as parseFrontmatter: leading blanks OK; empty
225
+ // headers (`---\n---`) leave group 1 unset so we skip the rewrite.
226
+ const fmMatch = fileContent.match(/^[\r\n]*---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*/);
227
+ if (fmMatch && fmMatch[1] != null) {
235
228
  let frontmatter = fmMatch[1];
236
- const keysToFixSet = new Set(keysToFix);
237
229
  for (const key of keysToFix) {
238
230
  const regex = new RegExp(`^(${key}:)`, 'gm');
239
231
  frontmatter = frontmatter.replace(regex, `x-${key}:`);
@@ -253,6 +245,6 @@ export function validate({ content, filePath, relativePath, fix }) {
253
245
  }
254
246
 
255
247
  // Remove consumed warnings (they've been merged into the error).
256
- const final = results.filter((r) => !consumed.has(r));
248
+ const final = results.filter(r => !consumed.has(r));
257
249
  return final.length > 0 ? final : null;
258
250
  }