@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.
Files changed (144) hide show
  1. package/README.md +55 -0
  2. package/bin/readme.js +8 -0
  3. package/package.json +58 -0
  4. package/src/bootstrap.js +97 -0
  5. package/src/cli.js +189 -0
  6. package/src/commands/dev.js +119 -0
  7. package/src/commands/eyes.js +37 -0
  8. package/src/commands/import.js +2565 -0
  9. package/src/commands/lint.js +70 -0
  10. package/src/commands/oas-sync.js +364 -0
  11. package/src/commands/oas-validate.js +208 -0
  12. package/src/commands/play.js +17 -0
  13. package/src/commands/pretty.js +133 -0
  14. package/src/commands/setup.js +256 -0
  15. package/src/commands/versions.js +81 -0
  16. package/src/dev/.next/app-build-manifest.json +20 -0
  17. package/src/dev/.next/build-manifest.json +31 -0
  18. package/src/dev/.next/cache/.rscinfo +1 -0
  19. package/src/dev/.next/cache/next-devtools-config.json +1 -0
  20. package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
  21. package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
  22. package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
  23. package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
  24. package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
  25. package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
  26. package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
  27. package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
  28. package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
  29. package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
  30. package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
  31. package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
  32. package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
  33. package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
  34. package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
  35. package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  36. package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
  37. package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
  38. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
  39. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
  40. package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
  41. package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
  42. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
  43. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
  44. package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
  45. package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
  46. package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
  47. package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
  48. package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
  49. package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
  50. package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
  51. package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
  52. package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
  53. package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
  54. package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
  55. package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
  56. package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
  57. package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
  58. package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
  59. package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
  60. package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
  61. package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
  62. package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
  63. package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
  64. package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
  65. package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
  66. package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  67. package/src/dev/.next/package.json +1 -0
  68. package/src/dev/.next/prerender-manifest.json +11 -0
  69. package/src/dev/.next/react-loadable-manifest.json +1 -0
  70. package/src/dev/.next/routes-manifest.json +1 -0
  71. package/src/dev/.next/server/app/[...slug]/page.js +360 -0
  72. package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +1 -0
  73. package/src/dev/.next/server/app/page.js +349 -0
  74. package/src/dev/.next/server/app/page_client-reference-manifest.js +1 -0
  75. package/src/dev/.next/server/app-paths-manifest.json +3 -0
  76. package/src/dev/.next/server/edge-runtime-webpack.js +1151 -0
  77. package/src/dev/.next/server/interception-route-rewrite-manifest.js +1 -0
  78. package/src/dev/.next/server/middleware-build-manifest.js +33 -0
  79. package/src/dev/.next/server/middleware-manifest.json +32 -0
  80. package/src/dev/.next/server/middleware-react-loadable-manifest.js +1 -0
  81. package/src/dev/.next/server/middleware.js +1113 -0
  82. package/src/dev/.next/server/next-font-manifest.js +1 -0
  83. package/src/dev/.next/server/next-font-manifest.json +1 -0
  84. package/src/dev/.next/server/pages-manifest.json +5 -0
  85. package/src/dev/.next/server/server-reference-manifest.js +1 -0
  86. package/src/dev/.next/server/server-reference-manifest.json +5 -0
  87. package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +1 -0
  88. package/src/dev/.next/server/vendor-chunks/@readme.js +25 -0
  89. package/src/dev/.next/server/vendor-chunks/@swc.js +55 -0
  90. package/src/dev/.next/server/vendor-chunks/next.js +3659 -0
  91. package/src/dev/.next/server/webpack-runtime.js +209 -0
  92. package/src/dev/.next/static/chunks/app/[...slug]/loading.js +28 -0
  93. package/src/dev/.next/static/chunks/app/[...slug]/page.js +28 -0
  94. package/src/dev/.next/static/chunks/app/layout.js +171 -0
  95. package/src/dev/.next/static/chunks/app/page.js +28 -0
  96. package/src/dev/.next/static/chunks/app-pages-internals.js +182 -0
  97. package/src/dev/.next/static/chunks/main-app.js +1882 -0
  98. package/src/dev/.next/static/chunks/polyfills.js +1 -0
  99. package/src/dev/.next/static/chunks/webpack.js +1393 -0
  100. package/src/dev/.next/static/css/app/layout.css +559 -0
  101. package/src/dev/.next/static/development/_buildManifest.js +1 -0
  102. package/src/dev/.next/static/development/_ssgManifest.js +1 -0
  103. package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +1 -0
  104. package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +1 -0
  105. package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +12 -0
  106. package/src/dev/.next/trace +21 -0
  107. package/src/dev/.next/types/app/[...slug]/page.ts +84 -0
  108. package/src/dev/.next/types/app/layout.ts +84 -0
  109. package/src/dev/.next/types/app/page.ts +84 -0
  110. package/src/dev/.next/types/cache-life.d.ts +141 -0
  111. package/src/dev/.next/types/package.json +1 -0
  112. package/src/dev/.next/types/routes.d.ts +55 -0
  113. package/src/dev/app/Sidebar.js +149 -0
  114. package/src/dev/app/[...slug]/loading.js +16 -0
  115. package/src/dev/app/[...slug]/page.js +43 -0
  116. package/src/dev/app/globals.css +167 -0
  117. package/src/dev/app/layout.js +73 -0
  118. package/src/dev/app/page.js +19 -0
  119. package/src/dev/lib/docs.js +337 -0
  120. package/src/dev/middleware.js +7 -0
  121. package/src/dev/next.config.mjs +22 -0
  122. package/src/index.js +12 -0
  123. package/src/prompts/index.js +352 -0
  124. package/src/utils/claude.js +15 -0
  125. package/src/utils/eyes.js +365 -0
  126. package/src/utils/git.js +143 -0
  127. package/src/utils/lint.js +99 -0
  128. package/src/utils/reporter.js +319 -0
  129. package/src/utils/setup-templates.js +323 -0
  130. package/src/utils/styles.js +50 -0
  131. package/src/utils/tamagotchi.js +1139 -0
  132. package/src/utils/tips.js +90 -0
  133. package/src/validators/components.js +230 -0
  134. package/src/validators/content.js +53 -0
  135. package/src/validators/duplicates.js +45 -0
  136. package/src/validators/frontmatter.js +247 -0
  137. package/src/validators/links.js +68 -0
  138. package/src/validators/nesting.js +50 -0
  139. package/src/validators/numbering.js +136 -0
  140. package/src/validators/oas-reference.js +126 -0
  141. package/src/validators/oas-schema.js +106 -0
  142. package/src/validators/ordering.js +121 -0
  143. package/src/validators/recipes.js +143 -0
  144. package/vendor/TOOLS.md +19 -0
@@ -0,0 +1,68 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import matter from 'gray-matter';
4
+
5
+ export const name = 'links';
6
+
7
+ const CHECKED_DIRS = ['docs', 'reference', 'custom_pages', 'recipes'];
8
+
9
+ // Match doc:slug and ref:slug links, e.g. [text](doc:my-page) or [text](ref:my-op#anchor)
10
+ const DOC_LINK_RE = /\((?:doc|ref):([a-zA-Z0-9_-]+)(?:#[^)]*)?\)/g;
11
+
12
+ function collectSlugs(gitRoot) {
13
+ const slugs = new Set();
14
+
15
+ for (const dir of CHECKED_DIRS) {
16
+ const dirPath = path.join(gitRoot, dir);
17
+ if (!fs.existsSync(dirPath)) continue;
18
+
19
+ const entries = fs.readdirSync(dirPath, { recursive: true });
20
+ for (const entry of entries) {
21
+ if (!/\.(md|mdx)$/.test(entry)) continue;
22
+ const filename = path.basename(entry);
23
+ if (filename === 'index.md' || filename === 'index.mdx') {
24
+ // index.md represents the parent directory, e.g. plans-and-pricing/index.md -> slug "plans-and-pricing"
25
+ const parentDir = path.basename(path.dirname(entry));
26
+ if (parentDir && parentDir !== '.') slugs.add(parentDir);
27
+ } else {
28
+ slugs.add(filename.replace(/\.(md|mdx)$/, ''));
29
+ }
30
+ }
31
+ }
32
+
33
+ return slugs;
34
+ }
35
+
36
+ export function validateAll(files, gitRoot) {
37
+ const validSlugs = collectSlugs(gitRoot);
38
+ const results = [];
39
+
40
+ for (const relPath of files) {
41
+ const topDir = relPath.split('/')[0];
42
+ if (!CHECKED_DIRS.includes(topDir)) continue;
43
+ if (!/\.(md|mdx)$/.test(relPath)) continue;
44
+
45
+ let body;
46
+ try {
47
+ const raw = fs.readFileSync(path.join(gitRoot, relPath), 'utf-8');
48
+ ({ content: body } = matter(raw));
49
+ } catch {
50
+ continue;
51
+ }
52
+
53
+ for (const match of body.matchAll(DOC_LINK_RE)) {
54
+ const prefix = match[0].slice(1, match[0].indexOf(':'));
55
+ const slug = match[1];
56
+ if (!validSlugs.has(slug)) {
57
+ results.push({
58
+ file: relPath,
59
+ rule: name,
60
+ severity: 'error',
61
+ message: `Broken link: "${prefix}:${slug}" does not match any page`,
62
+ });
63
+ }
64
+ }
65
+ }
66
+
67
+ return results.length > 0 ? results : null;
68
+ }
@@ -0,0 +1,50 @@
1
+ import path from 'node:path';
2
+
3
+ export const name = 'nesting';
4
+
5
+ // Max folder nesting per top-level content dir. Anything deeper still syncs
6
+ // but won't appear in the sidebar.
7
+ const MAX_DEPTH = {
8
+ docs: 3,
9
+ reference: 3,
10
+ recipes: 0,
11
+ custom_pages: 0,
12
+ custom_blocks: 0,
13
+ };
14
+
15
+ export function validateAll(files) {
16
+ const results = [];
17
+ const seen = new Set();
18
+
19
+ for (const relPath of files) {
20
+ const parts = relPath.split('/');
21
+ const topDir = parts[0];
22
+ const max = MAX_DEPTH[topDir];
23
+ if (max === undefined) continue;
24
+
25
+ // Depth = number of folders between the top dir and the file.
26
+ // docs/a/b/c/page.md → parts.length 5 → depth 3
27
+ // docs/a/b/c/d/page.md → parts.length 6 → depth 4
28
+ const depth = parts.length - 2;
29
+ if (depth <= max) continue;
30
+
31
+ // Warn once per offending folder to avoid spamming every file inside it.
32
+ const folderPath = parts.slice(0, -1).join('/');
33
+ if (seen.has(folderPath)) continue;
34
+ seen.add(folderPath);
35
+
36
+ const message =
37
+ max === 0
38
+ ? `Folders not supported: "${folderPath}" — "${topDir}" files must live in the root directory`
39
+ : `Too deeply nested: "${folderPath}" is ${depth} levels deep (only ${max} will render in the sidebar)`;
40
+
41
+ results.push({
42
+ file: folderPath,
43
+ rule: name,
44
+ severity: 'warning',
45
+ message,
46
+ });
47
+ }
48
+
49
+ return results.length > 0 ? results : null;
50
+ }
@@ -0,0 +1,136 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import * as styles from '../utils/styles.js';
5
+
6
+ export const name = 'numbering';
7
+
8
+ const SUFFIX_RE = /-(\d+)$/;
9
+
10
+ function prompt(question) {
11
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
12
+ return new Promise((resolve) => {
13
+ rl.question(question, (answer) => {
14
+ rl.close();
15
+ resolve(answer.trim().toLowerCase());
16
+ });
17
+ });
18
+ }
19
+
20
+ function updateOrderYaml(fromPath, toPath) {
21
+ const dir = path.dirname(fromPath);
22
+ const orderFile = path.join(dir, '_order.yaml');
23
+ if (!fs.existsSync(orderFile)) return;
24
+
25
+ const oldSlug = path.basename(fromPath).replace(/\.(md|mdx)$/, '');
26
+ const newSlug = path.basename(toPath).replace(/\.(md|mdx)$/, '');
27
+
28
+ const content = fs.readFileSync(orderFile, 'utf-8');
29
+ const updated = content.replace(
30
+ new RegExp(`^(- )${oldSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'),
31
+ `$1${newSlug}`,
32
+ );
33
+
34
+ if (updated !== content) {
35
+ fs.writeFileSync(orderFile, updated);
36
+ }
37
+ }
38
+
39
+ export async function validateAll(files, gitRoot, { fix } = {}) {
40
+ const results = [];
41
+ const renames = [];
42
+
43
+ // Collect all slugs (filenames without ext) and directory names across the repo.
44
+ const allSlugs = new Set();
45
+ const allDirs = new Set();
46
+ for (const relPath of files) {
47
+ allSlugs.add(path.basename(relPath).replace(/\.(md|mdx)$/, ''));
48
+ const parts = relPath.split('/');
49
+ for (let i = 0; i < parts.length - 1; i++) {
50
+ allDirs.add(parts[i]);
51
+ }
52
+ }
53
+
54
+ // Check files: if slug ends with -N and the base slug doesn't exist anywhere, warn.
55
+ for (const relPath of files) {
56
+ const slug = path.basename(relPath).replace(/\.(md|mdx)$/, '');
57
+ const match = slug.match(SUFFIX_RE);
58
+ if (!match) continue;
59
+
60
+ const baseSlug = slug.slice(0, -match[0].length);
61
+ if (!allSlugs.has(baseSlug)) {
62
+ const ext = path.extname(relPath);
63
+ const from = path.join(gitRoot, relPath);
64
+ const toRel = path.join(path.dirname(relPath), `${baseSlug}${ext}`);
65
+ const to = path.join(gitRoot, toRel);
66
+
67
+ results.push({
68
+ file: relPath,
69
+ rule: name,
70
+ severity: 'warning',
71
+ fixable: true,
72
+ message: `Unnecessary suffix: "${slug}${ext}" should be renamed to "${baseSlug}${ext}"`,
73
+ });
74
+ renames.push({ from, to, label: `${relPath} → ${toRel}` });
75
+ }
76
+ }
77
+
78
+ // Check directories: if a dir name ends with -N and the base dir doesn't exist, warn.
79
+ const warnedDirs = new Set();
80
+ for (const relPath of files) {
81
+ const parts = relPath.split('/');
82
+ for (let i = 0; i < parts.length - 1; i++) {
83
+ const dirName = parts[i];
84
+ if (warnedDirs.has(dirName)) continue;
85
+
86
+ const match = dirName.match(SUFFIX_RE);
87
+ if (!match) continue;
88
+
89
+ const baseName = dirName.slice(0, -match[0].length);
90
+ if (!allDirs.has(baseName)) {
91
+ warnedDirs.add(dirName);
92
+ const dirPath = parts.slice(0, i + 1).join('/');
93
+ const baseDirPath = [...parts.slice(0, i), baseName].join('/');
94
+ const from = path.join(gitRoot, dirPath);
95
+ const to = path.join(gitRoot, baseDirPath);
96
+
97
+ results.push({
98
+ file: dirPath,
99
+ rule: name,
100
+ severity: 'warning',
101
+ fixable: true,
102
+ message: `Unnecessary suffix: "${dirName}" folder should be renamed to "${baseName}"`,
103
+ });
104
+ renames.push({ from, to, label: `${dirPath}/ → ${baseDirPath}/` });
105
+ }
106
+ }
107
+ }
108
+
109
+ // Interactive rename when --fix is passed.
110
+ if (fix && renames.length > 0) {
111
+ console.log();
112
+ console.log(` The following will be renamed:`);
113
+ for (const r of renames) {
114
+ console.log(` ${styles.dim(r.label)}`);
115
+ }
116
+ console.log();
117
+ console.log(` ${styles.warn('Note:')} Renaming changes slugs, which could break existing URLs.`);
118
+ console.log();
119
+
120
+ const answer = await prompt(` Rename ${renames.length} ${renames.length === 1 ? 'path' : 'paths'}? (y/N) `);
121
+
122
+ if (answer === 'y' || answer === 'yes') {
123
+ // Sort longest path first so nested dirs get renamed before parents.
124
+ renames.sort((a, b) => b.from.length - a.from.length);
125
+ for (const r of renames) {
126
+ fs.renameSync(r.from, r.to);
127
+ updateOrderYaml(r.from, r.to);
128
+ }
129
+ for (const r of results) {
130
+ r.message += ' (fixed)';
131
+ }
132
+ }
133
+ }
134
+
135
+ return results.length > 0 ? results : null;
136
+ }
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import matter from 'gray-matter';
4
+ import { findOasFiles, extractOperations, collectExistingPages, syncOas } from '../commands/oas-sync.js';
5
+
6
+ export const name = 'oas-reference';
7
+
8
+ export function validateAll(files, gitRoot, { fix } = {}) {
9
+ const refDir = path.join(gitRoot, 'reference');
10
+ if (!fs.existsSync(refDir)) return [];
11
+
12
+ const oasFiles = findOasFiles(refDir);
13
+ const results = [];
14
+
15
+ // Build a map of OAS filename -> spec operations for quick lookup.
16
+ const oasMap = new Map();
17
+ for (const { filename, spec } of oasFiles) {
18
+ oasMap.set(filename, { spec, ops: extractOperations(spec) });
19
+ }
20
+
21
+ // Collect all reference pages with api frontmatter.
22
+ const refPages = files.filter((f) => f.startsWith('reference/') && f.endsWith('.md'));
23
+
24
+ for (const relPath of refPages) {
25
+ const filePath = path.join(gitRoot, relPath);
26
+ let data;
27
+ try {
28
+ ({ data } = matter(fs.readFileSync(filePath, 'utf-8')));
29
+ } catch {
30
+ continue;
31
+ }
32
+
33
+ if (!data.api || !data.api.file) continue;
34
+
35
+ const oasFilename = data.api.file;
36
+ const operationId = data.api.operationId;
37
+ const oas = oasMap.get(oasFilename);
38
+
39
+ // Check: OAS file doesn't exist.
40
+ if (!oas) {
41
+ results.push({
42
+ file: relPath,
43
+ rule: name,
44
+ message: `OAS file not found: "${oasFilename}" does not exist in reference/`,
45
+ fixable: false,
46
+ });
47
+ continue;
48
+ }
49
+
50
+ if (!operationId) continue;
51
+
52
+ // Check: operationId doesn't exist in the spec.
53
+ if (!oas.ops.has(operationId)) {
54
+ results.push({
55
+ file: relPath,
56
+ rule: name,
57
+ message: `Operation not found: "${operationId}" does not exist in "${oasFilename}"`,
58
+ fixable: true,
59
+ });
60
+ continue;
61
+ }
62
+
63
+ // Skip title/excerpt sync checks for ReadMeConfig (internal ReadMe pages).
64
+ // Check both the spec title and the page's directory path.
65
+ const isReadMeConfig = oas.spec.info?.title === 'ReadMeConfig'
66
+ || relPath.startsWith('reference/ReadMeConfig/');
67
+ if (isReadMeConfig) continue;
68
+
69
+ // Check: title or excerpt out of sync.
70
+ const op = oas.ops.get(operationId);
71
+ const expectedTitle = op.summary || operationId;
72
+ const expectedExcerpt = op.description || null;
73
+
74
+ if (data.title !== expectedTitle) {
75
+ results.push({
76
+ file: relPath,
77
+ rule: name,
78
+ severity: 'warning',
79
+ message: `Out of sync: title is "${data.title}" but spec summary is "${expectedTitle}"`,
80
+ fixable: true,
81
+ });
82
+ }
83
+
84
+ const currentExcerpt = data.excerpt || null;
85
+ if (currentExcerpt !== expectedExcerpt) {
86
+ results.push({
87
+ file: relPath,
88
+ rule: name,
89
+ severity: 'warning',
90
+ message: `Out of sync: excerpt does not match spec description for "${operationId}"`,
91
+ fixable: true,
92
+ });
93
+ }
94
+ }
95
+
96
+ // Check for missing pages: operations in the spec with no corresponding page.
97
+ const existingPages = collectExistingPages(refDir);
98
+ for (const [oasFilename, { ops }] of oasMap) {
99
+ const pagesForOas = existingPages.filter((p) => p.data.api.file === oasFilename);
100
+ const coveredOps = new Set(pagesForOas.map((p) => p.data.api.operationId));
101
+
102
+ for (const [opId] of ops) {
103
+ if (!coveredOps.has(opId)) {
104
+ results.push({
105
+ file: `reference/${oasFilename}`,
106
+ rule: name,
107
+ severity: 'warning',
108
+ message: `Missing page: no reference page found for operation "${opId}"`,
109
+ fixable: true,
110
+ });
111
+ }
112
+ }
113
+ }
114
+
115
+ // Apply fixes by running the full sync.
116
+ if (fix && results.length > 0) {
117
+ const syncResults = syncOas(gitRoot);
118
+ if (syncResults) {
119
+ for (const r of results) {
120
+ if (r.fixable) r.message += ' (fixed)';
121
+ }
122
+ }
123
+ }
124
+
125
+ return results;
126
+ }
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import OASNormalize from 'oas-normalize';
4
+ import { findOasFiles } from '../commands/oas-sync.js';
5
+
6
+ export const name = 'oas-schema';
7
+
8
+ /**
9
+ * Walk a parsed spec and find $ref values that are malformed.
10
+ * Valid internal refs start with "#/"; catches things like "#components/schemas/Foo".
11
+ */
12
+ function findBadRefs(obj, pointer = '') {
13
+ const issues = [];
14
+ if (obj && typeof obj === 'object') {
15
+ if (typeof obj.$ref === 'string') {
16
+ const ref = obj.$ref;
17
+ if (ref.startsWith('#') && !ref.startsWith('#/')) {
18
+ issues.push({ path: pointer, ref });
19
+ }
20
+ }
21
+ for (const [key, value] of Object.entries(obj)) {
22
+ if (key === '$ref') continue;
23
+ issues.push(...findBadRefs(value, `${pointer}/${key}`));
24
+ }
25
+ }
26
+ return issues;
27
+ }
28
+
29
+ export async function validateAll(files, gitRoot) {
30
+ const refDir = path.join(gitRoot, 'reference');
31
+ if (!fs.existsSync(refDir)) return [];
32
+
33
+ const oasFiles = findOasFiles(refDir);
34
+ const results = [];
35
+
36
+ for (const { filename, spec } of oasFiles) {
37
+ const filePath = path.join(refDir, filename);
38
+ const raw = fs.readFileSync(filePath, 'utf-8');
39
+
40
+ try {
41
+ const normalizer = new OASNormalize(raw, { enablePaths: false });
42
+ const result = await normalizer.validate({
43
+ shouldThrowIfInvalid: false,
44
+ parser: {
45
+ validate: {
46
+ rules: {
47
+ openapi: {
48
+ 'array-without-items': 'warning',
49
+ 'duplicate-non-request-body-parameters': 'warning',
50
+ 'duplicate-operation-id': 'warning',
51
+ 'non-optional-path-parameters': 'warning',
52
+ 'path-parameters-not-in-parameters': 'warning',
53
+ 'path-parameters-not-in-path': 'warning',
54
+ },
55
+ swagger: {
56
+ 'array-without-items': 'warning',
57
+ 'duplicate-non-request-body-parameters': 'warning',
58
+ 'duplicate-operation-id': 'warning',
59
+ 'non-optional-path-parameters': 'warning',
60
+ 'path-parameters-not-in-parameters': 'warning',
61
+ 'path-parameters-not-in-path': 'warning',
62
+ 'unknown-required-schema-property': 'warning',
63
+ },
64
+ },
65
+ },
66
+ },
67
+ });
68
+
69
+ if (!result.valid) {
70
+ for (const err of result.errors) {
71
+ results.push({
72
+ file: `reference/${filename}`,
73
+ rule: name,
74
+ message: err.message,
75
+ });
76
+ }
77
+ }
78
+
79
+ for (const warn of result.warnings) {
80
+ results.push({
81
+ file: `reference/${filename}`,
82
+ rule: name,
83
+ severity: 'warning',
84
+ message: warn.message,
85
+ });
86
+ }
87
+ } catch (err) {
88
+ results.push({
89
+ file: `reference/${filename}`,
90
+ rule: name,
91
+ message: err.message || String(err),
92
+ });
93
+ }
94
+
95
+ // Check for malformed $ref pointers (oas-normalize doesn't catch these).
96
+ for (const { path: refPath, ref } of findBadRefs(spec)) {
97
+ results.push({
98
+ file: `reference/${filename}`,
99
+ rule: name,
100
+ message: `Malformed $ref: "${ref}" at ${refPath} (should start with "#/")`,
101
+ });
102
+ }
103
+ }
104
+
105
+ return results;
106
+ }
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const name = 'ordering';
5
+
6
+ // Content directories where _order.yaml is expected.
7
+ const ORDERED_DIRS = ['docs', 'recipes', 'custom_pages'];
8
+
9
+ // Values that YAML interprets as non-strings and need quoting in _order.yaml.
10
+ const YAML_UNSAFE = /^(?:\d+\.?\d*|true|false|yes|no|on|off|null|~)$/i;
11
+ function yamlSafeSlug(slug) {
12
+ return YAML_UNSAFE.test(slug) ? `"${slug}"` : slug;
13
+ }
14
+
15
+ function slugFromFile(filename) {
16
+ return filename.replace(/\.(md|mdx)$/, '');
17
+ }
18
+
19
+ function parseOrderYaml(content) {
20
+ return content
21
+ .split('\n')
22
+ .map((line) => line.trim())
23
+ .filter((line) => line.startsWith('- '))
24
+ .map((line) => line.slice(2).trim().replace(/^(['"])(.*)\1$/, '$2'));
25
+ }
26
+
27
+ /**
28
+ * Find all directories that contain content and check their _order.yaml.
29
+ */
30
+ export function validateAll(files, gitRoot, { fix } = {}) {
31
+ const results = [];
32
+
33
+ // Group files by their immediate parent directory.
34
+ const dirContents = new Map();
35
+ for (const relPath of files) {
36
+ const topDir = relPath.split('/')[0];
37
+ if (!ORDERED_DIRS.includes(topDir)) continue;
38
+
39
+ const dir = path.dirname(relPath);
40
+ if (!dirContents.has(dir)) dirContents.set(dir, { files: [], subdirs: new Set() });
41
+
42
+ const filename = path.basename(relPath);
43
+ dirContents.get(dir).files.push(filename);
44
+
45
+ // Track subdirectories in the parent so we know about categories/nested dirs.
46
+ const parts = relPath.split('/');
47
+ if (parts.length > 2) {
48
+ const parentDir = parts.slice(0, -2).join('/');
49
+ const subdir = parts[parts.length - 2];
50
+ if (!dirContents.has(parentDir)) dirContents.set(parentDir, { files: [], subdirs: new Set() });
51
+ dirContents.get(parentDir).subdirs.add(subdir);
52
+ }
53
+ }
54
+
55
+ for (const [dir, { files: dirFiles, subdirs }] of dirContents) {
56
+ const orderPath = path.join(gitRoot, dir, '_order.yaml');
57
+
58
+ // Collect expected slugs: files (excluding index.md) + subdirectories.
59
+ const expectedSlugs = [];
60
+ for (const f of dirFiles) {
61
+ if (f === 'index.md' || f === 'index.mdx') continue;
62
+ expectedSlugs.push(slugFromFile(f));
63
+ }
64
+ for (const sub of subdirs) {
65
+ expectedSlugs.push(sub);
66
+ }
67
+
68
+ if (!fs.existsSync(orderPath)) {
69
+ if (expectedSlugs.length === 0) continue;
70
+
71
+ results.push({
72
+ file: path.join(dir, '_order.yaml'),
73
+ rule: name,
74
+ severity: 'warning',
75
+ fixable: true,
76
+ message: `Missing order: _order.yaml not found (${expectedSlugs.length} ${expectedSlugs.length === 1 ? 'entry needs' : 'entries need'} ordering)`,
77
+ _fix: { orderPath, missing: expectedSlugs },
78
+ });
79
+ continue;
80
+ }
81
+
82
+ const content = fs.readFileSync(orderPath, 'utf-8');
83
+ const ordered = new Set(parseOrderYaml(content));
84
+ const missing = expectedSlugs.filter((slug) => !ordered.has(slug));
85
+
86
+ if (missing.length > 0) {
87
+ results.push({
88
+ file: path.join(dir, '_order.yaml'),
89
+ rule: name,
90
+ severity: 'warning',
91
+ fixable: true,
92
+ message: `Missing from _order.yaml: ${missing.join(', ')}`,
93
+ _fix: { orderPath, missing },
94
+ });
95
+ }
96
+ }
97
+
98
+ // Apply fixes if requested.
99
+ if (fix) {
100
+ for (const r of results) {
101
+ if (!r._fix) continue;
102
+ const { orderPath, missing } = r._fix;
103
+ const newEntries = missing.map((slug) => `- ${yamlSafeSlug(slug)}`).join('\n');
104
+
105
+ if (fs.existsSync(orderPath)) {
106
+ const existing = fs.readFileSync(orderPath, 'utf-8');
107
+ const sep = existing.endsWith('\n') ? '' : '\n';
108
+ fs.writeFileSync(orderPath, `${existing}${sep}${newEntries}\n`);
109
+ } else {
110
+ fs.mkdirSync(path.dirname(orderPath), { recursive: true });
111
+ fs.writeFileSync(orderPath, `${newEntries}\n`);
112
+ }
113
+
114
+ r.message += ' (fixed)';
115
+ }
116
+ }
117
+
118
+ // Strip internal _fix data before returning.
119
+ for (const r of results) delete r._fix;
120
+ return results;
121
+ }