also looking at this
fix: name the document a refused Markdown link is in, and report all of them
#95
3 files
+195
-7
| @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' | ||
| 4 | 4 | import { tmpdir } from 'node:os' |
| 5 | 5 | import { dirname, join } from 'node:path' |
| 6 | 6 | import { buildDocs } from '../packages/bunpress/bin/cli' |
| 7 | import { createMarkdownPageCatalog, describeLinkFailures, findMarkdownFiles } from '../packages/bunpress/src/markdown-pages' | |
| 7 | 8 | import { decodeHtmlEntities, startServer } from '../packages/bunpress/src/serve' |
| 8 | 9 | |
| 9 | 10 | const temporary: string[] = [] |
| @@ -131,6 +132,83 @@ test('a missing linked source refuses rebuild without replacing the previous com | ||
| 131 | 132 | finally { stop() } |
| 132 | 133 | }, 30000) |
| 133 | 134 | |
| 135 | test('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 | ||
| 150 | test('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 | ||
| 165 | test('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 | ||
| 189 | test('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 | ||
| 206 | test('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 | ||
| 134 | 212 | test('canonical output collisions refuse publication', async () => { |
| 135 | 213 | const f = await fixture() |
| 136 | 214 | await writeFile(join(f.docs, 'guides.md'), '# Ambiguous guides\n') |