Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/generate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,31 @@ jobs:
commit_user_name: ${{ secrets.GIT_USER_NAME }}
commit_user_email: ${{ secrets.GIT_USER_EMAIL }}
commit_author: ${{ secrets.GIT_USER_NAME }} <${{ secrets.GIT_USER_EMAIL }}>
validate-paths:
name: Validate paths
needs: commit
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Setup
uses: ./.github/actions/setup
- name: Validate SUMMARY.md paths
run: npm run validate-paths
validate-links:
name: Validate links
needs: commit
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Setup
uses: ./.github/actions/setup
- name: Validate cross-site links
run: npm run validate-links
40 changes: 33 additions & 7 deletions codegen/lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,43 @@
import { join } from 'node:path'

// Site section roots (paths from repo root)
export const apiReferenceRoot = join('docs', 'api-reference')
export const guidesRoot = join('docs', 'guides')

// The published URL prefix for the API Reference site section on docs.seam.co.
export const apiReferenceUrlPrefix = '/api'

// The base URL for the published documentation site.
export const baseUrl = 'https://docs.seam.co/latest/'

// The URL prefix to strip when resolving absolute URLs to file paths.
export const siteUrlPrefix = '/latest'

// Each site section is a GitBook site section with its own SUMMARY.md.
export interface SiteSection {
// Display name for logging/errors.
name: string
// Path from repo root to the section's content directory.
root: string
// Published URL prefix (e.g., '/api'). Empty string for the default section.
urlPrefix: string
}

// Order matters: more-specific prefixes must come first so that URL matching
// picks the longest prefix (e.g., '/api' before the catch-all '').
export const siteSections: SiteSection[] = [
{
name: 'API Reference',
root: join('docs', 'api-reference'),
urlPrefix: '/api',
},
{ name: 'Guides', root: join('docs', 'guides'), urlPrefix: '' },
]

// Convenience accessors (used by codegen)
const guides = siteSections.find((s) => s.name === 'Guides')
const apiReference = siteSections.find((s) => s.name === 'API Reference')

if (guides == null || apiReference == null) {
throw new Error('Missing required site section in config')
}

export const guidesRoot = guides.root
export const apiReferenceRoot = apiReference.root
export const apiReferenceUrlPrefix = apiReference.urlPrefix

// Derived paths
export const apiReferenceSummaryPath = join(apiReferenceRoot, 'SUMMARY.md')
156 changes: 156 additions & 0 deletions codegen/validate-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'

import { baseUrl, siteSections, siteUrlPrefix } from './lib/config.js'

const absoluteUrlPattern = new RegExp(
`${baseUrl.replaceAll('.', '\\.')}[^)\\s]+`,
'g',
)

// Matches markdown links like [text](path) but not images ![text](path),
// absolute URLs, anchors-only, GitBook template tags, or angle-bracket paths.
const relativeLinkPattern =
/(?<!!)\]\((?!https?:\/\/|mailto:|#|{%|<|cursor:|file:)([^)]+)\)/g

interface BrokenLink {
file: string
line: number
url: string
reason: string
}

const brokenLinks: BrokenLink[] = []

function walkDir(dir: string): string[] {
const files: string[] = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
files.push(...walkDir(fullPath))
} else if (entry.name.endsWith('.md')) {
files.push(fullPath)
}
}
return files
}

function checkAbsoluteUrl(file: string, line: number, rawUrl: string): void {
const cleanUrl = rawUrl.replaceAll('\\', '')
const url = new URL(cleanUrl)
url.pathname = url.pathname.replace(siteUrlPrefix, '')

const pathname = url.pathname

const section = siteSections.find(({ urlPrefix }) =>
urlPrefix === ''
? true
: pathname.startsWith(urlPrefix + '/') || pathname === urlPrefix,
)

if (section == null) {
brokenLinks.push({
file,
line,
url: rawUrl,
reason: 'No matching site section',
})
return
}

const pagePath = pathname.replace(section.urlPrefix, '')
const targetRoot = join(section.root, pagePath)

const targetMd = `${targetRoot}.md`
const targetReadme = join(targetRoot, 'README.md')

if (!existsSync(targetMd) && !existsSync(targetReadme)) {
brokenLinks.push({
file,
line,
url: rawUrl,
reason: `File not found: ${targetMd} or ${targetReadme}`,
})
}
}

function checkRelativeLink(file: string, line: number, rawLink: string): void {
// Strip GitBook "mention" hint and anchor
const linkPath = rawLink.replace(/ "mention"$/, '').split('#')[0]
if (linkPath == null || linkPath === '') return

// Skip asset references
if (linkPath.includes('.gitbook/assets')) return

// Strip GitBook markdown escapes and decode URL encoding
const cleanPath = decodeURIComponent(
linkPath.replaceAll('\\(', '(').replaceAll('\\)', ')').replaceAll('\\', ''),
)

const fileDir = dirname(file)
const resolved = resolve(fileDir, cleanPath)

// Check if target exists as file, as README.md in directory, or as directory
if (!existsSync(resolved) && !existsSync(join(resolved, 'README.md'))) {
brokenLinks.push({
file,
line,
url: rawLink,
reason: `File not found: ${resolved}`,
})
}
}

const files = walkDir('docs')

for (const file of files) {
const contents = readFileSync(file, 'utf-8')
const lines = contents.split('\n')

for (let i = 0; i < lines.length; i++) {
const lineText = lines[i]
if (lineText == null) continue

// Check absolute docs.seam.co URLs
for (const match of lineText.matchAll(absoluteUrlPattern)) {
const rawUrl = match[0].replace(/[).,]*$/, '')
checkAbsoluteUrl(file, i + 1, rawUrl)
}

// Check relative links
for (const match of lineText.matchAll(relativeLinkPattern)) {
const rawLink = match[1]
if (rawLink == null) continue
checkRelativeLink(file, i + 1, rawLink)
}
}
}

if (brokenLinks.length > 0) {
// Group by broken target
const groups = new Map<string, Array<{ file: string; line: number }>>()
for (const { file, line, url } of brokenLinks) {
const existing = groups.get(url) ?? []
existing.push({ file, line })
groups.set(url, existing)
}

// eslint-disable-next-line no-console
console.error(
`Found ${brokenLinks.length} broken link(s) across ${groups.size} unique target(s):\n`,
)
for (const [target, sources] of groups) {
// eslint-disable-next-line no-console
console.error(` ${target}`)
for (const { file, line } of sources) {
// eslint-disable-next-line no-console
console.error(` - ${file}:${line}`)
}
// eslint-disable-next-line no-console
console.error('')
}
process.exit(1)
} else {
// eslint-disable-next-line no-console
console.log('All links are valid.')
}
101 changes: 101 additions & 0 deletions codegen/validate-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'

import { siteSections } from './lib/config.js'

// Matches markdown links in SUMMARY.md: * [Title](path/to/file.md)
const summaryLinkPattern = /\[([^\]]*)\]\(([^)]+)\)/g

// Matches ## headings that define groups in SUMMARY.md
const groupHeadingPattern = /^## (.+)$/

function slugify(heading: string): string {
return heading
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
}

interface PathMismatch {
section: string
line: number
title: string
path: string
reason: string
}

const mismatches: PathMismatch[] = []

for (const section of siteSections) {
const summaryPath = join(section.root, 'SUMMARY.md')
if (!existsSync(summaryPath)) continue

const contents = readFileSync(summaryPath, 'utf-8')
const lines = contents.split('\n')

let currentGroup: string | null = null

for (let i = 0; i < lines.length; i++) {
const lineText = lines[i]
if (lineText == null) continue

// Track current ## group heading
const headingMatch = lineText.match(groupHeadingPattern)
if (headingMatch?.[1] != null) {
currentGroup = slugify(headingMatch[1])
continue
}

for (const match of lineText.matchAll(summaryLinkPattern)) {
const title = match[1] ?? ''
const linkPath = match[2] ?? ''

// Skip external links and anchors
if (linkPath.startsWith('http') || linkPath.startsWith('#')) continue

const fullPath = join(section.root, linkPath)

// Check 1: file exists
if (!existsSync(fullPath)) {
mismatches.push({
section: section.name,
line: i + 1,
title,
path: linkPath,
reason: `File not found: ${fullPath}`,
})
continue
}

// Check 2: file path starts with the group slug
if (currentGroup != null && !linkPath.startsWith(currentGroup + '/')) {
mismatches.push({
section: section.name,
line: i + 1,
title,
path: linkPath,
reason: `Path should start with "${currentGroup}/" (listed under "## ${currentGroup}")`,
})
}
}
}
}

if (mismatches.length > 0) {
// eslint-disable-next-line no-console
console.error(`Found ${mismatches.length} SUMMARY.md path issue(s):\n`)
for (const { section, line, title, path, reason } of mismatches) {
// eslint-disable-next-line no-console
console.error(` [${section}] line ${line}: "${title}"`)
// eslint-disable-next-line no-console
console.error(` ${path}`)
// eslint-disable-next-line no-console
console.error(` ${reason}\n`)
}
process.exit(1)
} else {
// eslint-disable-next-line no-console
console.log('All SUMMARY.md paths are valid and consistent.')
}
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"scripts": {
"generate": "tsx codegen/smith.ts",
"postgenerate": "npm run format",
"validate-paths": "tsx codegen/validate-paths.ts",
"validate-links": "tsx codegen/validate-links.ts",
"typecheck": "tsc",
"lint": "eslint .",
"postlint": "prettier --check --ignore-path .prettierignore .",
Expand Down
Loading