@readme/cli 0.0.26
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/README.md +55 -0
- package/bin/readme.js +8 -0
- package/package.json +58 -0
- package/src/bootstrap.js +97 -0
- package/src/cli.js +189 -0
- package/src/commands/dev.js +119 -0
- package/src/commands/eyes.js +37 -0
- package/src/commands/import.js +2565 -0
- package/src/commands/lint.js +70 -0
- package/src/commands/oas-sync.js +364 -0
- package/src/commands/oas-validate.js +208 -0
- package/src/commands/play.js +17 -0
- package/src/commands/pretty.js +133 -0
- package/src/commands/setup.js +256 -0
- package/src/commands/versions.js +81 -0
- package/src/dev/.next/app-build-manifest.json +20 -0
- package/src/dev/.next/build-manifest.json +31 -0
- package/src/dev/.next/cache/.rscinfo +1 -0
- package/src/dev/.next/cache/next-devtools-config.json +1 -0
- package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
- package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
- package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
- package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
- package/src/dev/.next/package.json +1 -0
- package/src/dev/.next/prerender-manifest.json +11 -0
- package/src/dev/.next/react-loadable-manifest.json +1 -0
- package/src/dev/.next/routes-manifest.json +1 -0
- package/src/dev/.next/server/app/[...slug]/page.js +360 -0
- package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +1 -0
- package/src/dev/.next/server/app/page.js +349 -0
- package/src/dev/.next/server/app/page_client-reference-manifest.js +1 -0
- package/src/dev/.next/server/app-paths-manifest.json +3 -0
- package/src/dev/.next/server/edge-runtime-webpack.js +1151 -0
- package/src/dev/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/src/dev/.next/server/middleware-build-manifest.js +33 -0
- package/src/dev/.next/server/middleware-manifest.json +32 -0
- package/src/dev/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/src/dev/.next/server/middleware.js +1113 -0
- package/src/dev/.next/server/next-font-manifest.js +1 -0
- package/src/dev/.next/server/next-font-manifest.json +1 -0
- package/src/dev/.next/server/pages-manifest.json +5 -0
- package/src/dev/.next/server/server-reference-manifest.js +1 -0
- package/src/dev/.next/server/server-reference-manifest.json +5 -0
- package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +1 -0
- package/src/dev/.next/server/vendor-chunks/@readme.js +25 -0
- package/src/dev/.next/server/vendor-chunks/@swc.js +55 -0
- package/src/dev/.next/server/vendor-chunks/next.js +3659 -0
- package/src/dev/.next/server/webpack-runtime.js +209 -0
- package/src/dev/.next/static/chunks/app/[...slug]/loading.js +28 -0
- package/src/dev/.next/static/chunks/app/[...slug]/page.js +28 -0
- package/src/dev/.next/static/chunks/app/layout.js +171 -0
- package/src/dev/.next/static/chunks/app/page.js +28 -0
- package/src/dev/.next/static/chunks/app-pages-internals.js +182 -0
- package/src/dev/.next/static/chunks/main-app.js +1882 -0
- package/src/dev/.next/static/chunks/polyfills.js +1 -0
- package/src/dev/.next/static/chunks/webpack.js +1393 -0
- package/src/dev/.next/static/css/app/layout.css +559 -0
- package/src/dev/.next/static/development/_buildManifest.js +1 -0
- package/src/dev/.next/static/development/_ssgManifest.js +1 -0
- package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +1 -0
- package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +1 -0
- package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +12 -0
- package/src/dev/.next/trace +21 -0
- package/src/dev/.next/types/app/[...slug]/page.ts +84 -0
- package/src/dev/.next/types/app/layout.ts +84 -0
- package/src/dev/.next/types/app/page.ts +84 -0
- package/src/dev/.next/types/cache-life.d.ts +141 -0
- package/src/dev/.next/types/package.json +1 -0
- package/src/dev/.next/types/routes.d.ts +55 -0
- package/src/dev/app/Sidebar.js +149 -0
- package/src/dev/app/[...slug]/loading.js +16 -0
- package/src/dev/app/[...slug]/page.js +43 -0
- package/src/dev/app/globals.css +167 -0
- package/src/dev/app/layout.js +73 -0
- package/src/dev/app/page.js +19 -0
- package/src/dev/lib/docs.js +337 -0
- package/src/dev/middleware.js +7 -0
- package/src/dev/next.config.mjs +22 -0
- package/src/index.js +12 -0
- package/src/prompts/index.js +352 -0
- package/src/utils/claude.js +15 -0
- package/src/utils/eyes.js +365 -0
- package/src/utils/git.js +143 -0
- package/src/utils/lint.js +99 -0
- package/src/utils/reporter.js +319 -0
- package/src/utils/setup-templates.js +323 -0
- package/src/utils/styles.js +50 -0
- package/src/utils/tamagotchi.js +1139 -0
- package/src/utils/tips.js +90 -0
- package/src/validators/components.js +230 -0
- package/src/validators/content.js +53 -0
- package/src/validators/duplicates.js +45 -0
- package/src/validators/frontmatter.js +247 -0
- package/src/validators/links.js +68 -0
- package/src/validators/nesting.js +50 -0
- package/src/validators/numbering.js +136 -0
- package/src/validators/oas-reference.js +126 -0
- package/src/validators/oas-schema.js +106 -0
- package/src/validators/ordering.js +121 -0
- package/src/validators/recipes.js +143 -0
- package/vendor/TOOLS.md +19 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as styles from './styles.js';
|
|
2
|
+
import { loadPet } from './tamagotchi.js';
|
|
3
|
+
|
|
4
|
+
const tips = [
|
|
5
|
+
{
|
|
6
|
+
weight: 1,
|
|
7
|
+
commands: ['lint', 'oas:validate'],
|
|
8
|
+
condition: (ctx) => !ctx.isRunningInClaude && ctx.workflowOutdated,
|
|
9
|
+
render() {
|
|
10
|
+
console.log(` 💡 ${styles.bold('Tip:')} Your GitHub Action is out of date!`);
|
|
11
|
+
console.log(` ${styles.dim('⎿')} ${styles.orange(`${styles.binName()} setup:github`)}`);
|
|
12
|
+
console.log();
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
weight: 1,
|
|
17
|
+
commands: ['lint', 'oas:validate'],
|
|
18
|
+
condition: (ctx) => !ctx.isRunningInClaude && ctx.detectedPlatform && !ctx.hasCiWorkflow,
|
|
19
|
+
render(ctx) {
|
|
20
|
+
const label = ctx?.detectedPlatformLabel || 'CI';
|
|
21
|
+
console.log(` 💡 ${styles.bold('Tip:')} Set up ${label} to lint your docs on every PR!`);
|
|
22
|
+
console.log(` ${styles.dim('⎿')} ${styles.orange(`${styles.binName()} setup`)}`);
|
|
23
|
+
console.log();
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
weight: 1,
|
|
28
|
+
commands: ['lint', 'oas:validate'],
|
|
29
|
+
condition: (ctx) => !ctx.isRunningInClaude && ctx.hasClaude,
|
|
30
|
+
render() {
|
|
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"`)}`);
|
|
33
|
+
console.log();
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
weight: 1,
|
|
38
|
+
commands: ['lint', 'oas:validate'],
|
|
39
|
+
condition: (ctx) => !ctx.isRunningInClaude && !ctx.hasClaude,
|
|
40
|
+
render() {
|
|
41
|
+
console.log(` 💡 ${styles.bold('Tip:')} Install Claude to automatically fix these issues!`);
|
|
42
|
+
console.log(` ${styles.dim('⎿')} ${styles.dim('https://claude.ai/download')}`);
|
|
43
|
+
console.log();
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
weight: 0.1,
|
|
48
|
+
condition: () => !loadPet(),
|
|
49
|
+
render() {
|
|
50
|
+
console.log(` 💡 ${styles.bold('Tip:')} Want a little friend? Run ${styles.orange(`${styles.binName()} play`)} to hatch one!`);
|
|
51
|
+
console.log();
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
weight: 0.3,
|
|
56
|
+
condition: () => !!loadPet(),
|
|
57
|
+
render() {
|
|
58
|
+
const pet = loadPet();
|
|
59
|
+
console.log(` 💡 ${styles.bold('Tip:')} ${pet.name} misses you! Run ${styles.orange(`${styles.binName()} play`)} to check in.`);
|
|
60
|
+
console.log();
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get a random tip, optionally filtered by command name.
|
|
67
|
+
* @param {object} context
|
|
68
|
+
* @param {string} [context.command] - current command name (e.g. 'lint', 'help')
|
|
69
|
+
*/
|
|
70
|
+
export function getRandomTip(context) {
|
|
71
|
+
const cmd = context.command;
|
|
72
|
+
const eligible = tips.filter((t) => {
|
|
73
|
+
// Filter by command if tip has a commands array
|
|
74
|
+
if (t.commands && cmd && !t.commands.includes(cmd)) return false;
|
|
75
|
+
// Tips without commands array show everywhere
|
|
76
|
+
return t.condition(context);
|
|
77
|
+
});
|
|
78
|
+
if (eligible.length === 0) return null;
|
|
79
|
+
|
|
80
|
+
// Weighted random selection
|
|
81
|
+
const totalWeight = eligible.reduce((sum, t) => sum + (t.weight || 1), 0);
|
|
82
|
+
let rand = Math.random() * totalWeight;
|
|
83
|
+
let chosen = eligible[eligible.length - 1];
|
|
84
|
+
for (const tip of eligible) {
|
|
85
|
+
rand -= tip.weight || 1;
|
|
86
|
+
if (rand <= 0) { chosen = tip; break; }
|
|
87
|
+
}
|
|
88
|
+
// Bind context so render() can read it without a second argument.
|
|
89
|
+
return { render: () => chosen.render(context) };
|
|
90
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import markdown from "@readme/markdown";
|
|
5
|
+
|
|
6
|
+
const { mdxishTags } = markdown;
|
|
7
|
+
|
|
8
|
+
export const name = "components";
|
|
9
|
+
|
|
10
|
+
// Built-in components provided by @readme/markdown.
|
|
11
|
+
const BUILTIN_COMPONENTS = new Set([
|
|
12
|
+
// Documented in ReadMe user docs
|
|
13
|
+
"Accordion",
|
|
14
|
+
"Tabs",
|
|
15
|
+
"Tab",
|
|
16
|
+
"Cards",
|
|
17
|
+
"Card",
|
|
18
|
+
"Columns",
|
|
19
|
+
"Column",
|
|
20
|
+
"Image",
|
|
21
|
+
// 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",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// Directories that can contain MDXish content with component references.
|
|
41
|
+
const CONTENT_DIRS = new Set(["docs", "reference", "custom_pages", "recipes"]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract component tag names from MDXish content.
|
|
45
|
+
* Parses via @readme/markdown's mdxishTags so code blocks, inline code, and
|
|
46
|
+
* HTML comments are respected as markdown (tags inside them aren't components).
|
|
47
|
+
*/
|
|
48
|
+
function extractComponentTags(content) {
|
|
49
|
+
// Strip frontmatter — mdxishTags parses raw mdxish, so frontmatter values
|
|
50
|
+
// 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
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
return new Set(mdxishTags(body));
|
|
60
|
+
} catch {
|
|
61
|
+
return new Set();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract exported component names from .mdx file body.
|
|
67
|
+
* Matches: export const Foo, export function Foo, export default function Foo
|
|
68
|
+
*/
|
|
69
|
+
function extractExportedNames(body) {
|
|
70
|
+
const names = new Set();
|
|
71
|
+
const pattern = /export\s+(?:const|function|class)\s+([A-Z][a-zA-Z0-9]*)/g;
|
|
72
|
+
let match;
|
|
73
|
+
while ((match = pattern.exec(body)) !== null) {
|
|
74
|
+
names.add(match[1]);
|
|
75
|
+
}
|
|
76
|
+
return names;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Collect available custom block component names from custom_blocks/.
|
|
81
|
+
* .mdx files: uses the actual exported component names (e.g., export const Foo → <Foo />).
|
|
82
|
+
* .md files: uses the filename slug (e.g., Greeting.md → <Greeting />).
|
|
83
|
+
*/
|
|
84
|
+
function collectCustomBlockNames(gitRoot) {
|
|
85
|
+
const blocksDir = path.join(gitRoot, "custom_blocks");
|
|
86
|
+
if (!fs.existsSync(blocksDir)) return new Set();
|
|
87
|
+
|
|
88
|
+
const names = new Set();
|
|
89
|
+
const entries = fs.readdirSync(blocksDir);
|
|
90
|
+
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (!entry.endsWith(".md") && !entry.endsWith(".mdx")) continue;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const content = fs.readFileSync(path.join(blocksDir, entry), "utf-8");
|
|
96
|
+
|
|
97
|
+
if (entry.endsWith(".mdx")) {
|
|
98
|
+
// .mdx: component names come from exports.
|
|
99
|
+
const { content: body } = matter(content);
|
|
100
|
+
for (const n of extractExportedNames(body)) {
|
|
101
|
+
names.add(n);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
// .md snippet: component name is the PascalCase filename.
|
|
105
|
+
const slug = entry.replace(/\.md$/, "");
|
|
106
|
+
names.add(slug);
|
|
107
|
+
// Also register the PascalCase form so references resolve.
|
|
108
|
+
const pascalCase = slug
|
|
109
|
+
.split(/[-_]/)
|
|
110
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
111
|
+
.join("");
|
|
112
|
+
names.add(pascalCase);
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Skip unparseable files.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return names;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Per-file: validate custom_block files.
|
|
124
|
+
* - .mdx: must have an exported component and a usage example
|
|
125
|
+
* - .md: filename must be capitalized (used as component name via <Name />)
|
|
126
|
+
*/
|
|
127
|
+
export function validate({ content, relativePath }) {
|
|
128
|
+
if (!relativePath.startsWith("custom_blocks/")) return null;
|
|
129
|
+
|
|
130
|
+
const filename = path.basename(relativePath);
|
|
131
|
+
const isMdx = relativePath.endsWith(".mdx");
|
|
132
|
+
const isMd = relativePath.endsWith(".md");
|
|
133
|
+
|
|
134
|
+
if (!isMdx && !isMd) return null;
|
|
135
|
+
|
|
136
|
+
const results = [];
|
|
137
|
+
|
|
138
|
+
// .md snippet files: filename must be PascalCase (it becomes the component tag).
|
|
139
|
+
if (isMd) {
|
|
140
|
+
const slug = filename.replace(/\.md$/, "");
|
|
141
|
+
const pascalCase = slug
|
|
142
|
+
.split(/[-_]/)
|
|
143
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
144
|
+
.join("");
|
|
145
|
+
if (slug !== pascalCase) {
|
|
146
|
+
results.push({
|
|
147
|
+
file: relativePath,
|
|
148
|
+
rule: name,
|
|
149
|
+
severity: "warning",
|
|
150
|
+
message: `Bad filename: should be PascalCase — rename to "${pascalCase}.md" so it can be used as <${pascalCase} />`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return results.length > 0 ? results : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// .mdx component files: validate export and usage example.
|
|
157
|
+
let data;
|
|
158
|
+
let body;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
({ data, content: body } = matter(content));
|
|
162
|
+
} catch {
|
|
163
|
+
return null; // frontmatter validator handles parse errors.
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const componentName = data.name;
|
|
167
|
+
if (!componentName) return null; // frontmatter validator handles missing name.
|
|
168
|
+
|
|
169
|
+
// Check for an exported component.
|
|
170
|
+
// Matches: export const Name, export function Name, export default
|
|
171
|
+
const hasExport = /export\s+(const|function|default)\s+/.test(body);
|
|
172
|
+
if (!hasExport) {
|
|
173
|
+
results.push({
|
|
174
|
+
file: relativePath,
|
|
175
|
+
rule: name,
|
|
176
|
+
message: `Missing export: no exported component found in "${componentName}"`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check for a usage example — a component tag at the start of a line (not indented
|
|
181
|
+
// inside an export). The example demonstrates the component for the slash menu preview.
|
|
182
|
+
const hasExample = /^<[A-Z][a-zA-Z0-9]*/m.test(body);
|
|
183
|
+
if (!hasExample) {
|
|
184
|
+
results.push({
|
|
185
|
+
file: relativePath,
|
|
186
|
+
rule: name,
|
|
187
|
+
severity: "warning",
|
|
188
|
+
message: `Missing example: no usage example found for "${componentName}"`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return results.length > 0 ? results : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Cross-file: check that all component references in content files resolve.
|
|
197
|
+
*/
|
|
198
|
+
export function validateAll(files, gitRoot) {
|
|
199
|
+
const results = [];
|
|
200
|
+
const customBlocks = collectCustomBlockNames(gitRoot);
|
|
201
|
+
const available = new Set([...BUILTIN_COMPONENTS, ...customBlocks]);
|
|
202
|
+
|
|
203
|
+
for (const relPath of files) {
|
|
204
|
+
const dir = relPath.split("/")[0];
|
|
205
|
+
if (!CONTENT_DIRS.has(dir)) continue;
|
|
206
|
+
if (!relPath.endsWith(".md")) continue;
|
|
207
|
+
|
|
208
|
+
const filePath = path.join(gitRoot, relPath);
|
|
209
|
+
let content;
|
|
210
|
+
try {
|
|
211
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
212
|
+
} catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const usedComponents = extractComponentTags(content);
|
|
217
|
+
|
|
218
|
+
for (const comp of usedComponents) {
|
|
219
|
+
if (!available.has(comp)) {
|
|
220
|
+
results.push({
|
|
221
|
+
file: relPath,
|
|
222
|
+
rule: name,
|
|
223
|
+
message: `Unknown component: <${comp}> is not a built-in or custom block`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return results;
|
|
230
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
|
|
5
|
+
export const name = 'content';
|
|
6
|
+
|
|
7
|
+
export function validate({ filePath, content, relativePath }) {
|
|
8
|
+
const dir = relativePath.split('/')[0];
|
|
9
|
+
if (!['docs', 'reference', 'custom_pages'].includes(dir)) return null;
|
|
10
|
+
if (!relativePath.endsWith('.md')) return null;
|
|
11
|
+
|
|
12
|
+
let data;
|
|
13
|
+
let body;
|
|
14
|
+
try {
|
|
15
|
+
({ data, content: body } = matter(content));
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (data.hidden || data.deprecated) return null;
|
|
21
|
+
if (data.api) return null;
|
|
22
|
+
const trimmed = body.trim();
|
|
23
|
+
|
|
24
|
+
if (data.link?.url) {
|
|
25
|
+
if (trimmed.length > 0) {
|
|
26
|
+
return {
|
|
27
|
+
file: relativePath,
|
|
28
|
+
rule: name,
|
|
29
|
+
severity: 'warning',
|
|
30
|
+
message: 'Redirect page has body content: pages with a link URL won\'t display their content',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (trimmed.length === 0) {
|
|
36
|
+
// index.md files that act as parent pages (have child .md siblings) can be empty.
|
|
37
|
+
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
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
file: relativePath,
|
|
46
|
+
rule: name,
|
|
47
|
+
severity: 'warning',
|
|
48
|
+
message: 'Empty page: non-hidden page has no content',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export const name = 'duplicates';
|
|
4
|
+
|
|
5
|
+
// Only docs and reference require unique slugs.
|
|
6
|
+
const CHECKED_DIRS = ['docs', 'reference'];
|
|
7
|
+
|
|
8
|
+
export function validateAll(files) {
|
|
9
|
+
const results = [];
|
|
10
|
+
|
|
11
|
+
// Group files by slug.
|
|
12
|
+
const slugMap = new Map();
|
|
13
|
+
for (const relPath of files) {
|
|
14
|
+
const topDir = relPath.split('/')[0];
|
|
15
|
+
if (!CHECKED_DIRS.includes(topDir)) continue;
|
|
16
|
+
|
|
17
|
+
const filename = path.basename(relPath);
|
|
18
|
+
if (filename === 'index.md' || filename === 'index.mdx') continue;
|
|
19
|
+
|
|
20
|
+
const slug = filename.replace(/\.(md|mdx)$/, '');
|
|
21
|
+
if (!slugMap.has(slug)) slugMap.set(slug, []);
|
|
22
|
+
slugMap.get(slug).push(relPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const [slug, paths] of slugMap) {
|
|
26
|
+
if (paths.length < 2) continue;
|
|
27
|
+
|
|
28
|
+
// TODO: figure out why ReadMeConfig causes duplicate slugs and handle properly.
|
|
29
|
+
// For now, skip any duplicate set that involves a ReadMeConfig path.
|
|
30
|
+
if (paths.some((p) => p.includes('ReadMeConfig/'))) continue;
|
|
31
|
+
|
|
32
|
+
const others = paths.slice(1);
|
|
33
|
+
for (const relPath of others) {
|
|
34
|
+
const otherLocations = paths.filter((p) => p !== relPath).map((p) => path.dirname(p)).join(', ');
|
|
35
|
+
results.push({
|
|
36
|
+
file: relPath,
|
|
37
|
+
rule: name,
|
|
38
|
+
severity: 'error',
|
|
39
|
+
message: `Duplicate slug: "${slug}" also exists in ${otherLocations}`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return results.length > 0 ? results : null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import Ajv from 'ajv';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
export const name = 'frontmatter';
|
|
10
|
+
|
|
11
|
+
function getNestedValue(obj, instancePath) {
|
|
12
|
+
const keys = instancePath.split('/').filter(Boolean);
|
|
13
|
+
let current = obj;
|
|
14
|
+
for (const key of keys) {
|
|
15
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
16
|
+
current = current[key];
|
|
17
|
+
}
|
|
18
|
+
return current;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Levenshtein distance between two strings.
|
|
22
|
+
function levenshtein(a, b) {
|
|
23
|
+
const m = a.length;
|
|
24
|
+
const n = b.length;
|
|
25
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
26
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
27
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
28
|
+
for (let i = 1; i <= m; i++) {
|
|
29
|
+
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]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return dp[m][n];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Returns similarity ratio between 0 and 1.
|
|
39
|
+
function similarity(a, b) {
|
|
40
|
+
const maxLen = Math.max(a.length, b.length);
|
|
41
|
+
if (maxLen === 0) return 1;
|
|
42
|
+
return 1 - levenshtein(a, b) / maxLen;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const TYPO_THRESHOLD = 0.7;
|
|
46
|
+
|
|
47
|
+
// Recursively collect all known property names from a schema, following $ref and allOf.
|
|
48
|
+
function collectPropertyNames(schema, defs) {
|
|
49
|
+
const props = new Set();
|
|
50
|
+
|
|
51
|
+
if (schema.properties) {
|
|
52
|
+
for (const k of Object.keys(schema.properties)) props.add(k);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (schema.$ref?.startsWith('#/defs/')) {
|
|
56
|
+
const refSchema = defs[schema.$ref.slice('#/defs/'.length)];
|
|
57
|
+
if (refSchema) {
|
|
58
|
+
for (const k of collectPropertyNames(refSchema, defs)) props.add(k);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (schema.allOf) {
|
|
63
|
+
for (const sub of schema.allOf) {
|
|
64
|
+
for (const k of collectPropertyNames(sub, defs)) props.add(k);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return props;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Map directory names to their index in the schema's oneOf array.
|
|
72
|
+
const DIR_SCHEMA_INDEX = {
|
|
73
|
+
docs: 0,
|
|
74
|
+
custom_pages: 1,
|
|
75
|
+
reference: 2,
|
|
76
|
+
recipes: 3,
|
|
77
|
+
custom_blocks: 4,
|
|
78
|
+
};
|
|
79
|
+
|
|
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
|
+
|
|
84
|
+
const ajv = new Ajv({ allErrors: true, strict: false, logger: false, verbose: true });
|
|
85
|
+
|
|
86
|
+
const validators = {};
|
|
87
|
+
const knownProperties = {};
|
|
88
|
+
for (const [dir, index] of Object.entries(DIR_SCHEMA_INDEX)) {
|
|
89
|
+
const subSchema = { defs: fullSchema.defs, ...fullSchema.oneOf[index] };
|
|
90
|
+
validators[dir] = ajv.compile(subSchema);
|
|
91
|
+
knownProperties[dir] = collectPropertyNames(subSchema, fullSchema.defs);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function validate({ content, filePath, relativePath, fix }) {
|
|
95
|
+
// Step 1: Parse the frontmatter YAML.
|
|
96
|
+
let data;
|
|
97
|
+
try {
|
|
98
|
+
({ data } = matter(content));
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return {
|
|
101
|
+
file: relativePath,
|
|
102
|
+
rule: name,
|
|
103
|
+
message: `Invalid frontmatter: ${e.reason || e.message || 'unknown error'}`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const results = [];
|
|
108
|
+
const dir = relativePath.split('/')[0];
|
|
109
|
+
|
|
110
|
+
// Step 2: Validate the parsed frontmatter against the schema.
|
|
111
|
+
const validateFn = validators[dir];
|
|
112
|
+
if (validateFn) {
|
|
113
|
+
const valid = validateFn(data);
|
|
114
|
+
if (!valid) {
|
|
115
|
+
for (const err of validateFn.errors) {
|
|
116
|
+
if (err.keyword === 'not' && err.schema?.properties) {
|
|
117
|
+
const target = err.instancePath ? getNestedValue(data, err.instancePath) : data;
|
|
118
|
+
if (!target || !Object.keys(err.schema.properties).some((k) => k in target)) continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let msg;
|
|
122
|
+
if (err.keyword === 'not' && err.schema?.description) {
|
|
123
|
+
msg = err.schema.description;
|
|
124
|
+
} else {
|
|
125
|
+
// Convert JSON pointer paths (/foo/bar) to dot notation (foo.bar).
|
|
126
|
+
const loc = err.instancePath ? err.instancePath.slice(1).replace(/\//g, '.') : '';
|
|
127
|
+
msg = loc ? `"${loc}" ${err.message}` : err.message;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
results.push({
|
|
131
|
+
file: relativePath,
|
|
132
|
+
rule: name,
|
|
133
|
+
severity: 'error',
|
|
134
|
+
message: `Invalid frontmatter: ${msg}`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Step 3: Warn about unknown properties (allow x- prefix for custom metadata).
|
|
141
|
+
const allowed = knownProperties[dir];
|
|
142
|
+
const unknownKeys = [];
|
|
143
|
+
if (allowed && data && typeof data === 'object') {
|
|
144
|
+
for (const key of Object.keys(data)) {
|
|
145
|
+
if (!allowed.has(key) && !key.startsWith('x-')) {
|
|
146
|
+
unknownKeys.push(key);
|
|
147
|
+
results.push({
|
|
148
|
+
file: relativePath,
|
|
149
|
+
rule: name,
|
|
150
|
+
severity: 'warning',
|
|
151
|
+
fixable: true,
|
|
152
|
+
_key: key,
|
|
153
|
+
message: `Unknown property: "${key}" is not a known frontmatter field (use x-${key} for custom metadata)`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Step 4: Detect typos by comparing unknown properties against known ones.
|
|
160
|
+
const missingRequired = results.filter(
|
|
161
|
+
(r) => r.severity === 'error' && r.message.includes('must have required property'),
|
|
162
|
+
);
|
|
163
|
+
const unknowns = results.filter((r) => r._key);
|
|
164
|
+
|
|
165
|
+
const consumed = new Set();
|
|
166
|
+
|
|
167
|
+
// First pass: match unknowns to missing required properties (these become errors).
|
|
168
|
+
for (const err of missingRequired) {
|
|
169
|
+
const match = err.message.match(/must have required property '(\w+)'/);
|
|
170
|
+
if (!match) continue;
|
|
171
|
+
const required = match[1];
|
|
172
|
+
|
|
173
|
+
let bestMatch = null;
|
|
174
|
+
let bestScore = 0;
|
|
175
|
+
for (const warn of unknowns) {
|
|
176
|
+
if (consumed.has(warn)) continue;
|
|
177
|
+
const score = similarity(required, warn._key);
|
|
178
|
+
if (score >= TYPO_THRESHOLD && score > bestScore) {
|
|
179
|
+
bestMatch = warn;
|
|
180
|
+
bestScore = score;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (bestMatch) {
|
|
185
|
+
consumed.add(bestMatch);
|
|
186
|
+
err.message = `Invalid frontmatter: "${bestMatch._key}" is not a valid property — did you mean "${required}"?`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Second pass: match remaining unknowns to any known property (these stay as warnings).
|
|
191
|
+
const suggestedTypos = new Set();
|
|
192
|
+
if (allowed) {
|
|
193
|
+
for (const warn of unknowns) {
|
|
194
|
+
if (consumed.has(warn)) continue;
|
|
195
|
+
|
|
196
|
+
let bestProp = null;
|
|
197
|
+
let bestScore = 0;
|
|
198
|
+
for (const known of allowed) {
|
|
199
|
+
const score = similarity(warn._key, known);
|
|
200
|
+
if (score >= TYPO_THRESHOLD && score > bestScore) {
|
|
201
|
+
bestProp = known;
|
|
202
|
+
bestScore = score;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (bestProp) {
|
|
207
|
+
suggestedTypos.add(warn._key);
|
|
208
|
+
warn.message = `Unknown property: "${warn._key}" — did you mean "${bestProp}"?`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Step 5: Apply fixes — rename unknown (non-typo) properties to x- prefixed.
|
|
214
|
+
if (fix && filePath) {
|
|
215
|
+
const typoKeys = new Set([...consumed].map((r) => r._key));
|
|
216
|
+
const keysToFix = unknownKeys.filter((key) => !typoKeys.has(key) && !suggestedTypos.has(key));
|
|
217
|
+
|
|
218
|
+
if (keysToFix.length > 0) {
|
|
219
|
+
let fileContent = fs.readFileSync(filePath, 'utf-8');
|
|
220
|
+
|
|
221
|
+
// Only replace within the frontmatter block (between the --- delimiters).
|
|
222
|
+
const fmMatch = fileContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
223
|
+
if (fmMatch) {
|
|
224
|
+
let frontmatter = fmMatch[1];
|
|
225
|
+
const keysToFixSet = new Set(keysToFix);
|
|
226
|
+
for (const key of keysToFix) {
|
|
227
|
+
const regex = new RegExp(`^(${key}:)`, 'gm');
|
|
228
|
+
frontmatter = frontmatter.replace(regex, `x-${key}:`);
|
|
229
|
+
}
|
|
230
|
+
fileContent = fileContent.replace(fmMatch[1], frontmatter);
|
|
231
|
+
}
|
|
232
|
+
fs.writeFileSync(filePath, fileContent, 'utf-8');
|
|
233
|
+
|
|
234
|
+
// Mark the corresponding warnings as fixed.
|
|
235
|
+
const keysToFixSet = new Set(keysToFix);
|
|
236
|
+
for (const r of results) {
|
|
237
|
+
if (r._key && keysToFixSet.has(r._key)) {
|
|
238
|
+
r.message += ' (fixed)';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Remove consumed warnings (they've been merged into the error).
|
|
245
|
+
const final = results.filter((r) => !consumed.has(r));
|
|
246
|
+
return final.length > 0 ? final : null;
|
|
247
|
+
}
|