Files
upstream-tldraw/apps/docs/scripts/lib/checkBrokenLinks.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

313 lines
9.4 KiB
TypeScript

import { nicelog } from '@/utils/nicelog'
import { connect } from './connect'
export interface BrokenLink {
articlePath: string
line: number
url: string
reason: string
}
// Internal rewrites: source → destination (both are valid paths)
const INTERNAL_REWRITES: Record<string, string> = {
'/releases': '/getting-started/releases',
'/quick-start': '/getting-started/quick-start',
'/installation': '/getting-started/installation',
}
// Section and category paths are not pages: only article paths are generated
// (dynamicParams = false) and page.tsx 404s for anything else. These section roots are
// reachable only because next.config.js redirects them to an article.
const REDIRECTED_SECTION_PATHS = ['/docs', '/examples', '/reference', '/starter-kits']
// Path prefixes that are served by another system (e.g. the marketing site)
// rather than this Next.js app, so they will never appear in the docs DB.
// Matches the prefix as an exact path OR as a parent of a subpath:
// e.g. '/legal' matches '/legal' and '/legal/tldraw-license' but not '/legal-foo'.
const EXTERNAL_ROUTE_PREFIXES = [
'/accessibility',
'/blog',
'/careers',
'/company',
'/events',
'/faq',
'/features',
'/get-a-license',
'/legal',
'/partner',
'/pricing',
'/showcase',
'/thanks',
]
function isExternalRoute(urlPath: string): boolean {
return EXTERNAL_ROUTE_PREFIXES.some(
(prefix) => urlPath === prefix || urlPath.startsWith(prefix + '/')
)
}
/**
* Strip fenced code blocks from markdown, replacing them with blank lines
* to preserve line numbering for error reporting.
*/
export function stripCodeBlocks(content: string): string {
return content.replace(/^(`{3,})[^\n]*\n[\s\S]*?^\1\s*$/gm, (match) => {
// Replace with same number of newlines to preserve line count
return '\n'.repeat((match.match(/\n/g) || []).length)
})
}
// File extensions that indicate static assets (not page routes)
const STATIC_ASSET_EXTENSIONS =
/\.(png|jpe?g|gif|svg|webp|ico|pdf|mp4|webm|mp3|woff2?|ttf|eot|css|js|json|xml|txt|zip|tar|gz)$/i
/**
* Extract all links from markdown/MDX content.
* Returns array of { url, line } for every link found (internal and external).
*/
export function extractLinks(content: string): Array<{ url: string; line: number }> {
const stripped = stripCodeBlocks(content)
const lines = stripped.split('\n')
const links: Array<{ url: string; line: number }> = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNum = i + 1
// Standard markdown links: [text](url)
const mdLinkRegex = /\[[^\]]*\]\(([^)]*)\)/g
let match
while ((match = mdLinkRegex.exec(line)) !== null) {
const url = match[1].trim()
if (url) links.push({ url, line: lineNum })
}
// JSX href attributes: href="url"
const hrefRegex = /href="([^"]*)"/g
while ((match = hrefRegex.exec(line)) !== null) {
const url = match[1].trim()
if (url) links.push({ url, line: lineNum })
}
// CodeLinks JSON values: <CodeLinks links={{"Name":"/path"}}>
const codeLinksRegex = /<CodeLinks\s+links=\{\{([^}]+)\}\}/g
while ((match = codeLinksRegex.exec(line)) !== null) {
try {
const json = JSON.parse(`{${match[1]}}`)
for (const value of Object.values(json)) {
if (typeof value === 'string' && value) {
links.push({ url: value, line: lineNum })
}
}
} catch {
// Skip malformed JSON
}
}
}
return links
}
/**
* Extract internal links from markdown/MDX content.
* Returns array of { url, line } for each internal link found.
*/
function extractInternalLinks(content: string): Array<{ url: string; line: number }> {
return extractLinks(content).filter(({ url }) => url.startsWith('/'))
}
/**
* Try to resolve a path via redirect patterns from next.config.js.
* Returns the resolved destination path, or null if no redirect matches.
*/
function tryRedirect(urlPath: string): string | null {
// /examples/:cat/:id → /examples/:id
const exTwoParts = urlPath.match(/^\/examples\/[^/]+\/([^/]+)$/)
if (exTwoParts) return `/examples/${exTwoParts[1]}`
// /examples/:cat1/:cat2/:id → /examples/:id
const exThreeParts = urlPath.match(/^\/examples\/[^/]+\/[^/]+\/([^/]+)$/)
if (exThreeParts) return `/examples/${exThreeParts[1]}`
// /getting-started/:child → /:child
const gsMatch = urlPath.match(/^\/getting-started\/(.+)$/)
if (gsMatch) return `/${gsMatch[1]}`
return null
}
function anchorNotFoundReason(fragment: string, path: string, slugs: Set<string> | undefined) {
const reason = `anchor #${fragment} not found in ${path}`
const caseMismatch = slugs && [...slugs].find((s) => s.toLowerCase() === fragment.toLowerCase())
return caseMismatch ? `${reason} (heading ids are case-sensitive, use #${caseMismatch})` : reason
}
export async function checkBrokenLinks(): Promise<number> {
const db = await connect({ mode: 'readonly' })
// Build set of all valid paths
const validPaths = new Set<string>()
const articles = await db.all<{ path: string | null; content: string }[]>(
'SELECT path, content FROM articles'
)
for (const row of articles) {
if (row.path) validPaths.add(row.path)
}
for (const path of REDIRECTED_SECTION_PATHS) {
validPaths.add(path)
}
// Add internal rewrite sources (these are valid URLs that rewrite to DB paths)
for (const source of Object.keys(INTERNAL_REWRITES)) {
validPaths.add(source)
}
// Case-insensitive path set for fallback matching (handles macOS FS collisions)
const validPathsLower = new Set([...validPaths].map((p) => p.toLowerCase()))
// Build heading lookup: articlePath → Set<slug>
const headingMap = new Map<string, Set<string>>()
const headings = await db.all<{ slug: string; articleId: string }[]>(
'SELECT slug, articleId FROM headings'
)
// We need article paths keyed by article ID
const articlePathById = new Map<string, string>()
const allArticles = await db.all<{ id: string; path: string | null }[]>(
'SELECT id, path FROM articles'
)
for (const a of allArticles) {
if (a.path) articlePathById.set(a.id, a.path)
}
// Rendered heading ids keep their case (rehype-slug runs with `maintainCase: true`), so
// `#custom-shapes` is a dead anchor on a page whose heading id is `Custom-shapes` (#10256).
for (const h of headings) {
const articlePath = articlePathById.get(h.articleId)
if (!articlePath) continue
let slugs = headingMap.get(articlePath)
if (!slugs) {
slugs = new Set()
headingMap.set(articlePath, slugs)
}
slugs.add(h.slug)
// Also index by lowercase path for case-insensitive path lookups
const lowerPath = articlePath.toLowerCase()
let lowerSlugs = headingMap.get(lowerPath)
if (!lowerSlugs) {
lowerSlugs = new Set()
headingMap.set(lowerPath, lowerSlugs)
}
lowerSlugs.add(h.slug)
}
// Also build heading map for rewrite destinations
for (const [source, dest] of Object.entries(INTERNAL_REWRITES)) {
const slugs = headingMap.get(dest)
if (slugs) {
headingMap.set(source, slugs)
}
}
// Check all articles for broken links
const broken: BrokenLink[] = []
for (const article of articles) {
if (!article.path) continue
const links = extractInternalLinks(article.content)
for (const { url, line } of links) {
// Skip static asset paths (images, fonts, etc.)
if (STATIC_ASSET_EXTENSIONS.test(url)) continue
// Split path and fragment
const hashIdx = url.indexOf('#')
const urlPath = hashIdx >= 0 ? url.slice(0, hashIdx) : url
const fragment = hashIdx >= 0 ? url.slice(hashIdx + 1) : null
// Skip paths served by an external system (marketing site etc.)
// — they will never appear in the docs DB but are still valid in production.
if (isExternalRoute(urlPath)) continue
// Empty path with fragment = same-page anchor
if (urlPath === '' && fragment) {
const slugs = headingMap.get(article.path)
if (!slugs || !slugs.has(fragment)) {
broken.push({
articlePath: article.path,
line,
url,
reason: anchorNotFoundReason(fragment, article.path, slugs),
})
}
continue
}
// Check if path is valid
let pathValid = false
let resolvedPath = urlPath
if (validPaths.has(urlPath)) {
pathValid = true
} else if (validPathsLower.has(urlPath.toLowerCase())) {
// Case-insensitive fallback (handles macOS FS collisions
// where e.g. Atom.mdx and atom.mdx map to the same file)
pathValid = true
} else {
// Try redirect resolution
const redirected = tryRedirect(urlPath)
if (
redirected &&
(validPaths.has(redirected) || validPathsLower.has(redirected.toLowerCase()))
) {
pathValid = true
resolvedPath = redirected
}
}
if (!pathValid) {
broken.push({
articlePath: article.path,
line,
url,
reason: 'page not found',
})
continue
}
// Check fragment if present
if (fragment) {
// Resolve internal rewrites for heading lookup
const lookupPath = INTERNAL_REWRITES[resolvedPath] || resolvedPath
const slugs = headingMap.get(lookupPath) || headingMap.get(lookupPath.toLowerCase())
if (!slugs || !slugs.has(fragment)) {
broken.push({
articlePath: article.path,
line,
url,
reason: anchorNotFoundReason(fragment, resolvedPath, slugs),
})
}
}
}
}
if (broken.length > 0) {
nicelog(
`\n✗ Found ${broken.length} broken link${broken.length === 1 ? '' : 's'}:\n\n` +
broken
.map(
(b, i) =>
`${i + 1}.\t${b.url}\n\tIn: ${b.articlePath} (line ${b.line})\n\tReason: ${b.reason}`
)
.join('\n\n') +
'\n'
)
}
await db.close()
return broken.length
}