@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.
- package/package.json +3 -3
- package/src/cli.js +1 -1
- package/src/commands/import.js +2134 -430
- package/src/commands/import.slug-planner.test.js +86 -0
- package/src/commands/import.test.js +136 -0
- package/src/commands/oas-sync.js +60 -75
- package/src/prompts/index.js +101 -13
- package/src/utils/llms.js +224 -0
- package/src/utils/llms.test.js +257 -0
- package/src/utils/url-segs.js +124 -0
- package/src/utils/url-segs.test.js +239 -0
- package/src/validators/components.js +1 -0
- package/src/validators/duplicates.js +6 -4
- package/src/validators/frontmatter.js +11 -0
- package/src/validators/oas-reference.js +1 -31
- package/src/validators/ordering.js +169 -83
- package/vendor/TOOLS.md +1 -1
- package/src/dev/.next/app-build-manifest.json +0 -20
- package/src/dev/.next/build-manifest.json +0 -31
- package/src/dev/.next/cache/.rscinfo +0 -1
- package/src/dev/.next/cache/next-devtools-config.json +0 -1
- 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 +0 -1
- package/src/dev/.next/prerender-manifest.json +0 -11
- package/src/dev/.next/react-loadable-manifest.json +0 -1
- package/src/dev/.next/routes-manifest.json +0 -1
- package/src/dev/.next/server/app/[...slug]/page.js +0 -360
- package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +0 -1
- package/src/dev/.next/server/app/page.js +0 -349
- package/src/dev/.next/server/app/page_client-reference-manifest.js +0 -1
- package/src/dev/.next/server/app-paths-manifest.json +0 -3
- package/src/dev/.next/server/edge-runtime-webpack.js +0 -1151
- package/src/dev/.next/server/interception-route-rewrite-manifest.js +0 -1
- package/src/dev/.next/server/middleware-build-manifest.js +0 -33
- package/src/dev/.next/server/middleware-manifest.json +0 -32
- package/src/dev/.next/server/middleware-react-loadable-manifest.js +0 -1
- package/src/dev/.next/server/middleware.js +0 -1113
- package/src/dev/.next/server/next-font-manifest.js +0 -1
- package/src/dev/.next/server/next-font-manifest.json +0 -1
- package/src/dev/.next/server/pages-manifest.json +0 -5
- package/src/dev/.next/server/server-reference-manifest.js +0 -1
- package/src/dev/.next/server/server-reference-manifest.json +0 -5
- package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +0 -1
- package/src/dev/.next/server/vendor-chunks/@readme.js +0 -25
- package/src/dev/.next/server/vendor-chunks/@swc.js +0 -55
- package/src/dev/.next/server/vendor-chunks/next.js +0 -3659
- package/src/dev/.next/server/webpack-runtime.js +0 -209
- package/src/dev/.next/static/chunks/app/[...slug]/loading.js +0 -28
- package/src/dev/.next/static/chunks/app/[...slug]/page.js +0 -28
- package/src/dev/.next/static/chunks/app/layout.js +0 -171
- package/src/dev/.next/static/chunks/app/page.js +0 -28
- package/src/dev/.next/static/chunks/app-pages-internals.js +0 -182
- package/src/dev/.next/static/chunks/main-app.js +0 -1882
- package/src/dev/.next/static/chunks/polyfills.js +0 -1
- package/src/dev/.next/static/chunks/webpack.js +0 -1393
- package/src/dev/.next/static/css/app/layout.css +0 -559
- package/src/dev/.next/static/development/_buildManifest.js +0 -1
- package/src/dev/.next/static/development/_ssgManifest.js +0 -1
- package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
- package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +0 -1
- package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +0 -12
- package/src/dev/.next/trace +0 -21
- package/src/dev/.next/types/app/[...slug]/page.ts +0 -84
- package/src/dev/.next/types/app/layout.ts +0 -84
- package/src/dev/.next/types/app/page.ts +0 -84
- package/src/dev/.next/types/cache-life.d.ts +0 -141
- package/src/dev/.next/types/package.json +0 -1
- package/src/dev/.next/types/routes.d.ts +0 -55
- 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
|
+
})
|
|
@@ -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
|
-
|
|
22
|
-
slugMap.
|
|
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 [
|
|
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
|
-
//
|
|
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
|
-
//
|
|
7
|
-
const
|
|
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
|
-
|
|
16
|
-
|
|
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(
|
|
23
|
-
.filter((
|
|
24
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
}
|