@readme/cli 0.0.27 → 0.0.30

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 (123) hide show
  1. package/package.json +5 -5
  2. package/src/cli.js +3 -3
  3. package/src/commands/import-oas.test.js +277 -0
  4. package/src/commands/import.classify.test.js +172 -0
  5. package/src/commands/import.js +2848 -443
  6. package/src/commands/import.landing.test.js +217 -0
  7. package/src/commands/import.slug-planner.test.js +86 -0
  8. package/src/commands/import.staging.test.js +158 -0
  9. package/src/commands/import.test.js +382 -0
  10. package/src/commands/oas-sync.js +362 -92
  11. package/src/prompts/index.js +101 -13
  12. package/src/utils/lint.js +2 -2
  13. package/src/utils/llms.js +224 -0
  14. package/src/utils/llms.test.js +302 -0
  15. package/src/utils/reporter.js +2 -1
  16. package/src/utils/slug-for-entry.js +16 -0
  17. package/src/utils/url-segs.js +124 -0
  18. package/src/utils/url-segs.test.js +239 -0
  19. package/src/validators/components.js +11 -7
  20. package/src/validators/duplicates.js +8 -8
  21. package/src/validators/frontmatter.js +11 -0
  22. package/src/validators/links.js +2 -8
  23. package/src/validators/oas-reference.js +13 -38
  24. package/src/validators/ordering.js +169 -83
  25. package/vendor/TOOLS.md +1 -1
  26. package/src/dev/.next/app-build-manifest.json +0 -20
  27. package/src/dev/.next/build-manifest.json +0 -31
  28. package/src/dev/.next/cache/.rscinfo +0 -1
  29. package/src/dev/.next/cache/next-devtools-config.json +0 -1
  30. package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
  31. package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
  32. package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
  33. package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
  34. package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
  35. package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
  36. package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
  37. package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
  38. package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
  39. package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
  40. package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
  41. package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
  42. package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
  43. package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
  44. package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
  45. package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  46. package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
  47. package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
  48. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
  49. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
  50. package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
  51. package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
  52. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
  53. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
  54. package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
  55. package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
  56. package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
  57. package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
  58. package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
  59. package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
  60. package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
  61. package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
  62. package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
  63. package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
  64. package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
  65. package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
  66. package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
  67. package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
  68. package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
  69. package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
  70. package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
  71. package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
  72. package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
  73. package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
  74. package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
  75. package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
  76. package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  77. package/src/dev/.next/package.json +0 -1
  78. package/src/dev/.next/prerender-manifest.json +0 -11
  79. package/src/dev/.next/react-loadable-manifest.json +0 -1
  80. package/src/dev/.next/routes-manifest.json +0 -1
  81. package/src/dev/.next/server/app/[...slug]/page.js +0 -360
  82. package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +0 -1
  83. package/src/dev/.next/server/app/page.js +0 -349
  84. package/src/dev/.next/server/app/page_client-reference-manifest.js +0 -1
  85. package/src/dev/.next/server/app-paths-manifest.json +0 -3
  86. package/src/dev/.next/server/edge-runtime-webpack.js +0 -1151
  87. package/src/dev/.next/server/interception-route-rewrite-manifest.js +0 -1
  88. package/src/dev/.next/server/middleware-build-manifest.js +0 -33
  89. package/src/dev/.next/server/middleware-manifest.json +0 -32
  90. package/src/dev/.next/server/middleware-react-loadable-manifest.js +0 -1
  91. package/src/dev/.next/server/middleware.js +0 -1113
  92. package/src/dev/.next/server/next-font-manifest.js +0 -1
  93. package/src/dev/.next/server/next-font-manifest.json +0 -1
  94. package/src/dev/.next/server/pages-manifest.json +0 -5
  95. package/src/dev/.next/server/server-reference-manifest.js +0 -1
  96. package/src/dev/.next/server/server-reference-manifest.json +0 -5
  97. package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +0 -1
  98. package/src/dev/.next/server/vendor-chunks/@readme.js +0 -25
  99. package/src/dev/.next/server/vendor-chunks/@swc.js +0 -55
  100. package/src/dev/.next/server/vendor-chunks/next.js +0 -3659
  101. package/src/dev/.next/server/webpack-runtime.js +0 -209
  102. package/src/dev/.next/static/chunks/app/[...slug]/loading.js +0 -28
  103. package/src/dev/.next/static/chunks/app/[...slug]/page.js +0 -28
  104. package/src/dev/.next/static/chunks/app/layout.js +0 -171
  105. package/src/dev/.next/static/chunks/app/page.js +0 -28
  106. package/src/dev/.next/static/chunks/app-pages-internals.js +0 -182
  107. package/src/dev/.next/static/chunks/main-app.js +0 -1882
  108. package/src/dev/.next/static/chunks/polyfills.js +0 -1
  109. package/src/dev/.next/static/chunks/webpack.js +0 -1393
  110. package/src/dev/.next/static/css/app/layout.css +0 -559
  111. package/src/dev/.next/static/development/_buildManifest.js +0 -1
  112. package/src/dev/.next/static/development/_ssgManifest.js +0 -1
  113. package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
  114. package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +0 -1
  115. package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +0 -12
  116. package/src/dev/.next/trace +0 -21
  117. package/src/dev/.next/types/app/[...slug]/page.ts +0 -84
  118. package/src/dev/.next/types/app/layout.ts +0 -84
  119. package/src/dev/.next/types/app/page.ts +0 -84
  120. package/src/dev/.next/types/cache-life.d.ts +0 -141
  121. package/src/dev/.next/types/package.json +0 -1
  122. package/src/dev/.next/types/routes.d.ts +0 -55
  123. 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
+ })
@@ -135,19 +135,22 @@ export function validate({ content, relativePath }) {
135
135
 
136
136
  const results = [];
137
137
 
138
- // .md snippet files: filename must be PascalCase (it becomes the component tag).
138
+ // .md snippet files: the filename becomes a component tag <Name />, so it must
139
+ // be a valid JS identifier starting with an uppercase letter. Underscores and
140
+ // digits are fine (<Helm_v4_error /> is valid); hyphens and a lowercase initial
141
+ // are not.
139
142
  if (isMd) {
140
143
  const slug = filename.replace(/\.md$/, "");
141
- const pascalCase = slug
142
- .split(/[-_]/)
143
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
144
- .join("");
145
- if (slug !== pascalCase) {
144
+ if (!/^[A-Z][A-Za-z0-9_$]*$/.test(slug)) {
145
+ const suggestion = slug
146
+ .split(/[-_]/)
147
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
148
+ .join("");
146
149
  results.push({
147
150
  file: relativePath,
148
151
  rule: name,
149
152
  severity: "warning",
150
- message: `Bad filename: should be PascalCase — rename to "${pascalCase}.md" so it can be used as <${pascalCase} />`,
153
+ message: `Bad filename: "${slug}" can't be used as a component tag — rename to "${suggestion}.md" so it can be used as <${suggestion} />`,
151
154
  });
152
155
  }
153
156
  return results.length > 0 ? results : null;
@@ -220,6 +223,7 @@ export function validateAll(files, gitRoot) {
220
223
  results.push({
221
224
  file: relPath,
222
225
  rule: name,
226
+ severity: 'warning',
223
227
  message: `Unknown component: <${comp}> is not a built-in or custom block`,
224
228
  });
225
229
  }
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { slugForEntry } from '../utils/slug-for-entry.js';
2
3
 
3
4
  export const name = 'duplicates';
4
5
 
@@ -8,27 +9,26 @@ const CHECKED_DIRS = ['docs', 'reference'];
8
9
  export function validateAll(files) {
9
10
  const results = [];
10
11
 
11
- // Group files by slug.
12
+ // Group files by "<topDir>:<slug>" so slugs only collide within the same section.
12
13
  const slugMap = new Map();
13
14
  for (const relPath of files) {
14
15
  const topDir = relPath.split('/')[0];
15
16
  if (!CHECKED_DIRS.includes(topDir)) continue;
16
17
 
17
- const filename = path.basename(relPath);
18
- if (filename === 'index.md' || filename === 'index.mdx') continue;
19
-
20
- const slug = filename.replace(/\.(md|mdx)$/, '');
21
- if (!slugMap.has(slug)) slugMap.set(slug, []);
22
- slugMap.get(slug).push(relPath);
18
+ const slug = slugForEntry(relPath);
19
+ const key = `${topDir}:${slug}`;
20
+ if (!slugMap.has(key)) slugMap.set(key, []);
21
+ slugMap.get(key).push(relPath);
23
22
  }
24
23
 
25
- for (const [slug, paths] of slugMap) {
24
+ for (const [key, paths] of slugMap) {
26
25
  if (paths.length < 2) continue;
27
26
 
28
27
  // TODO: figure out why ReadMeConfig causes duplicate slugs and handle properly.
29
28
  // For now, skip any duplicate set that involves a ReadMeConfig path.
30
29
  if (paths.some((p) => p.includes('ReadMeConfig/'))) continue;
31
30
 
31
+ const slug = key.slice(key.indexOf(':') + 1);
32
32
  const others = paths.slice(1);
33
33
  for (const relPath of others) {
34
34
  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;
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import matter from 'gray-matter';
4
+ import { slugForEntry } from '../utils/slug-for-entry.js';
4
5
 
5
6
  export const name = 'links';
6
7
 
@@ -19,14 +20,7 @@ function collectSlugs(gitRoot) {
19
20
  const entries = fs.readdirSync(dirPath, { recursive: true });
20
21
  for (const entry of entries) {
21
22
  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
- }
23
+ slugs.add(slugForEntry(entry));
30
24
  }
31
25
  }
32
26
 
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import matter from 'gray-matter';
4
- import { findOasFiles, extractOperations, collectExistingPages, syncOas } from '../commands/oas-sync.js';
4
+ import { findOasFiles, extractOperations, collectExistingPages, syncOas, operationKey } from '../commands/oas-sync.js';
5
5
 
6
6
  export const name = 'oas-reference';
7
7
 
@@ -34,6 +34,7 @@ export function validateAll(files, gitRoot, { fix } = {}) {
34
34
 
35
35
  const oasFilename = data.api.file;
36
36
  const operationId = data.api.operationId;
37
+ const isWebhook = !!data.api.webhook;
37
38
  const oas = oasMap.get(oasFilename);
38
39
 
39
40
  // Check: OAS file doesn't exist.
@@ -50,7 +51,7 @@ export function validateAll(files, gitRoot, { fix } = {}) {
50
51
  if (!operationId) continue;
51
52
 
52
53
  // Check: operationId doesn't exist in the spec.
53
- if (!oas.ops.has(operationId)) {
54
+ if (!oas.ops.has(operationKey({ operationId, isWebhook }))) {
54
55
  results.push({
55
56
  file: relPath,
56
57
  rule: name,
@@ -60,52 +61,26 @@ export function validateAll(files, gitRoot, { fix } = {}) {
60
61
  continue;
61
62
  }
62
63
 
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
- }
64
+ // Title/excerpt are owned by the OAS spec at render time — no sync check here.
94
65
  }
95
66
 
96
67
  // Check for missing pages: operations in the spec with no corresponding page.
97
68
  const existingPages = collectExistingPages(refDir);
98
69
  for (const [oasFilename, { ops }] of oasMap) {
99
70
  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)) {
71
+ const coveredOps = new Set(
72
+ pagesForOas.map((p) =>
73
+ operationKey({ operationId: p.data.api.operationId, isWebhook: !!p.data.api.webhook }),
74
+ ),
75
+ );
76
+
77
+ for (const op of ops.values()) {
78
+ if (!coveredOps.has(operationKey(op))) {
104
79
  results.push({
105
80
  file: `reference/${oasFilename}`,
106
81
  rule: name,
107
82
  severity: 'warning',
108
- message: `Missing page: no reference page found for operation "${opId}"`,
83
+ message: `Missing page: no reference page found for operation "${op.operationId}"`,
109
84
  fixable: true,
110
85
  });
111
86
  }