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
313 lines
9.4 KiB
TypeScript
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
|
|
}
|