@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,302 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { parseLlmsTxt, analyzeLlmsTxt } from './llms.js'
4
+
5
+ const BASE = 'https://docs.example.com/llms.txt'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // parseLlmsTxt — root-relative resolution
9
+ // ---------------------------------------------------------------------------
10
+
11
+ test('parseLlmsTxt: root-relative links resolve from the origin by default', () => {
12
+ const parsed = parseLlmsTxt(
13
+ `# Example\n\n## Guides\n- [Overview](/overview.md)\n- [Install](/docs/install.md)\n`,
14
+ 'https://example.com/docs/llms.txt',
15
+ )
16
+
17
+ const urls = parsed.sections[0].items.map((item) => item.url)
18
+ assert.deepEqual(urls, [
19
+ 'https://example.com/overview.md',
20
+ 'https://example.com/docs/install.md',
21
+ ])
22
+ })
23
+
24
+ test('parseLlmsTxt: explicit llms-dir policy resolves nested root-relative page links from the llms.txt directory', () => {
25
+ const parsed = parseLlmsTxt(
26
+ `# Example\n\n## Guides\n- [Overview](/overview.md)\n- [API](/api/reference.md)\n- [Absolute](https://example.com/reference/openapi.md)\n`,
27
+ 'https://example.com/docs/llms.txt',
28
+ { rootRelativeResolution: 'llms-dir' },
29
+ )
30
+
31
+ const urls = parsed.sections[0].items.map((item) => item.url)
32
+ assert.deepEqual(urls, [
33
+ 'https://example.com/docs/overview.md',
34
+ 'https://example.com/docs/api/reference.md',
35
+ 'https://example.com/reference/openapi.md',
36
+ ])
37
+ })
38
+
39
+ test('parseLlmsTxt: llms-dir policy preserves links already scoped to the llms.txt directory', () => {
40
+ const parsed = parseLlmsTxt(
41
+ `# Example\n\n## Guides\n- [Already scoped](/docs/reference.md)\n- [Deep already scoped](/docs/api/reference.md?version=1#auth)\n`,
42
+ 'https://example.com/docs/llms.txt',
43
+ { rootRelativeResolution: 'llms-dir' },
44
+ )
45
+
46
+ const urls = parsed.sections[0].items.map((item) => item.url)
47
+ assert.deepEqual(urls, [
48
+ 'https://example.com/docs/reference.md',
49
+ 'https://example.com/docs/api/reference.md?version=1#auth',
50
+ ])
51
+ })
52
+
53
+ test('parseLlmsTxt: llms-dir policy does not rewrite explicit nested llms.txt links', () => {
54
+ const parsed = parseLlmsTxt(
55
+ `# Example\n\n## Guides\n- [API llms](/api/llms.txt)\n- [Docs API llms](/docs/api/llms.txt)\n`,
56
+ 'https://example.com/docs/llms.txt',
57
+ { rootRelativeResolution: 'llms-dir' },
58
+ )
59
+
60
+ const urls = parsed.sections[0].items.map((item) => item.url)
61
+ assert.deepEqual(urls, [
62
+ 'https://example.com/api/llms.txt',
63
+ 'https://example.com/docs/api/llms.txt',
64
+ ])
65
+ })
66
+
67
+ test('parseLlmsTxt: llms-dir policy preserves root-scoped non-page files', () => {
68
+ const parsed = parseLlmsTxt(
69
+ `# Example\n\n## Guides\n- [Overview](/overview.md)\n- [OpenAPI](/reference/openapi.yaml)\n- [Image](/assets/logo.png)\n`,
70
+ 'https://example.com/docs/llms.txt',
71
+ { rootRelativeResolution: 'llms-dir' },
72
+ )
73
+
74
+ const urls = parsed.sections[0].items.map((item) => item.url)
75
+ assert.deepEqual(urls, [
76
+ 'https://example.com/docs/overview.md',
77
+ 'https://example.com/reference/openapi.yaml',
78
+ 'https://example.com/assets/logo.png',
79
+ ])
80
+ })
81
+
82
+ test('parseLlmsTxt: llms-dir policy handles query and hash page links', () => {
83
+ const parsed = parseLlmsTxt(
84
+ `# Example\n\n## Guides\n- [Overview](/overview.md?tab=js#install)\n- [No extension](/get-started?tab=js#install)\n- [Hash only](#local)\n`,
85
+ 'https://example.com/docs/llms.txt',
86
+ { rootRelativeResolution: 'llms-dir' },
87
+ )
88
+
89
+ const urls = parsed.sections[0].items.map((item) => item.url)
90
+ assert.deepEqual(urls, [
91
+ 'https://example.com/docs/overview.md?tab=js#install',
92
+ 'https://example.com/docs/get-started?tab=js#install',
93
+ ])
94
+ })
95
+
96
+ test('parseLlmsTxt: malformed and unsupported links are ignored', () => {
97
+ const parsed = parseLlmsTxt(
98
+ `# Example\n\n## Guides\n- [Mail](mailto:test@example.com)\n- [JS](javascript:alert(1))\n- [OK](/ok.md)\n`,
99
+ 'https://example.com/docs/llms.txt',
100
+ { rootRelativeResolution: 'llms-dir' },
101
+ )
102
+
103
+ const urls = parsed.sections[0].items.map((item) => item.url)
104
+ assert.deepEqual(urls, ['https://example.com/docs/ok.md'])
105
+ })
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // parseLlmsTxt — standard H2 sections
109
+ // ---------------------------------------------------------------------------
110
+
111
+ test('parseLlmsTxt: parses H1 title and H2 sections', () => {
112
+ const body = [
113
+ '# My Docs',
114
+ '',
115
+ '## Getting Started',
116
+ '- [Overview](https://docs.example.com/overview)',
117
+ '- [Quickstart](https://docs.example.com/quickstart)',
118
+ '',
119
+ '## Reference',
120
+ '- [API](https://docs.example.com/api)',
121
+ ].join('\n')
122
+
123
+ const result = parseLlmsTxt(body, BASE)
124
+ assert.equal(result.title, 'My Docs')
125
+ assert.equal(result.sections.length, 2)
126
+ assert.equal(result.sections[0].title, 'Getting Started')
127
+ assert.equal(result.sections[0].items.length, 2)
128
+ assert.equal(result.sections[1].title, 'Reference')
129
+ assert.equal(result.h3Fallback, undefined)
130
+ })
131
+
132
+ test('parseLlmsTxt: items with no H2 fall into implicit Resources section', () => {
133
+ const body = [
134
+ '# My Docs',
135
+ '- [Overview](https://docs.example.com/overview)',
136
+ ].join('\n')
137
+
138
+ const result = parseLlmsTxt(body, BASE)
139
+ assert.equal(result.sections.length, 1)
140
+ assert.equal(result.sections[0].title, 'Resources')
141
+ })
142
+
143
+ test('parseLlmsTxt: strips trailing (section) markers from H2 titles', () => {
144
+ const body = [
145
+ '# My Docs',
146
+ '',
147
+ '## getting-started (section)',
148
+ '- [Overview](https://docs.example.com/overview)',
149
+ '',
150
+ '## API Reference (SECTION)',
151
+ '- [Pets](https://docs.example.com/pets)',
152
+ '',
153
+ '## Guides (section) extra',
154
+ '- [Install](https://docs.example.com/install)',
155
+ ].join('\n')
156
+
157
+ const result = parseLlmsTxt(body, BASE)
158
+ assert.deepEqual(
159
+ result.sections.map((s) => s.title),
160
+ ['getting-started', 'API Reference', 'Guides (section) extra'],
161
+ )
162
+ })
163
+
164
+ test('parseLlmsTxt: strips (section) markers when falling back to H3 headings', () => {
165
+ const manyItems = Array.from({ length: 201 }, (_, i) => `- [Page ${i}](https://docs.example.com/page-${i})`).join('\n')
166
+ const body = [
167
+ '# Big Site',
168
+ '',
169
+ '## Docs',
170
+ manyItems,
171
+ '',
172
+ '### getting-started (section)',
173
+ '- [A1](https://docs.example.com/a1)',
174
+ '',
175
+ '### Reference (section)',
176
+ '- [B1](https://docs.example.com/b1)',
177
+ ].join('\n')
178
+
179
+ const result = parseLlmsTxt(body, BASE)
180
+ assert.equal(result.h3Fallback, true)
181
+ const titles = result.sections.map((s) => s.title)
182
+ assert.ok(titles.includes('getting-started'))
183
+ assert.ok(titles.includes('Reference'))
184
+ assert.ok(!titles.includes('getting-started (section)'))
185
+ assert.ok(!titles.includes('Reference (section)'))
186
+ })
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // parseLlmsTxt — H3 fallback
190
+ // ---------------------------------------------------------------------------
191
+
192
+ // Generates a Couchbase-style body: one H2 bucket, N H3 sub-sections each
193
+ // with `itemsPerSection` pages. With itemsPerSection >= 68, the single H2
194
+ // section exceeds MAX_SECTION_ITEMS (200) and triggers the H3 fallback.
195
+ function makeCouchbaseBody(sections, itemsPerSection) {
196
+ const lines = ['# Couchbase', '', '> Official Couchbase docs.', '', '## Docs', '']
197
+ for (const title of sections) {
198
+ lines.push(`### ${title}`)
199
+ for (let i = 0; i < itemsPerSection; i++) {
200
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-')
201
+ lines.push(`- [Page ${i}](https://docs.couchbase.com/${slug}/page-${i})`)
202
+ }
203
+ lines.push('')
204
+ }
205
+ return lines.join('\n')
206
+ }
207
+
208
+ const CB_SECTIONS = ['.NET Analytics SDK (1.0)', '.NET Entity Framework (1.0)', '.NET SDK (3.9)']
209
+
210
+ test('parseLlmsTxt: falls back to H3 when single H2 section is oversized', () => {
211
+ const manyItems = Array.from({ length: 201 }, (_, i) =>
212
+ `- [Page ${i}](https://docs.example.com/page-${i})`
213
+ ).join('\n')
214
+
215
+ const body = [
216
+ '# Big Site',
217
+ '',
218
+ '## Docs',
219
+ manyItems,
220
+ '',
221
+ '### Section A',
222
+ '- [A1](https://docs.example.com/a1)',
223
+ '- [A2](https://docs.example.com/a2)',
224
+ '',
225
+ '### Section B',
226
+ '- [B1](https://docs.example.com/b1)',
227
+ ].join('\n')
228
+
229
+ const result = parseLlmsTxt(body, BASE)
230
+ assert.equal(result.h3Fallback, true)
231
+ assert.ok(result.sections.length > 1, `expected >1 section, got ${result.sections.length}`)
232
+
233
+ const titles = result.sections.map((s) => s.title)
234
+ assert.ok(titles.includes('Section A'))
235
+ assert.ok(titles.includes('Section B'))
236
+ })
237
+
238
+ test('parseLlmsTxt: Couchbase-style H3 structure produces one section per SDK', () => {
239
+ const body = makeCouchbaseBody(CB_SECTIONS, 70)
240
+ const result = parseLlmsTxt(body, 'https://docs.couchbase.com/llms.txt')
241
+
242
+ assert.equal(result.h3Fallback, true)
243
+ assert.equal(result.title, 'Couchbase')
244
+ assert.equal(result.sections.length, 3)
245
+ assert.equal(result.sections[0].title, '.NET Analytics SDK (1.0)')
246
+ assert.equal(result.sections[1].title, '.NET Entity Framework (1.0)')
247
+ assert.equal(result.sections[2].title, '.NET SDK (3.9)')
248
+ assert.equal(result.sections[0].items.length, 70)
249
+ })
250
+
251
+ test('parseLlmsTxt: does NOT use H3 fallback when H2 sections are usable', () => {
252
+ const body = [
253
+ '# Site',
254
+ '',
255
+ '## Section A',
256
+ '- [A1](https://docs.example.com/a1)',
257
+ '- [A2](https://docs.example.com/a2)',
258
+ '',
259
+ '## Section B',
260
+ '- [B1](https://docs.example.com/b1)',
261
+ '',
262
+ '### Sub B',
263
+ '- [B2](https://docs.example.com/b2)',
264
+ ].join('\n')
265
+
266
+ const result = parseLlmsTxt(body, BASE)
267
+ assert.equal(result.h3Fallback, undefined)
268
+ assert.equal(result.sections.length, 2)
269
+ assert.equal(result.sections[0].title, 'Section A')
270
+ assert.equal(result.sections[1].title, 'Section B')
271
+ })
272
+
273
+ test('parseLlmsTxt: does NOT use H3 fallback when no H3 headings exist', () => {
274
+ const manyItems = Array.from({ length: 201 }, (_, i) =>
275
+ `- [Page ${i}](https://docs.example.com/page-${i})`
276
+ ).join('\n')
277
+
278
+ const body = ['# Big Site', '', '## Docs', manyItems].join('\n')
279
+
280
+ const result = parseLlmsTxt(body, BASE)
281
+ assert.equal(result.h3Fallback, undefined)
282
+ assert.equal(result.sections.length, 1)
283
+ assert.equal(result.sections[0].title, 'Docs')
284
+ })
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // analyzeLlmsTxt
288
+ // ---------------------------------------------------------------------------
289
+
290
+ test('analyzeLlmsTxt: marks usable when it has link items', () => {
291
+ const body = '## Section\n- [Page](https://docs.example.com/page)'
292
+ const result = analyzeLlmsTxt(body, BASE)
293
+ assert.equal(result.usable, true)
294
+ assert.equal(result.reason, null)
295
+ })
296
+
297
+ test('analyzeLlmsTxt: marks not usable when no link items', () => {
298
+ const body = '# Just a title\n\nSome prose with no links.'
299
+ const result = analyzeLlmsTxt(body, BASE)
300
+ assert.equal(result.usable, false)
301
+ assert.ok(result.reason)
302
+ })
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createRequire } from "node:module";
5
+ import { fileURLToPath } from "node:url";
5
6
  import ora from "ora";
6
7
  import * as styles from "./styles.js";
7
8
  import { hasClaude } from "./claude.js";
@@ -177,7 +178,7 @@ export function createHumanReporter() {
177
178
  // When running inside Claude, output instructions and structured issue list.
178
179
  if (isRunningInClaude && unfixed.length > 0) {
179
180
  const cliRoot = path.join(
180
- path.dirname(new URL(import.meta.url).pathname),
181
+ path.dirname(fileURLToPath(import.meta.url)),
181
182
  "../..",
182
183
  );
183
184
 
@@ -0,0 +1,16 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * Canonical slug for a git-format page path. `index.md`/`index.mdx` carries
5
+ * its parent folder's slug (e.g. `docs/a/setup/index.md` → "setup", matching
6
+ * how gitto slugs a folder from its index page); any other file is its
7
+ * filename minus the extension. A root-level index file falls back to "index".
8
+ */
9
+ export function slugForEntry(relPath) {
10
+ const filename = path.basename(relPath);
11
+ if (filename === 'index.md' || filename === 'index.mdx') {
12
+ const parentDir = path.basename(path.dirname(relPath));
13
+ return parentDir && parentDir !== '.' && parentDir !== path.sep ? parentDir : 'index';
14
+ }
15
+ return filename.replace(/\.(md|mdx)$/, '');
16
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * URL segment parsing utilities shared across import.js pipeline stages.
3
+ * Centralised here so the single-extension-strip bug only lives in one place.
4
+ */
5
+
6
+ // Extensions that are implementation artifacts, not meaningful path segments.
7
+ const DOC_FILE_EXTENSIONS = ['md', 'mdx', 'html', 'htm', 'txt']
8
+ const DOC_EXT_RE = new RegExp(`(\\.(${DOC_FILE_EXTENSIONS.join('|')}))+$`, 'i')
9
+
10
+ /**
11
+ * Strip all chained doc-file extensions (.html.md, .html, .md, .mdx, .htm, .txt)
12
+ * from a single URL path segment. Handles the double-extension pattern used
13
+ * by some SSGs (e.g. `.html.md`).
14
+ */
15
+ export function stripSegmentExtensions(s) {
16
+ return s.replace(DOC_EXT_RE, '')
17
+ }
18
+
19
+ /**
20
+ * Parse a URL's pathname into segments suitable for trie nesting. Strips file
21
+ * extensions (including double-extensions like `.html.md`) and drops a trailing
22
+ * `index` segment so `/foo`, `/foo/`, `/foo/index.html`, `/foo/index.html.md`,
23
+ * and `/foo/index.md` all collapse to the same logical page.
24
+ * Returns null if the URL can't be parsed.
25
+ */
26
+ export function urlTrieSegs(url) {
27
+ try {
28
+ const segs = new URL(url).pathname
29
+ .split('/')
30
+ .filter(Boolean)
31
+ .map((s) => stripSegmentExtensions(s))
32
+ .filter(Boolean)
33
+ if (segs.length > 0 && segs[segs.length - 1].toLowerCase() === 'index') segs.pop()
34
+ return segs
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Extract URL path segments for slug planning. Strips file extensions
42
+ * (including double-extensions like `.html.md`) and leading numeric prefixes
43
+ * (e.g. `01-intro` → `intro`). Also drops a trailing `index` segment so
44
+ * `/foo/index.html.md` and `/foo/` both collapse to the same slug base.
45
+ */
46
+ export function extractUrlPathSegments(url) {
47
+ if (!url) return []
48
+ try {
49
+ const segs = new URL(url).pathname
50
+ .split('/')
51
+ .filter(Boolean)
52
+ .map((s) => stripSegmentExtensions(s).replace(/^\d+[-_.]/, ''))
53
+ .filter(Boolean)
54
+ if (segs.length > 0 && segs[segs.length - 1].toLowerCase() === 'index') segs.pop()
55
+ return segs
56
+ } catch {
57
+ return []
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Normalise a URL to a canonical path key for deduplication. Strips trailing
63
+ * slash, strips all chained doc-file extensions from the path tail, drops a
64
+ * trailing `index`, and lowercases — so `/foo/bar.html.md`, `/foo/bar.html`,
65
+ * `/foo/bar/index.html`, and `/foo/bar` all resolve to the same key `/foo/bar`.
66
+ */
67
+ export function normalizePath(url) {
68
+ try {
69
+ const u = new URL(url)
70
+ let p = u.pathname.replace(/\/$/, '').toLowerCase()
71
+ p = p.replace(DOC_EXT_RE, '')
72
+ p = p.replace(/\/index$/i, '')
73
+ return p || '/'
74
+ } catch {
75
+ return String(url).toLowerCase()
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Dedupe key for llms.txt page rows only.
81
+ *
82
+ * Invariants:
83
+ * - includes origin so same-path docs on different hosts stay distinct
84
+ * - collapses doc URL spellings for the same page tail (.md/.mdx/.html/.htm, /index, trailing slash)
85
+ * - preserves query params because some docs routers encode page state there
86
+ * - ignores fragments because they identify anchors within a page, not separate import pages
87
+ */
88
+ export function llmsPageDedupeKey(url) {
89
+ try {
90
+ const u = new URL(url)
91
+ let p = u.pathname.replace(/\/$/, '').toLowerCase()
92
+ p = p.replace(DOC_EXT_RE, '')
93
+ p = p.replace(/\/index$/i, '')
94
+
95
+ const params = [...u.searchParams.entries()].sort(([ak, av], [bk, bv]) => (ak === bk ? av.localeCompare(bv) : ak.localeCompare(bk)))
96
+ const query = params.length > 0 ? `?${params.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')}` : ''
97
+
98
+ return `${u.origin.toLowerCase()}${p || '/'}${query}`
99
+ } catch {
100
+ return String(url).toLowerCase()
101
+ }
102
+ }
103
+
104
+ export function compareCanonicalUrlPreference(a, b) {
105
+ const aRank = canonicalUrlPreferenceRank(a)
106
+ const bRank = canonicalUrlPreferenceRank(b)
107
+ if (aRank !== bRank) return aRank - bRank
108
+ return String(a || '').length - String(b || '').length
109
+ }
110
+
111
+ function canonicalUrlPreferenceRank(url) {
112
+ try {
113
+ const pathname = new URL(url).pathname
114
+ const lower = pathname.toLowerCase()
115
+ if (/\.md$/.test(lower)) return 0
116
+ if (/\.txt$/.test(lower)) return 1
117
+ if (!/\/$/.test(pathname) && !/\/index\.html?$/.test(lower) && !/\.[^/]+$/.test(lower)) return 2
118
+ if (/\/$/.test(pathname)) return 3
119
+ if (/\/index\.html?$/.test(lower)) return 4
120
+ return 5
121
+ } catch {
122
+ return 5
123
+ }
124
+ }