ReviewOS

also looking at this

stacks/bunpress

fix: name the document a refused Markdown link is in, and report all of them

#95
Open glennmichael123 wants to merge fix/collect-every-refused-link into main
3 files +195 -7
test/markdown-source-links.test.tsmodified+78-0
Changes to test/markdown-source-links.test.ts
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
44import { tmpdir } from 'node:os'
55import { dirname, join } from 'node:path'
66import { buildDocs } from '../packages/bunpress/bin/cli'
7import { createMarkdownPageCatalog, describeLinkFailures, findMarkdownFiles } from '../packages/bunpress/src/markdown-pages'
78import { decodeHtmlEntities, startServer } from '../packages/bunpress/src/serve'
89
910const temporary: string[] = []
@@ -131,6 +132,83 @@ test('a missing linked source refuses rebuild without replacing the previous com
131132 finally { stop() }
132133}, 30000)
133134
135test('a refused link names the document it was authored in', async () => {
136 const f = await fixture()
137 await writeFile(join(f.docs, 'guides/start.md'), '# Start\n\n[gone](../adr/absent.md)\n')
138 const files = await findMarkdownFiles(f.docs)
139 const catalog = createMarkdownPageCatalog(files, f.docs, { enabled: false, locales: ['en'], defaultLocale: 'en' } as never)
140 const page = catalog.findPage('en', '/guides/start')!
141 expect(page).toBeDefined()
142
143 // The destination is the part that is missing, so naming it alone leaves the
144 // reader grepping for whichever of their documents holds the link. The path
145 // is relative to the documentation root: an absolute temporary directory is
146 // noise in a build log.
147 expect(() => catalog.resolveLink(page, '../adr/absent.md')).toThrow('guides/start.md: Markdown link has no documentation source: ../adr/absent.md')
148}, 30000)
149
150test('a refused link inside an include names the included file, not its host', async () => {
151 const f = await fixture()
152 await mkdir(join(f.docs, 'parts'), { recursive: true })
153 await writeFile(join(f.docs, 'parts/snippet.md'), '[gone](./absent.md)\n')
154 const files = await findMarkdownFiles(f.docs)
155 const catalog = createMarkdownPageCatalog(files, f.docs, { enabled: false, locales: ['en'], defaultLocale: 'en' } as never)
156 const included = catalog.findPage('en', '/parts/snippet')!
157
158 // `rewriteMarkdownSourceLinks` hands `resolveLink` the innermost origin on
159 // its include stack, so the file named here is where the link was written
160 // rather than whichever document pulled it in. That is the one a person has
161 // to open, and the two differ exactly when the message matters most.
162 expect(() => catalog.resolveLink(included, './absent.md')).toThrow('parts/snippet.md: Markdown link has no documentation source: ./absent.md')
163}, 30000)
164
165test('one build reports every refused link, not the first', async () => {
166 const f = await fixture()
167 await writeFile(join(f.docs, 'guides/start.md'), '# Start\n\n[one](./absent-one.md) and [again](./absent-one.md)\n')
168 await writeFile(join(f.docs, 'guides/second.md'), '# Second\n\n[two](./absent-two.md)\n')
169
170 const reported: string[] = []
171 const error = console.error
172 console.error = (...parts: unknown[]) => { reported.push(parts.map(String).join(' ')) }
173 try {
174 expect(await buildDocs({ dir: f.docs, outdir: f.out, config: f.configFile, searchIndex: false })).toBe(false)
175 }
176 finally { console.error = error }
177
178 // Both documents in a single run. Throwing on the first meant a set with N
179 // dead links took N builds, each one a full build, with no way to know how
180 // many were left until the last passed.
181 const text = reported.join('\n')
182 expect(text).toContain('3 Markdown links could not be resolved')
183 expect(text).toContain('guides/start.md')
184 expect(text).toContain('./absent-one.md')
185 expect(text).toContain('guides/second.md')
186 expect(text).toContain('./absent-two.md')
187}, 30000)
188
189test('a destination repeated in one document is counted rather than repeated', () => {
190 // The real case this came from was three links to one missing file in a
191 // single document. Listed one per line they are three identical rows, since
192 // the authored line number is already gone by the time links resolve.
193 const summary = describeLinkFailures([
194 { sourceFile: 'todo/12-api.md', destination: './09-ci-cd.md', reason: 'no-documentation-source' },
195 { sourceFile: 'todo/12-api.md', destination: './09-ci-cd.md', reason: 'no-documentation-source' },
196 { sourceFile: 'todo/12-api.md', destination: './09-ci-cd.md', reason: 'no-documentation-source' },
197 { sourceFile: 'todo/13-mirror.md', destination: './gone.md', reason: 'no-rendered-page' },
198 ])
199
200 expect(summary).toContain('4 Markdown links could not be resolved')
201 expect(summary).toContain('todo/12-api.md ./09-ci-cd.md (no documentation source) (3 times)')
202 expect(summary).toContain('todo/13-mirror.md ./gone.md (no rendered page)')
203 expect(summary.split('\n')).toHaveLength(3)
204})
205
206test('a single refused link still reads as one', () => {
207 expect(describeLinkFailures([
208 { sourceFile: 'guides/start.md', destination: './absent.md', reason: 'no-documentation-source' },
209 ])).toStartWith('1 Markdown link could not be resolved:')
210})
211
134212test('canonical output collisions refuse publication', async () => {
135213 const f = await fixture()
136214 await writeFile(join(f.docs, 'guides.md'), '# Ambiguous guides\n')