@readme/cli 0.0.27 → 0.0.29

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 (115) hide show
  1. package/package.json +3 -3
  2. package/src/cli.js +1 -1
  3. package/src/commands/import.js +2134 -430
  4. package/src/commands/import.slug-planner.test.js +86 -0
  5. package/src/commands/import.test.js +136 -0
  6. package/src/commands/oas-sync.js +60 -75
  7. package/src/prompts/index.js +101 -13
  8. package/src/utils/llms.js +224 -0
  9. package/src/utils/llms.test.js +257 -0
  10. package/src/utils/url-segs.js +124 -0
  11. package/src/utils/url-segs.test.js +239 -0
  12. package/src/validators/components.js +1 -0
  13. package/src/validators/duplicates.js +6 -4
  14. package/src/validators/frontmatter.js +11 -0
  15. package/src/validators/oas-reference.js +1 -31
  16. package/src/validators/ordering.js +169 -83
  17. package/vendor/TOOLS.md +1 -1
  18. package/src/dev/.next/app-build-manifest.json +0 -20
  19. package/src/dev/.next/build-manifest.json +0 -31
  20. package/src/dev/.next/cache/.rscinfo +0 -1
  21. package/src/dev/.next/cache/next-devtools-config.json +0 -1
  22. package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
  23. package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
  24. package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
  25. package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
  26. package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
  27. package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
  28. package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
  29. package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
  30. package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
  31. package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
  32. package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
  33. package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
  34. package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
  35. package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
  36. package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
  37. package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  38. package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
  39. package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
  40. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
  41. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
  42. package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
  43. package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
  44. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
  45. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
  46. package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
  47. package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
  48. package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
  49. package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
  50. package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
  51. package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
  52. package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
  53. package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
  54. package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
  55. package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
  56. package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
  57. package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
  58. package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
  59. package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
  60. package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
  61. package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
  62. package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
  63. package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
  64. package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
  65. package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
  66. package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
  67. package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
  68. package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  69. package/src/dev/.next/package.json +0 -1
  70. package/src/dev/.next/prerender-manifest.json +0 -11
  71. package/src/dev/.next/react-loadable-manifest.json +0 -1
  72. package/src/dev/.next/routes-manifest.json +0 -1
  73. package/src/dev/.next/server/app/[...slug]/page.js +0 -360
  74. package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +0 -1
  75. package/src/dev/.next/server/app/page.js +0 -349
  76. package/src/dev/.next/server/app/page_client-reference-manifest.js +0 -1
  77. package/src/dev/.next/server/app-paths-manifest.json +0 -3
  78. package/src/dev/.next/server/edge-runtime-webpack.js +0 -1151
  79. package/src/dev/.next/server/interception-route-rewrite-manifest.js +0 -1
  80. package/src/dev/.next/server/middleware-build-manifest.js +0 -33
  81. package/src/dev/.next/server/middleware-manifest.json +0 -32
  82. package/src/dev/.next/server/middleware-react-loadable-manifest.js +0 -1
  83. package/src/dev/.next/server/middleware.js +0 -1113
  84. package/src/dev/.next/server/next-font-manifest.js +0 -1
  85. package/src/dev/.next/server/next-font-manifest.json +0 -1
  86. package/src/dev/.next/server/pages-manifest.json +0 -5
  87. package/src/dev/.next/server/server-reference-manifest.js +0 -1
  88. package/src/dev/.next/server/server-reference-manifest.json +0 -5
  89. package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +0 -1
  90. package/src/dev/.next/server/vendor-chunks/@readme.js +0 -25
  91. package/src/dev/.next/server/vendor-chunks/@swc.js +0 -55
  92. package/src/dev/.next/server/vendor-chunks/next.js +0 -3659
  93. package/src/dev/.next/server/webpack-runtime.js +0 -209
  94. package/src/dev/.next/static/chunks/app/[...slug]/loading.js +0 -28
  95. package/src/dev/.next/static/chunks/app/[...slug]/page.js +0 -28
  96. package/src/dev/.next/static/chunks/app/layout.js +0 -171
  97. package/src/dev/.next/static/chunks/app/page.js +0 -28
  98. package/src/dev/.next/static/chunks/app-pages-internals.js +0 -182
  99. package/src/dev/.next/static/chunks/main-app.js +0 -1882
  100. package/src/dev/.next/static/chunks/polyfills.js +0 -1
  101. package/src/dev/.next/static/chunks/webpack.js +0 -1393
  102. package/src/dev/.next/static/css/app/layout.css +0 -559
  103. package/src/dev/.next/static/development/_buildManifest.js +0 -1
  104. package/src/dev/.next/static/development/_ssgManifest.js +0 -1
  105. package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
  106. package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +0 -1
  107. package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +0 -12
  108. package/src/dev/.next/trace +0 -21
  109. package/src/dev/.next/types/app/[...slug]/page.ts +0 -84
  110. package/src/dev/.next/types/app/layout.ts +0 -84
  111. package/src/dev/.next/types/app/page.ts +0 -84
  112. package/src/dev/.next/types/cache-life.d.ts +0 -141
  113. package/src/dev/.next/types/package.json +0 -1
  114. package/src/dev/.next/types/routes.d.ts +0 -55
  115. package/src/validators/numbering.js +0 -134
@@ -0,0 +1,239 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { stripSegmentExtensions, urlTrieSegs, extractUrlPathSegments, normalizePath, compareCanonicalUrlPreference, llmsPageDedupeKey } from './url-segs.js'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // stripSegmentExtensions
7
+ // ---------------------------------------------------------------------------
8
+
9
+ test('stripSegmentExtensions: strips single .md', () => {
10
+ assert.equal(stripSegmentExtensions('index.md'), 'index')
11
+ })
12
+
13
+ test('stripSegmentExtensions: strips single .html', () => {
14
+ assert.equal(stripSegmentExtensions('index.html'), 'index')
15
+ })
16
+
17
+ test('stripSegmentExtensions: strips single .htm', () => {
18
+ assert.equal(stripSegmentExtensions('index.htm'), 'index')
19
+ })
20
+
21
+ test('stripSegmentExtensions: strips double .html.md (plaid pattern)', () => {
22
+ assert.equal(stripSegmentExtensions('index.html.md'), 'index')
23
+ })
24
+
25
+ test('stripSegmentExtensions: strips .mdx', () => {
26
+ assert.equal(stripSegmentExtensions('page.mdx'), 'page')
27
+ })
28
+
29
+ test('stripSegmentExtensions: strips .txt', () => {
30
+ assert.equal(stripSegmentExtensions('terminal.txt'), 'terminal')
31
+ })
32
+
33
+ test('stripSegmentExtensions: leaves bare txt segment untouched (no dot prefix)', () => {
34
+ assert.equal(stripSegmentExtensions('txt'), 'txt')
35
+ })
36
+
37
+ test('stripSegmentExtensions: leaves plain segments untouched', () => {
38
+ assert.equal(stripSegmentExtensions('quickstart'), 'quickstart')
39
+ })
40
+
41
+ test('stripSegmentExtensions: leaves kebab segments untouched', () => {
42
+ assert.equal(stripSegmentExtensions('getting-started'), 'getting-started')
43
+ })
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // urlTrieSegs — the double-extension plaid.com pattern
47
+ // ---------------------------------------------------------------------------
48
+
49
+ test('urlTrieSegs: .html.md index collapses to parent path (plaid pattern)', () => {
50
+ assert.deepEqual(
51
+ urlTrieSegs('https://plaid.com/docs/account/index.html.md'),
52
+ ['docs', 'account'],
53
+ )
54
+ })
55
+
56
+ test('urlTrieSegs: .html index collapses to parent path', () => {
57
+ assert.deepEqual(
58
+ urlTrieSegs('https://plaid.com/docs/account/index.html'),
59
+ ['docs', 'account'],
60
+ )
61
+ })
62
+
63
+ test('urlTrieSegs: trailing slash collapses correctly', () => {
64
+ assert.deepEqual(
65
+ urlTrieSegs('https://plaid.com/docs/account/'),
66
+ ['docs', 'account'],
67
+ )
68
+ })
69
+
70
+ test('urlTrieSegs: non-index .html.md page keeps its segment', () => {
71
+ assert.deepEqual(
72
+ urlTrieSegs('https://plaid.com/docs/account/billing/index.html.md'),
73
+ ['docs', 'account', 'billing'],
74
+ )
75
+ })
76
+
77
+ test('urlTrieSegs: orval.dev docs/index.html.md collapses correctly', () => {
78
+ assert.deepEqual(
79
+ urlTrieSegs('https://orval.dev/docs/index.html.md'),
80
+ ['docs'],
81
+ )
82
+ })
83
+
84
+ test('urlTrieSegs: plain URL without extension passes through', () => {
85
+ assert.deepEqual(
86
+ urlTrieSegs('https://example.com/docs/quickstart'),
87
+ ['docs', 'quickstart'],
88
+ )
89
+ })
90
+
91
+ test('urlTrieSegs: returns null on unparseable input', () => {
92
+ assert.equal(urlTrieSegs('not-a-url'), null)
93
+ })
94
+
95
+ test('urlTrieSegs: .txt segment strips extension (warp.dev pattern)', () => {
96
+ assert.deepEqual(
97
+ urlTrieSegs('https://docs.warp.dev/_llms-txt/terminal.txt'),
98
+ ['_llms-txt', 'terminal'],
99
+ )
100
+ })
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // extractUrlPathSegments — slug source
104
+ // ---------------------------------------------------------------------------
105
+
106
+ test('extractUrlPathSegments: .html.md index → parent segments only (plaid pattern)', () => {
107
+ assert.deepEqual(
108
+ extractUrlPathSegments('https://plaid.com/docs/account/index.html.md'),
109
+ ['docs', 'account'],
110
+ )
111
+ })
112
+
113
+ test('extractUrlPathSegments: slug from deepened path', () => {
114
+ assert.deepEqual(
115
+ extractUrlPathSegments('https://plaid.com/docs/account/billing/index.html.md'),
116
+ ['docs', 'account', 'billing'],
117
+ )
118
+ })
119
+
120
+ test('extractUrlPathSegments: strips leading numeric prefix', () => {
121
+ assert.deepEqual(
122
+ extractUrlPathSegments('https://example.com/docs/01-intro.md'),
123
+ ['docs', 'intro'],
124
+ )
125
+ })
126
+
127
+ test('extractUrlPathSegments: plain .md page', () => {
128
+ assert.deepEqual(
129
+ extractUrlPathSegments('https://example.com/docs/quickstart.md'),
130
+ ['docs', 'quickstart'],
131
+ )
132
+ })
133
+
134
+ test('extractUrlPathSegments: returns [] for empty/null input', () => {
135
+ assert.deepEqual(extractUrlPathSegments(''), [])
136
+ assert.deepEqual(extractUrlPathSegments(null), [])
137
+ })
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // normalizePath — deduplication keys
141
+ // ---------------------------------------------------------------------------
142
+
143
+ test('normalizePath: .html.md and .html and bare path all produce same key', () => {
144
+ const a = normalizePath('https://plaid.com/docs/account/index.html.md')
145
+ const b = normalizePath('https://plaid.com/docs/account/index.html')
146
+ const c = normalizePath('https://plaid.com/docs/account/index')
147
+ assert.equal(a, b)
148
+ assert.equal(b, c)
149
+ })
150
+
151
+ test('normalizePath: trailing slash and bare path produce same key', () => {
152
+ const a = normalizePath('https://example.com/docs/foo/')
153
+ const b = normalizePath('https://example.com/docs/foo')
154
+ assert.equal(a, b)
155
+ })
156
+
157
+ test('normalizePath: index variants and bare path produce same key', () => {
158
+ const bare = normalizePath('https://example.com/docs/get-started')
159
+ assert.equal(normalizePath('https://example.com/docs/get-started/'), bare)
160
+ assert.equal(normalizePath('https://example.com/docs/get-started/index.html'), bare)
161
+ assert.equal(normalizePath('https://example.com/docs/get-started/index.htm'), bare)
162
+ })
163
+
164
+ test('normalizePath: lowercases result', () => {
165
+ assert.equal(normalizePath('https://example.com/Docs/QuickStart'), '/docs/quickstart')
166
+ })
167
+
168
+ test('normalizePath: falls back for unparseable input', () => {
169
+ assert.equal(normalizePath('not-a-url'), 'not-a-url')
170
+ })
171
+
172
+ test('normalizePath: strips .txt extension (warp.dev pattern)', () => {
173
+ assert.equal(
174
+ normalizePath('https://docs.warp.dev/_llms-txt/terminal.txt'),
175
+ '/_llms-txt/terminal',
176
+ )
177
+ })
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // llmsPageDedupeKey — llms.txt page-row deduplication
181
+ // ---------------------------------------------------------------------------
182
+
183
+ test('llmsPageDedupeKey: includes origin so same paths on different hosts stay distinct', () => {
184
+ assert.notEqual(
185
+ llmsPageDedupeKey('https://docs.example.com/docs/get-started.md'),
186
+ llmsPageDedupeKey('https://example.com/docs/get-started.md'),
187
+ )
188
+ })
189
+
190
+ test('llmsPageDedupeKey: preserves query params by default', () => {
191
+ assert.notEqual(
192
+ llmsPageDedupeKey('https://example.com/docs/search?product=api'),
193
+ llmsPageDedupeKey('https://example.com/docs/search?product=sdk'),
194
+ )
195
+ })
196
+
197
+ test('llmsPageDedupeKey: normalizes query param order', () => {
198
+ assert.equal(
199
+ llmsPageDedupeKey('https://example.com/docs/search?b=2&a=1'),
200
+ llmsPageDedupeKey('https://example.com/docs/search?a=1&b=2'),
201
+ )
202
+ })
203
+
204
+ test('llmsPageDedupeKey: ignores fragments but preserves router query state', () => {
205
+ assert.equal(
206
+ llmsPageDedupeKey('https://example.com/docs/get-started.md?tab=js#install'),
207
+ llmsPageDedupeKey('https://example.com/docs/get-started?tab=js#auth'),
208
+ )
209
+ })
210
+
211
+ test('llmsPageDedupeKey: collapses doc URL spellings for the same page', () => {
212
+ const bare = llmsPageDedupeKey('https://example.com/docs/get-started')
213
+ assert.equal(llmsPageDedupeKey('https://example.com/docs/get-started.md'), bare)
214
+ assert.equal(llmsPageDedupeKey('https://example.com/docs/get-started.txt'), bare)
215
+ assert.equal(llmsPageDedupeKey('https://example.com/docs/get-started/index.html'), bare)
216
+ assert.equal(llmsPageDedupeKey('https://example.com/docs/get-started/'), bare)
217
+ })
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // compareCanonicalUrlPreference — retained URL selection
221
+ // ---------------------------------------------------------------------------
222
+
223
+ test('compareCanonicalUrlPreference: prefers md over txt, bare, trailing slash, and index', () => {
224
+ const urls = [
225
+ 'https://example.com/docs/get-started/index.html',
226
+ 'https://example.com/docs/get-started/',
227
+ 'https://example.com/docs/get-started',
228
+ 'https://example.com/docs/get-started.txt',
229
+ 'https://example.com/docs/get-started.md',
230
+ ]
231
+
232
+ assert.deepEqual([...urls].sort(compareCanonicalUrlPreference), [
233
+ 'https://example.com/docs/get-started.md',
234
+ 'https://example.com/docs/get-started.txt',
235
+ 'https://example.com/docs/get-started',
236
+ 'https://example.com/docs/get-started/',
237
+ 'https://example.com/docs/get-started/index.html',
238
+ ])
239
+ })
@@ -220,6 +220,7 @@ export function validateAll(files, gitRoot) {
220
220
  results.push({
221
221
  file: relPath,
222
222
  rule: name,
223
+ severity: 'warning',
223
224
  message: `Unknown component: <${comp}> is not a built-in or custom block`,
224
225
  });
225
226
  }
@@ -8,7 +8,7 @@ const CHECKED_DIRS = ['docs', 'reference'];
8
8
  export function validateAll(files) {
9
9
  const results = [];
10
10
 
11
- // Group files by slug.
11
+ // Group files by "<topDir>:<slug>" so slugs only collide within the same section.
12
12
  const slugMap = new Map();
13
13
  for (const relPath of files) {
14
14
  const topDir = relPath.split('/')[0];
@@ -18,17 +18,19 @@ export function validateAll(files) {
18
18
  if (filename === 'index.md' || filename === 'index.mdx') continue;
19
19
 
20
20
  const slug = filename.replace(/\.(md|mdx)$/, '');
21
- if (!slugMap.has(slug)) slugMap.set(slug, []);
22
- slugMap.get(slug).push(relPath);
21
+ const key = `${topDir}:${slug}`;
22
+ if (!slugMap.has(key)) slugMap.set(key, []);
23
+ slugMap.get(key).push(relPath);
23
24
  }
24
25
 
25
- for (const [slug, paths] of slugMap) {
26
+ for (const [key, paths] of slugMap) {
26
27
  if (paths.length < 2) continue;
27
28
 
28
29
  // TODO: figure out why ReadMeConfig causes duplicate slugs and handle properly.
29
30
  // For now, skip any duplicate set that involves a ReadMeConfig path.
30
31
  if (paths.some((p) => p.includes('ReadMeConfig/'))) continue;
31
32
 
33
+ const slug = key.slice(key.indexOf(':') + 1);
32
34
  const others = paths.slice(1);
33
35
  for (const relPath of others) {
34
36
  const otherLocations = paths.filter((p) => p !== relPath).map((p) => path.dirname(p)).join(', ');
@@ -113,6 +113,17 @@ export function validate({ content, filePath, relativePath, fix }) {
113
113
  const valid = validateFn(data);
114
114
  if (!valid) {
115
115
  for (const err of validateFn.errors) {
116
+ // Reference pages are OAS-backed: title/excerpt come from the spec, so a
117
+ // missing title is not an error for pages that declare an api.file.
118
+ if (
119
+ dir === 'reference' &&
120
+ data?.api?.file &&
121
+ err.keyword === 'required' &&
122
+ err.params?.missingProperty === 'title'
123
+ ) {
124
+ continue;
125
+ }
126
+
116
127
  if (err.keyword === 'not' && err.schema?.properties) {
117
128
  const target = err.instancePath ? getNestedValue(data, err.instancePath) : data;
118
129
  if (!target || !Object.keys(err.schema.properties).some((k) => k in target)) continue;
@@ -60,37 +60,7 @@ export function validateAll(files, gitRoot, { fix } = {}) {
60
60
  continue;
61
61
  }
62
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
- }
63
+ // Title/excerpt are owned by the OAS spec at render time — no sync check here.
94
64
  }
95
65
 
96
66
  // Check for missing pages: operations in the spec with no corresponding page.
@@ -3,8 +3,13 @@ import path from 'node:path';
3
3
 
4
4
  export const name = 'ordering';
5
5
 
6
- // Content directories where _order.yaml is expected.
7
- const ORDERED_DIRS = ['docs', 'recipes', 'custom_pages'];
6
+ // Top-level content directories that use _order.yaml.
7
+ const CONTENT_DIRS = ['docs', 'reference', 'recipes', 'custom_pages'];
8
+
9
+ // Of those, the dirs that must list every page/folder. reference ordering is
10
+ // OAS-managed, so we never flag pages "missing from" a reference _order.yaml —
11
+ // but stale entries are flagged everywhere.
12
+ const REQUIRE_ORDER = new Set(['docs', 'recipes', 'custom_pages']);
8
13
 
9
14
  // Values that YAML interprets as non-strings and need quoting in _order.yaml.
10
15
  const YAML_UNSAFE = /^(?:\d+\.?\d*|true|false|yes|no|on|off|null|~)$/i;
@@ -12,110 +17,191 @@ function yamlSafeSlug(slug) {
12
17
  return YAML_UNSAFE.test(slug) ? `"${slug}"` : slug;
13
18
  }
14
19
 
15
- function slugFromFile(filename) {
16
- return filename.replace(/\.(md|mdx)$/, '');
20
+ // Returns the slug a "- entry" line refers to, or null for any other line.
21
+ // Handles quoted slugs and trailing inline comments (`- overview # comment`).
22
+ function parseOrderEntry(line) {
23
+ const trimmed = line.trim();
24
+ if (!trimmed.startsWith('- ')) return null;
25
+ const value = trimmed.slice(2).trim();
26
+ const quoted = value.match(/^(['"])(.*)\1\s*(?:#.*)?$/);
27
+ if (quoted) return quoted[2];
28
+ const slug = value.replace(/(?:^|\s)#.*$/, '').trim();
29
+ return slug === '' ? null : slug;
17
30
  }
18
31
 
19
32
  function parseOrderYaml(content) {
20
33
  return content
21
34
  .split('\n')
22
- .map((line) => line.trim())
23
- .filter((line) => line.startsWith('- '))
24
- .map((line) => line.slice(2).trim().replace(/^(['"])(.*)\1$/, '$2'));
35
+ .map(parseOrderEntry)
36
+ .filter((entry) => entry !== null);
37
+ }
38
+
39
+ function isIndex(name) {
40
+ return name === 'index' || name === 'index.md' || name === 'index.mdx';
41
+ }
42
+
43
+ /**
44
+ * Walk a content directory tree once. For every directory, capture the on-disk
45
+ * slug set (child pages + child folders) and its _order.yaml entries (or null
46
+ * when absent), so the caller can diff the two in a single pass.
47
+ */
48
+ function collectDirs(gitRoot, topDir) {
49
+ const base = path.join(gitRoot, topDir);
50
+ if (!fs.existsSync(base)) return [];
51
+
52
+ const dirs = [];
53
+ const stack = [base];
54
+ while (stack.length) {
55
+ const cur = stack.pop();
56
+ const onDisk = new Set();
57
+ let orderEntries = null;
58
+
59
+ for (const entry of fs.readdirSync(cur, { withFileTypes: true })) {
60
+ if (entry.isDirectory()) {
61
+ onDisk.add(entry.name);
62
+ stack.push(path.join(cur, entry.name));
63
+ } else if (entry.name === '_order.yaml') {
64
+ orderEntries = parseOrderYaml(fs.readFileSync(path.join(cur, entry.name), 'utf-8'));
65
+ } else if (/\.(md|mdx)$/.test(entry.name) && !isIndex(entry.name)) {
66
+ onDisk.add(entry.name.replace(/\.(md|mdx)$/, ''));
67
+ }
68
+ }
69
+
70
+ dirs.push({ dir: cur, onDisk, orderEntries });
71
+ }
72
+ return dirs;
25
73
  }
26
74
 
27
75
  /**
28
- * Find all directories that contain content and check their _order.yaml.
76
+ * Validate every _order.yaml against the files and folders next to it:
77
+ * - on disk but missing from _order.yaml → "Missing from _order.yaml" (required dirs only)
78
+ * - in _order.yaml but missing on disk → "Stale entry"
79
+ * - an index entry that shouldn't be listed → "Invalid entry"
29
80
  */
30
81
  export function validateAll(files, gitRoot, { fix } = {}) {
31
82
  const results = [];
32
83
 
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
- }
84
+ for (const topDir of CONTENT_DIRS) {
85
+ const requireOrder = REQUIRE_ORDER.has(topDir);
86
+
87
+ for (const { dir, onDisk, orderEntries } of collectDirs(gitRoot, topDir)) {
88
+ const orderPath = path.join(dir, '_order.yaml');
89
+ const relOrder = path.relative(gitRoot, orderPath);
90
+
91
+ // No _order.yaml at all: only required dirs need one.
92
+ if (orderEntries === null) {
93
+ if (requireOrder && onDisk.size > 0) {
94
+ const missing = [...onDisk].sort();
95
+ results.push({
96
+ file: relOrder,
97
+ rule: name,
98
+ severity: 'warning',
99
+ fixable: true,
100
+ message: `Missing order: _order.yaml not found (${missing.length} ${missing.length === 1 ? 'entry needs' : 'entries need'} ordering)`,
101
+ _fixAdd: { orderPath, missing },
102
+ });
103
+ }
104
+ continue;
105
+ }
54
106
 
55
- for (const [dir, { files: dirFiles, subdirs }] of dirContents) {
56
- const orderPath = path.join(gitRoot, dir, '_order.yaml');
107
+ const ordered = new Set(orderEntries);
108
+
109
+ // On disk but not ordered.
110
+ if (requireOrder) {
111
+ const missing = [...onDisk].filter((slug) => !ordered.has(slug)).sort();
112
+ if (missing.length > 0) {
113
+ results.push({
114
+ file: relOrder,
115
+ rule: name,
116
+ severity: 'warning',
117
+ fixable: true,
118
+ message: `Missing from _order.yaml: ${missing.join(', ')}`,
119
+ _fixAdd: { orderPath, missing },
120
+ });
121
+ }
122
+ }
57
123
 
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);
124
+ // Ordered but not on disk (or an index entry that shouldn't be listed).
125
+ for (const entry of orderEntries) {
126
+ if (isIndex(entry)) {
127
+ results.push({
128
+ file: relOrder,
129
+ rule: name,
130
+ severity: 'warning',
131
+ fixable: true,
132
+ message: `Invalid entry: "${entry}" should not be listed in _order.yaml`,
133
+ _fixRemove: { orderPath, entry },
134
+ });
135
+ } else if (!onDisk.has(entry)) {
136
+ results.push({
137
+ file: relOrder,
138
+ rule: name,
139
+ severity: 'warning',
140
+ fixable: true,
141
+ message: `Stale entry: "${entry}" in _order.yaml has no matching file or folder`,
142
+ _fixRemove: { orderPath, entry },
143
+ });
144
+ }
145
+ }
66
146
  }
147
+ }
67
148
 
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;
149
+ if (!fix) return strip(results);
150
+
151
+ // Additions: append missing slugs to the _order.yaml.
152
+ for (const r of results) {
153
+ if (!r._fixAdd) continue;
154
+ const { orderPath, missing } = r._fixAdd;
155
+ const newEntries = missing.map((slug) => `- ${yamlSafeSlug(slug)}`).join('\n');
156
+
157
+ if (fs.existsSync(orderPath)) {
158
+ const existing = fs.readFileSync(orderPath, 'utf-8');
159
+ const sep = existing.endsWith('\n') ? '' : '\n';
160
+ fs.writeFileSync(orderPath, `${existing}${sep}${newEntries}\n`);
161
+ } else {
162
+ fs.mkdirSync(path.dirname(orderPath), { recursive: true });
163
+ fs.writeFileSync(orderPath, `${newEntries}\n`);
80
164
  }
165
+ r.message += ' (fixed)';
166
+ }
81
167
 
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 },
168
+ // Removals: group stale/index entries by order file, then rewrite each once.
169
+ const removalsByPath = new Map();
170
+ for (const r of results) {
171
+ if (!r._fixRemove) continue;
172
+ const { orderPath, entry } = r._fixRemove;
173
+ if (!removalsByPath.has(orderPath)) removalsByPath.set(orderPath, new Set());
174
+ removalsByPath.get(orderPath).add(entry);
175
+ }
176
+ for (const [orderPath, toRemove] of removalsByPath) {
177
+ if (!fs.existsSync(orderPath)) continue;
178
+ // Drop only the targeted entry lines, keeping comments, blank lines, and
179
+ // untouched entries exactly as written.
180
+ const kept = fs
181
+ .readFileSync(orderPath, 'utf-8')
182
+ .split('\n')
183
+ .filter((line) => {
184
+ const entry = parseOrderEntry(line);
185
+ return entry === null || !toRemove.has(entry);
94
186
  });
187
+ if (kept.some((line) => parseOrderEntry(line) !== null)) {
188
+ fs.writeFileSync(orderPath, kept.join('\n'));
189
+ } else {
190
+ fs.unlinkSync(orderPath);
95
191
  }
96
192
  }
193
+ for (const r of results) {
194
+ if (r._fixRemove) r.message += ' (fixed)';
195
+ }
97
196
 
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
- }
197
+ return strip(results);
198
+ }
113
199
 
114
- r.message += ' (fixed)';
115
- }
200
+ // Remove internal fix descriptors before returning results to the reporter.
201
+ function strip(results) {
202
+ for (const r of results) {
203
+ delete r._fixAdd;
204
+ delete r._fixRemove;
116
205
  }
117
-
118
- // Strip internal _fix data before returning.
119
- for (const r of results) delete r._fix;
120
206
  return results;
121
207
  }
package/vendor/TOOLS.md CHANGED
@@ -4,7 +4,7 @@ The ReadMe CLI (`npx @readme/cli`) has several commands that can help fix issues
4
4
 
5
5
  ## `npx @readme/cli lint --fix`
6
6
 
7
- Automatically fixes common linting issues: ordering problems, frontmatter cleanup, numbering suffixes, etc. Always try this first.
7
+ Automatically fixes common linting issues: ordering problems, frontmatter cleanup, etc. Always try this first.
8
8
 
9
9
  ## `npx @readme/cli oas:sync`
10
10
 
@@ -1,20 +0,0 @@
1
- {
2
- "pages": {
3
- "/layout": [
4
- "static/chunks/webpack.js",
5
- "static/chunks/main-app.js",
6
- "static/css/app/layout.css",
7
- "static/chunks/app/layout.js"
8
- ],
9
- "/[...slug]/loading": [
10
- "static/chunks/webpack.js",
11
- "static/chunks/main-app.js",
12
- "static/chunks/app/[...slug]/loading.js"
13
- ],
14
- "/[...slug]/page": [
15
- "static/chunks/webpack.js",
16
- "static/chunks/main-app.js",
17
- "static/chunks/app/[...slug]/page.js"
18
- ]
19
- }
20
- }