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
packages/bunpress/src/markdown-pages.tsmodified+92-5
Changes to packages/bunpress/src/markdown-pages.ts
@@ -13,12 +13,32 @@ export interface MarkdownPage {
1313export interface MarkdownPageCatalog {
1414 readonly pages: readonly MarkdownPage[]
1515 findPage: (locale: string, path: string) => MarkdownPage | undefined
16 resolveLink: (page: MarkdownPage, destination: string) => string
16 resolveLink: (page: MarkdownPage, destination: string, collect?: MarkdownLinkCollector) => string
1717}
1818
19/** A link that named no document, reported rather than thrown. */
20export interface MarkdownLinkFailure {
21 /** The authoring document, relative to the documentation root. */
22 readonly sourceFile: string
23 /** The destination exactly as it was written. */
24 readonly destination: string
25 readonly reason: 'no-documentation-source' | 'no-rendered-page'
26}
27
28export type MarkdownLinkCollector = (failure: MarkdownLinkFailure) => void
29
1930export interface MarkdownPageContext {
2031 readonly page: MarkdownPage
2132 readonly catalog: MarkdownPageCatalog
33 /**
34 * Where to put a refused link instead of throwing on the first one.
35 *
36 * Absent by default, which keeps the single-page callers failing fast: the
37 * dev server renders one page on request and has no later point to report
38 * at, so a throw is the whole answer there. A build renders every page and
39 * can say how many are broken, which is the difference this exists for.
40 */
41 readonly collectLinkFailures?: MarkdownLinkCollector
2242}
2343
2444/** Per-render boundaries for authored content expanded from another file. */
@@ -40,6 +60,22 @@ function routeFor(path: string): string {
4060 return `/${encodePath(route)}`
4161}
4262
63/**
64 * The document a link was authored in, for a message somebody has to act on.
65 *
66 * Both link failures below name the destination, which is not enough to find
67 * the link: the destination is what is missing, and the file holding it is
68 * what has to be edited. Relative to the documentation root, because an
69 * absolute temporary path is noise in a build log.
70 *
71 * Under an include, `resolveLink` is handed the innermost origin rather than
72 * the host page, so this names the file the link was actually written in.
73 */
74function authoredIn(page: MarkdownPage, root: string): string {
75 const within = relative(root, page.sourceFile)
76 return within && !within.startsWith('..') && !isAbsolute(within) ? within.split(sep).join('/') : page.sourceFile
77}
78
4379function routeKey(path: string): string {
4480 const encoded = path.split('/').map(segment => encodeURIComponent(decodeURIComponent(segment))).join('/')
4581 return encoded.replace(/\/+$/, '') || '/'
@@ -100,13 +136,32 @@ export function createMarkdownPageCatalog(markdownFiles: readonly string[], docs
100136 }
101137 }
102138
139 /**
140 * Report the link, or throw on it when nobody is collecting.
141 *
142 * Returning the destination untouched is safe precisely because a collecting
143 * caller does not publish: the build throws once it has rendered everything,
144 * so this HTML is discarded. Leaving it alone rather than substituting a
145 * placeholder also keeps the failing page readable while it is inspected.
146 */
147 function refuse(page: MarkdownPage, rawPath: string, destination: string, reason: MarkdownLinkFailure['reason'], collect?: MarkdownLinkCollector): string {
148 const sourceFile = authoredIn(page, root)
149 const message = reason === 'no-documentation-source'
150 ? `${sourceFile}: Markdown link has no documentation source: ${rawPath}`
151 : `${sourceFile}: Markdown link has no rendered page: ${rawPath}`
152 if (!collect)
153 throw new Error(message)
154 collect({ sourceFile, destination: rawPath, reason })
155 return destination
156 }
157
103158 return Object.freeze({
104159 pages: Object.freeze(pages),
105160 findPage(locale: string, path: string): MarkdownPage | undefined {
106161 const key = `${locale}\0${routeKey(path)}`
107162 return byRoute.get(key) ?? aliases.get(key)
108163 },
109 resolveLink(page: MarkdownPage, destination: string): string {
164 resolveLink(page: MarkdownPage, destination: string, collect?: MarkdownLinkCollector): string {
110165 if (!destination || /^(?:[a-z][a-z\d+.-]*:|\/|#|\?)/i.test(destination))
111166 return destination
112167 const boundary = destination.search(/[?#]/)
@@ -124,16 +179,48 @@ export function createMarkdownPageCatalog(markdownFiles: readonly string[], docs
124179 return destination
125180 const source = sources.get(target)
126181 if (!source)
127 throw new Error(`Markdown link has no documentation source: ${rawPath}`)
182 return refuse(page, rawPath, destination, 'no-documentation-source', collect)
128183 const locale = source.explicitLocale ? source.locale : page.locale
129184 const selected = byPage.get(`${locale}\0${source.path}`)
130185 if (!selected)
131 throw new Error(`Markdown link has no rendered page: ${rawPath}`)
186 return refuse(page, rawPath, destination, 'no-rendered-page', collect)
132187 return `${localizeUrl(language, locale, routeFor(selected.relativePath))}${suffix}`
133188 },
134189 })
135190}
136191
192/**
193 * One message for every refused link, in the order they were rendered.
194 *
195 * Grouped by document and destination with a count, because the same dead
196 * destination written three times in one file is three identical lines
197 * otherwise. There is no line number: by the time links are resolved the
198 * markdown has already become HTML, so the authored position is gone, and a
199 * fabricated one would be worse than none.
200 */
201export function describeLinkFailures(failures: readonly MarkdownLinkFailure[]): string {
202 const groups = new Map<string, { failure: MarkdownLinkFailure, count: number }>()
203 for (const failure of failures) {
204 const key = `${failure.sourceFile}\0${failure.destination}\0${failure.reason}`
205 const seen = groups.get(key)
206 if (seen)
207 seen.count += 1
208 else
209 groups.set(key, { failure, count: 1 })
210 }
211
212 const lines = [...groups.values()].map(({ failure, count }) => {
213 const reason = failure.reason === 'no-documentation-source' ? 'no documentation source' : 'no rendered page'
214 const times = count > 1 ? ` (${count} times)` : ''
215 return ` ${failure.sourceFile} ${failure.destination} (${reason})${times}`
216 })
217
218 const total = failures.length
219 const heading = total === 1 ? '1 Markdown link could not be resolved:' : `${total} Markdown links could not be resolved:`
220
221 return [heading, ...lines].join('\n')
222}
223
137224/** Parse actual anchor attributes; code text, images and external destinations stay intact. */
138225export async function rewriteMarkdownSourceLinks(html: string, context: MarkdownPageContext, origins: MarkdownLinkOrigins = new Map()): Promise<string> {
139226 const stack = [context.page]
@@ -158,7 +245,7 @@ export async function rewriteMarkdownSourceLinks(html: string, context: Markdown
158245 }).on('a[href]', {
159246 element(element) {
160247 const href = decodeHtmlEntities(element.getAttribute('href')!)
161 const destination = context.catalog.resolveLink(stack[stack.length - 1], href)
248 const destination = context.catalog.resolveLink(stack[stack.length - 1], href, context.collectLinkFailures)
162249 if (destination !== href)
163250 element.setAttribute('href', escapeHtmlAttribute(destination))
164251 },