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
3 changes: 3 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ jobs:
- name: Check types
run: pnpm run check:types

- name: Check internal anchors
run: pnpm run check:anchors

- name: Test
run: pnpm run test

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"dev": "node --env-file-if-exists=.env --import=tsx node_modules/vite/bin/vite.js",
"build": "node scripts/guard-vocs-config.mjs && pnpm run check:types && vite build && node --experimental-strip-types scripts/finalize-sitemap.ts",
"check": "biome check --write --unsafe .",
"check:anchors": "node --experimental-strip-types scripts/check-internal-anchors.ts",
"check:markdown": "node scripts/check-markdown-components.mjs",
"check:types": "tsgo --project tsconfig.json --noEmit",
"preview": "node dist/preview.js",
Expand Down Expand Up @@ -80,6 +81,7 @@
"@typescript/native-preview": "7.0.0-dev.20260624.1",
"@vitejs/plugin-react": "^6.0.3",
"anser": "^2.3.5",
"github-slugger": "2.0.0",
"remark-stringify": "^11.0.0",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions scripts/check-internal-anchors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import path from 'node:path'
import { checkInternalAnchors } from '../src/lib/internal-anchor-links.ts'

const result = checkInternalAnchors(path.resolve('src/pages'))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include redirect fragments in anchor validation

Because the command passes only src/pages to the checker, fragment-bearing redirect destinations in src/lib/docs-routing.ts and vocs.config.ts never become link events. Existing mappings such as docs-routing.ts:30 and vocs.config.ts:1281 can therefore point at a renamed heading indefinitely while this required check passes, silently breaking the legacy deep links those redirects preserve; include redirect destinations in the anchor candidates and validate their fragments against the canonical pages.

AGENTS.md reference: AGENTS.md:L9-L10

Useful? React with 👍 / 👎.


if (result.failures.length > 0) {
console.error('Broken internal anchor links:')
for (const failure of result.failures) {
console.error(
`- ${path.relative(process.cwd(), failure.file)}:${failure.line} ${failure.href} (missing #${failure.fragment} on ${failure.targetRoute})`,
)
}
process.exit(1)
}

console.log(
`Internal anchor check passed: ${result.linksChecked} links across ${result.pagesChecked} pages.`,
)
94 changes: 94 additions & 0 deletions src/lib/internal-anchor-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { checkInternalAnchors } from './internal-anchor-links'

const temporaryDirectories: string[] = []

afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true })
}
})

function pages(files: Record<string, string>): string {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'internal-anchor-links-'))
temporaryDirectories.push(directory)
for (const [file, content] of Object.entries(files)) {
const target = path.join(directory, file)
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, content)
}
return directory
}

describe('internal anchor links', () => {
it('accepts generated, duplicate, and JSX anchors', () => {
const directory = pages({
'docs/index.mdx': `
[Generated](/docs/target#hello-world)
[Duplicate](/docs/target#repeat-1)
<Card to="/docs/target#custom-id" />
`,
'docs/target.mdx': `
## Hello, world!
## Repeat
## Repeat
<span id="custom-id" />
`,
})

expect(checkInternalAnchors(directory)).toMatchObject({ linksChecked: 3, failures: [] })
})

it('reports stale Markdown and JSX fragments with source locations', () => {
const directory = pages({
'docs/index.mdx': '[Old](/docs/target#old-heading)\n<Card to="/docs/target#also-old" />\n',
'docs/target.mdx': '## New heading\n',
})

expect(checkInternalAnchors(directory).failures).toEqual([
expect.objectContaining({ line: 1, href: '/docs/target#old-heading' }),
expect.objectContaining({ line: 2, href: '/docs/target#also-old' }),
])
})

it('expands imported MDX snippets at their render position', () => {
const directory = pages({
'docs/index.mdx': '[Imported heading](/docs/target#imported-heading)\n',
'docs/target.mdx': `
import Shared from '../snippets/shared.mdx'

# Target

<Shared />
`,
'snippets/shared.mdx': '### Imported heading\n',
})

expect(checkInternalAnchors(directory).failures).toEqual([])
})

it('resolves same-page, relative, and developers-mounted links', () => {
const directory = pages({
'docs/guide/index.mdx': `
# Guide
[Same](#guide)
[Relative](./target#target)
[Mounted](/developers/docs/guide/target#target)
`,
'docs/guide/target.mdx': '# Target\n',
})

expect(checkInternalAnchors(directory)).toMatchObject({ linksChecked: 3, failures: [] })
})

it('leaves missing pages to the Vocs dead-link check', () => {
const directory = pages({
'docs/index.mdx': '[Missing](/docs/not-a-page#heading)\n',
})

expect(checkInternalAnchors(directory)).toMatchObject({ linksChecked: 0, failures: [] })
})
})
230 changes: 230 additions & 0 deletions src/lib/internal-anchor-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import fs from 'node:fs'
import path from 'node:path'
import GithubSlugger from 'github-slugger'
import remarkMdx from 'remark-mdx'
import remarkParse from 'remark-parse'
import { unified } from 'unified'

type AstNode = {
type: string
name?: string | null
value?: unknown
url?: string
alt?: string | null
attributes?: Array<{ type: string; name: string; value?: unknown }>
children?: AstNode[]
position?: { start: { line: number } }
}

type SourceEvent =
| { type: 'heading'; file: string; line: number; text: string }
| { type: 'anchor'; file: string; line: number; id: string }
| { type: 'link'; file: string; line: number; href: string }

type Page = {
file: string
route: string
routeDirectory: string
}

export type BrokenInternalAnchor = {
file: string
line: number
href: string
targetRoute: string
fragment: string
}

export type InternalAnchorCheck = {
pagesChecked: number
linksChecked: number
failures: BrokenInternalAnchor[]
}

const markdownExtensions = new Set(['.md', '.mdx'])

function markdownFiles(directory: string): string[] {
return fs
.readdirSync(directory, { withFileTypes: true })
.flatMap((entry) => {
const file = path.join(directory, entry.name)
if (entry.isDirectory()) return markdownFiles(file)
return entry.isFile() && markdownExtensions.has(path.extname(file)) ? [file] : []
})
.sort()
}

function pageFor(file: string, pagesDirectory: string): Page {
const relativeFile = path.relative(pagesDirectory, file).split(path.sep).join('/')
const withoutExtension = relativeFile.replace(/\.(?:md|mdx)$/, '')
const isIndex = withoutExtension === 'index' || withoutExtension.endsWith('/index')
const route = normalizeRoute(`/${withoutExtension.replace(/(?:^|\/)index$/, '')}`)

return {
file,
route,
routeDirectory: isIndex ? route : path.posix.dirname(route),
}
}

function normalizeRoute(route: string): string {
const withoutExtension = route.replace(/\.(?:md|mdx)$/, '')
const withoutIndex = withoutExtension.replace(/\/index$/, '')
if (withoutIndex === '/') return '/'
return withoutIndex.replace(/\/$/, '')
}

function nodeText(node: AstNode): string {
if ((node.type === 'text' || node.type === 'inlineCode') && typeof node.value === 'string') {
return node.value
}
if (node.type === 'image' && typeof node.alt === 'string') return node.alt
return node.children?.map(nodeText).join('') ?? ''
}

function staticAttribute(node: AstNode, names: Set<string>): string | undefined {
const attribute = node.attributes?.find(
(candidate) => candidate.type === 'mdxJsxAttribute' && names.has(candidate.name),
)
return typeof attribute?.value === 'string' ? attribute.value : undefined
}

function importsFor(node: AstNode, file: string): Map<string, string> {
const imports = new Map<string, string>()
const source = typeof node.value === 'string' ? node.value : ''
const pattern = /import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.(?:md|mdx))['"]/g

for (const match of source.matchAll(pattern)) {
const [, localName, importPath] = match
if (!localName || !importPath) continue
imports.set(localName, path.resolve(path.dirname(file), importPath))
}
return imports
}

function parseFile(file: string): AstNode {
try {
return unified().use(remarkParse).use(remarkMdx).parse(fs.readFileSync(file, 'utf8')) as AstNode
} catch (error) {
throw new Error(`Unable to parse ${file}`, { cause: error })
}
}

function sourceEvents(file: string, stack: string[] = []): SourceEvent[] {
if (stack.includes(file)) {
throw new Error(`Circular MDX import: ${[...stack, file].join(' -> ')}`)
}

const tree = parseFile(file)
const imports = new Map<string, string>()
for (const child of tree.children ?? []) {
if (child.type !== 'mdxjsEsm') continue
for (const [localName, importFile] of importsFor(child, file)) {
imports.set(localName, importFile)
}
}

const visit = (node: AstNode): SourceEvent[] => {
const line = node.position?.start.line ?? 1
const events: SourceEvent[] = []

if (node.type === 'heading') {
events.push({ type: 'heading', file, line, text: nodeText(node) })
}

if (node.type === 'link' || node.type === 'definition') {
if (typeof node.url === 'string') {
events.push({ type: 'link', file, line, href: node.url })
}
}

if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
const id = staticAttribute(node, new Set(['id']))
if (id) events.push({ type: 'anchor', file, line, id })

const href = staticAttribute(node, new Set(['href', 'to']))
if (href) events.push({ type: 'link', file, line, href })

const importedFile = node.name ? imports.get(node.name) : undefined
if (importedFile) {
if (!fs.existsSync(importedFile)) {
throw new Error(`${file}:${line} imports missing Markdown file ${importedFile}`)
}
events.push(...sourceEvents(importedFile, [...stack, file]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset slug generation at imported MDX boundaries

Imported .mdx files compile as separate modules, so Vocs' heading slugger resets for each file, but flattening imported events here makes the checker slug every heading in one host-page sequence. If a host page and its imported snippet both contain ## Setup, the rendered DOM has two id="setup" headings, while this checker invents setup-1 for the snippet and incorrectly accepts a link to that nonexistent fragment; preserve a separate slugger scope for each compiled MDX file when expanding shared snippets.

AGENTS.md reference: AGENTS.md:L20-L22

Useful? React with 👍 / 👎.

}
}

for (const child of node.children ?? []) events.push(...visit(child))
return events
}

return visit(tree)
}

function headingAnchor(text: string, slugger: GithubSlugger): string {
return slugger.slug(text)
}

function targetFor(href: string, page: Page): { route: string; fragment: string } | undefined {
if (/^(?:https?:|mailto:|tel:)/.test(href)) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate canonical absolute documentation links

For canonical links such as https://tempo.xyz/developers/docs/...#fragment, this early return classifies the URL as external before the existing /developers/docs normalization can run. The repository already has these internal fragment links in src/pages/docs/protocol/tips/tip-1020.mdx and src/pages/docs/protocol/tip20/spec.mdx, so a target heading can be renamed and leave them stale without this required check reporting anything; recognize the canonical docs origin and normalize its mount before filtering external URLs.

AGENTS.md reference: AGENTS.md:L7-L10

Useful? React with 👍 / 👎.


const basePath = href.startsWith('#') ? page.route : `${page.routeDirectory}/`
const base = `https://tempo.local${basePath}`
const url = new URL(href, base)
if (!url.hash || url.origin !== 'https://tempo.local') return

let route = url.pathname
if (route === '/developers/docs' || route.startsWith('/developers/docs/')) {
route = route.slice('/developers'.length)
}
route = normalizeRoute(route)
if (route !== '/docs' && !route.startsWith('/docs/')) return

return { route, fragment: decodeURIComponent(url.hash.slice(1)) }
}

export function checkInternalAnchors(
pagesDirectory = path.resolve('src/pages'),
): InternalAnchorCheck {
const pages = markdownFiles(pagesDirectory).map((file) => pageFor(file, pagesDirectory))
const docsPages = pages.filter(
(page) => page.route === '/docs' || page.route.startsWith('/docs/'),
)
const eventsByRoute = new Map(docsPages.map((page) => [page.route, sourceEvents(page.file)]))
const anchorsByRoute = new Map<string, Set<string>>()

for (const page of docsPages) {
const slugger = new GithubSlugger()
const anchors = new Set<string>()
for (const event of eventsByRoute.get(page.route) ?? []) {
if (event.type === 'heading') anchors.add(headingAnchor(event.text, slugger))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse explicit heading anchors before slugging

When a page follows the prescribed rename pattern ## Tempo API pagination modes {#modes}, nodeText includes {#modes} in the heading text and the checker records a generated slug such as tempo-api-pagination-modes-modes instead of the explicit modes ID. A preserved link to #modes then fails the required Verify workflow even though the heading deliberately retains that deep link; extract explicit heading IDs rather than passing the suffix to GithubSlugger.

AGENTS.md reference: AGENTS.md:L66-L70

Useful? React with 👍 / 👎.

if (event.type === 'anchor') anchors.add(event.id)
}
anchorsByRoute.set(page.route, anchors)
}

const failures: BrokenInternalAnchor[] = []
let linksChecked = 0
for (const page of docsPages) {
for (const event of eventsByRoute.get(page.route) ?? []) {
if (event.type !== 'link') continue
const target = targetFor(event.href, page)
if (!target) continue

const anchors = anchorsByRoute.get(target.route)
if (!anchors) continue // Vocs checks whether the target page exists.

linksChecked += 1
if (anchors.has(target.fragment)) continue
failures.push({
file: event.file,
line: event.line,
href: event.href,
targetRoute: target.route,
fragment: target.fragment,
})
}
}

return { pagesChecked: docsPages.length, linksChecked, failures }
}
2 changes: 1 addition & 1 deletion src/pages/docs/guide/issuance/create-a-stablecoin.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Ensure that you have set up your project with Wagmi, a Tempo chain config, and a
Before we send off a transaction to deploy our stablecoin to the Tempo testnet, we need to make sure our account is funded with a stablecoin to cover the transaction fee.

As we have configured our project to use `AlphaUSD` (`0x20c000…0001`)
as the [default fee token](/docs/quickstart/integrate-tempo#default-fee-token), we will need to add some `AlphaUSD` to our account.
as the [default fee token](/docs/quickstart/evm-compatibility#consideration-1-setting-a-user-default-fee-token), we will need to add some `AlphaUSD` to our account.

Luckily, the built-in Tempo testnet faucet supports funding accounts with `AlphaUSD`.

Expand Down
Loading
Loading