@readme/cli 0.0.31 → 0.2.0

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 (54) hide show
  1. package/README.md +10 -0
  2. package/bin/readme.js +1 -1
  3. package/package.json +34 -24
  4. package/src/bootstrap.js +9 -13
  5. package/src/cli.js +17 -11
  6. package/src/commands/components.js +35 -18
  7. package/src/commands/dev.js +12 -8
  8. package/src/commands/eyes.js +12 -8
  9. package/src/commands/import.js +2263 -2066
  10. package/src/commands/lint.js +33 -29
  11. package/src/commands/oas-bundle.js +89 -0
  12. package/src/commands/oas-sync.js +96 -93
  13. package/src/commands/oas-validate.js +10 -7
  14. package/src/commands/pretty.js +177 -164
  15. package/src/commands/setup.js +24 -26
  16. package/src/commands/versions.js +6 -2
  17. package/src/dev/app/[...slug]/page.js +1 -0
  18. package/src/dev/app/layout.js +18 -11
  19. package/src/dev/app/page.js +1 -0
  20. package/src/dev/lib/docs.js +27 -19
  21. package/src/dev/next.config.mjs +2 -2
  22. package/src/gha.js +7 -7
  23. package/src/prompts/index.js +16 -6
  24. package/src/utils/eyes.js +143 -51
  25. package/src/utils/git.js +3 -3
  26. package/src/utils/lint.js +39 -38
  27. package/src/utils/llms.js +94 -91
  28. package/src/utils/markdown.js +256 -0
  29. package/src/utils/marketplace.js +12 -5
  30. package/src/utils/multi-spinner.js +5 -7
  31. package/src/utils/promise-pool.js +7 -5
  32. package/src/utils/reporter.js +88 -91
  33. package/src/utils/setup-templates.js +12 -6
  34. package/src/utils/styles.js +2 -1
  35. package/src/utils/tamagotchi.js +257 -91
  36. package/src/utils/tips.js +18 -9
  37. package/src/utils/url-segs.js +46 -41
  38. package/src/validators/components.js +55 -63
  39. package/src/validators/content.js +5 -10
  40. package/src/validators/duplicates.js +6 -2
  41. package/src/validators/frontmatter.js +19 -21
  42. package/src/validators/links.js +4 -8
  43. package/src/validators/oas-reference.js +7 -14
  44. package/src/validators/oas-schema.js +2 -0
  45. package/src/validators/ordering.js +5 -5
  46. package/src/validators/recipes.js +4 -12
  47. package/src/commands/import-oas.test.js +0 -277
  48. package/src/commands/import.classify.test.js +0 -172
  49. package/src/commands/import.landing.test.js +0 -217
  50. package/src/commands/import.slug-planner.test.js +0 -86
  51. package/src/commands/import.staging.test.js +0 -158
  52. package/src/commands/import.test.js +0 -382
  53. package/src/utils/llms.test.js +0 -302
  54. package/src/utils/url-segs.test.js +0 -239
@@ -1,302 +0,0 @@
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
- })
@@ -1,239 +0,0 @@
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
- })