diff --git a/docs/config.json b/docs/config.json index 872da3bf..8b5dd21d 100644 --- a/docs/config.json +++ b/docs/config.json @@ -10,7 +10,8 @@ "label": "Getting Started", "children": [ { "label": "Introduction", "to": "introduction" }, - { "label": "Installation", "to": "installation" } + { "label": "Installation", "to": "installation" }, + { "label": "Text Measurement with Pretext", "to": "pretext" } ], "frameworks": [ { @@ -128,10 +129,18 @@ "to": "framework/react/examples/dynamic", "label": "Dynamic" }, + { + "to": "framework/react/examples/pretext", + "label": "Pretext" + }, { "to": "framework/react/examples/padding", "label": "Padding" }, + { + "to": "framework/react/examples/scroll-padding", + "label": "Scroll Padding" + }, { "to": "framework/react/examples/sticky", "label": "Sticky" @@ -165,10 +174,6 @@ "to": "framework/svelte/examples/fixed", "label": "Fixed" }, - { - "to": "framework/svelte/examples/variable", - "label": "Variable" - }, { "to": "framework/svelte/examples/dynamic", "label": "Dynamic" diff --git a/docs/pretext.md b/docs/pretext.md new file mode 100644 index 00000000..eafcff82 --- /dev/null +++ b/docs/pretext.md @@ -0,0 +1,116 @@ +--- +title: Text Measurement with Pretext +--- + +[Pretext](https://github.com/chenglou/pretext) is a text measurement and layout library from Cheng Lou. TanStack Virtual still owns scrolling, range calculation, item positioning, and scroll-to behavior; Pretext can own the text-height estimate for rows whose height is mostly determined by wrapped text. + +This is useful for chat logs, AI streams, activity feeds, comments, changelogs, notifications, and other text-heavy timelines where DOM measurement creates visible correction work. + +## When to use it + +Use Pretext when each virtual row's height can be derived from: + +- Text content +- The exact canvas font string used by the rendered text +- The available content width +- The rendered line-height +- Matching whitespace, word-break, and letter-spacing settings + +Do not make Pretext responsible for rows whose height depends on images, embeds, block markdown, loaded components, or arbitrary CSS layout. For those rows, use `measureElement`, call `resizeItem` when the extra content resolves, or split the text-only and non-text portions into separate sizing paths. + +## Install + +```sh +npm install @chenglou/pretext +``` + +## Basic pattern + +Cache `prepare()` by text and text-style inputs. Run `layout()` for the current width. When the width, font, line-height, or text options change, reset Virtual's measurements so offsets are recalculated from the new estimates. + +```tsx +import { clearCache, layout, prepare } from '@chenglou/pretext' +import { useVirtualizer } from '@tanstack/react-virtual' + +const font = '14px Arial' +const lineHeight = 20 +const preparedCache = new Map>() + +function getPrepared(row: { id: string; text: string }) { + const key = `${row.id}:${font}:${row.text}` + const cached = preparedCache.get(key) + + if (cached) { + return cached + } + + const prepared = prepare(row.text, font, { + whiteSpace: 'pre-wrap', + letterSpacing: 0, + }) + preparedCache.set(key, prepared) + return prepared +} + +function estimateRowHeight(row: { id: string; text: string }, contentWidth: number) { + const text = layout(getPrepared(row), contentWidth, lineHeight) + const textHeight = Math.max(lineHeight, text.height) + + return textHeight + 24 +} + +function Messages({ rows }: { rows: Array<{ id: string; text: string }> }) { + const parentRef = React.useRef(null) + const [width, setWidth] = React.useState(640) + + React.useLayoutEffect(() => { + const element = parentRef.current + + if (!element) { + return + } + + const update = () => setWidth(element.clientWidth) + const observer = new ResizeObserver(update) + + update() + observer.observe(element) + + return () => observer.disconnect() + }, []) + + const virtualizer = useVirtualizer({ + count: rows.length, + getItemKey: (index) => rows[index]!.id, + getScrollElement: () => parentRef.current, + estimateSize: (index) => estimateRowHeight(rows[index]!, width - 32), + }) + + React.useLayoutEffect(() => { + virtualizer.measure() + }, [virtualizer, width]) + + React.useEffect(() => { + document.fonts.ready.then(() => { + preparedCache.clear() + clearCache() + virtualizer.measure() + }) + }, [virtualizer]) + + return
{/* render virtual rows */}
+} +``` + +## Robustness checklist + +- Match CSS and Pretext inputs exactly. `font`, `line-height`, `letter-spacing`, `white-space`, and `word-break` must agree with the rendered row. +- Prefer named fonts. System font aliases can map differently between CSS and canvas, especially on macOS. +- Wait for fonts before trusting cached measurements. After `document.fonts.ready`, clear your prepared-text cache, call Pretext's `clearCache()`, and call `virtualizer.measure()`. +- Rerun `layout()`, not `prepare()`, on resize. `prepare()` is the expensive per-text setup; `layout()` is the cheap width-dependent path. +- Clamp empty text if your UI renders empty rows as one line. Pretext returns zero height for an empty string. +- Use one sizing owner per row. Do not call `measureElement` for the same row that you also size with `resizeItem` or Pretext estimates unless you deliberately want DOM measurement to override the text estimate. +- Keep a fallback for unsupported runtimes. Pretext currently needs `Intl.Segmenter` and Canvas 2D text measurement. +- Use `resizeItem(index, size)` when you know a row's final size outside render, such as after markdown preprocessing, image metadata loading, or a controlled expand/collapse transition. + +See the React Pretext example for a complete chat-style implementation: [React Pretext](./framework/react/examples/pretext). diff --git a/examples/lit/dynamic/tsconfig.json b/examples/lit/dynamic/tsconfig.json index c2bfe0aa..fe69a024 100644 --- a/examples/lit/dynamic/tsconfig.json +++ b/examples/lit/dynamic/tsconfig.json @@ -6,7 +6,8 @@ "module": "ESNext", "moduleResolution": "node", "experimentalDecorators": true, - "useDefineForClassFields": false + "useDefineForClassFields": false, + "types": ["node"] }, "files": ["src/main.ts"], "include": ["src"] diff --git a/examples/lit/fixed/tsconfig.json b/examples/lit/fixed/tsconfig.json index c2bfe0aa..fe69a024 100644 --- a/examples/lit/fixed/tsconfig.json +++ b/examples/lit/fixed/tsconfig.json @@ -6,7 +6,8 @@ "module": "ESNext", "moduleResolution": "node", "experimentalDecorators": true, - "useDefineForClassFields": false + "useDefineForClassFields": false, + "types": ["node"] }, "files": ["src/main.ts"], "include": ["src"] diff --git a/examples/react/pretext/README.md b/examples/react/pretext/README.md new file mode 100644 index 00000000..96e243e6 --- /dev/null +++ b/examples/react/pretext/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `pnpm install` +- `pnpm --filter tanstack-react-virtual-example-pretext dev` diff --git a/examples/react/pretext/index.html b/examples/react/pretext/index.html new file mode 100644 index 00000000..84b7eb68 --- /dev/null +++ b/examples/react/pretext/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/examples/react/pretext/package.json b/examples/react/pretext/package.json new file mode 100644 index 00000000..9e1dd5f5 --- /dev/null +++ b/examples/react/pretext/package.json @@ -0,0 +1,24 @@ +{ + "name": "tanstack-react-virtual-example-pretext", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "serve": "vite preview" + }, + "dependencies": { + "@chenglou/pretext": "^0.0.7", + "@tanstack/react-virtual": "^3.13.26", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "@types/react": "^18.3.23", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "5.6.3", + "vite": "^6.4.2" + } +} diff --git a/examples/react/pretext/src/index.css b/examples/react/pretext/src/index.css new file mode 100644 index 00000000..787eef52 --- /dev/null +++ b/examples/react/pretext/src/index.css @@ -0,0 +1,128 @@ +* { + box-sizing: border-box; +} + +html { + color: #1f2937; + font-family: Arial, Helvetica, sans-serif; + line-height: 1.5; +} + +body { + margin: 0; + background: #f6f7fb; +} + +button { + border: 1px solid #c8d1dc; + border-radius: 6px; + background: #fff; + color: #1f2937; + cursor: pointer; + font: inherit; + padding: 6px 10px; +} + +button:hover { + background: #eef4ff; +} + +.app { + display: grid; + gap: 16px; + margin: 0 auto; + max-width: 980px; + padding: 24px; +} + +.toolbar { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.stat { + color: #596579; + font-size: 13px; + margin-left: auto; +} + +.list { + background: #fff; + border: 1px solid #d9e0ea; + border-radius: 8px; + height: 720px; + overflow-x: hidden; + overflow-y: auto; +} + +.spacer { + position: relative; + width: 100%; +} + +.message-row { + left: 0; + padding: 8px 16px; + position: absolute; + top: 0; + width: 100%; +} + +.message-bubble { + background: #f8fafc; + border: 1px solid #d9e0ea; + border-radius: 8px; + max-width: 680px; + padding: 12px 14px; + width: 100%; +} + +.message-row.user .message-bubble { + background: #eef6ff; + border-color: #bed8f4; + margin-left: auto; +} + +.message-meta { + align-items: center; + color: #596579; + display: flex; + font-size: 12px; + font-weight: 700; + gap: 8px; + height: 18px; + line-height: 18px; + margin-bottom: 6px; +} + +.message-time { + color: #8792a2; + font-weight: 400; +} + +.message-body { + font: + 14px/20px Arial, + Helvetica, + sans-serif; + letter-spacing: 0; + margin: 0; + white-space: pre-wrap; +} + +@media (max-width: 640px) { + .app { + padding: 12px; + } + + .list { + height: 640px; + } + + .stat { + flex-basis: 100%; + margin-left: 0; + } +} diff --git a/examples/react/pretext/src/main.tsx b/examples/react/pretext/src/main.tsx new file mode 100644 index 00000000..7e796d0c --- /dev/null +++ b/examples/react/pretext/src/main.tsx @@ -0,0 +1,245 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { clearCache, layout, prepare } from '@chenglou/pretext' +import { useVirtualizer } from '@tanstack/react-virtual' + +import './index.css' + +type Message = { + id: string + author: string + body: string + time: string + type: 'agent' | 'user' +} + +const BODY_FONT = '14px Arial' +const BODY_LINE_HEIGHT = 20 +const BODY_LETTER_SPACING = 0 +const BUBBLE_BORDER_WIDTH = 2 +const BUBBLE_MAX_WIDTH = 680 +const BUBBLE_X_PADDING = 28 +const BUBBLE_Y_PADDING = 24 +const META_BODY_GAP = 6 +const META_HEIGHT = 18 +const ROW_X_PADDING = 32 +const ROW_Y_PADDING = 16 +const DEFAULT_VIEWPORT_WIDTH = 760 + +const preparedCache = new Map>() + +const sampleBodies = [ + 'The expensive part of variable-height virtualization is usually discovering the height after the row has already rendered. Pretext lets this example calculate the message height from the text, font, width, and line-height before the DOM node exists.', + 'This row includes hard breaks.\n\nThe CSS uses white-space: pre-wrap, so the Pretext prepare call uses the same option. Keeping those two in sync is the difference between a stable scroll position and a slow drip of measurement corrections.', + 'Resize the page and the example reruns layout() for the new content width. It does not rerun prepare() unless the text or font inputs change.', + 'Rows still use TanStack Virtual for scroll state, range extraction, absolute positioning, overscan, and scrollToIndex. Pretext only owns text measurement.', + 'If a row contains media, embeds, block markdown, or custom components, let the virtualizer measure that row with measureElement or call resizeItem when the non-text content resolves.', + 'The practical pattern is to make one part of the system responsible for each row size. For these text-only rows, Pretext provides the size. For mixed content, measured DOM can be the fallback.', + 'A named font is intentional here. Pretext uses canvas text measurement, and named fonts are easier to keep aligned with CSS than system font aliases.', + 'The virtualizer is reset after fonts finish loading. That clears Pretext caches and recalculates row sizes using the final font metrics.', + 'Pretext returns zero height for an empty string. If your UI still renders an empty text block as one line, clamp the measured body height to at least one line-height.', + 'This is a synthetic chat log, but the same approach works for AI streams, activity feeds, notification centers, changelogs, comment threads, and other text-heavy timelines.', +] + +const messages = Array.from({ length: 2000 }, (_, index): Message => { + const body = sampleBodies[index % sampleBodies.length]! + const repeatCount = index % 7 === 0 ? 2 : 1 + const repeatedBody = Array.from({ length: repeatCount }, () => body).join( + '\n\n', + ) + + return { + id: `message-${index}`, + author: index % 3 === 0 ? 'Support' : index % 3 === 1 ? 'Customer' : 'Ops', + body: `${repeatedBody}\n\nMessage ${index + 1}`, + time: `${String(8 + (index % 10)).padStart(2, '0')}:${String( + (index * 7) % 60, + ).padStart(2, '0')}`, + type: index % 3 === 1 ? 'user' : 'agent', + } +}) + +function fallbackTextHeight(text: string, width: number) { + const averageCharacterWidth = 7 + const charactersPerLine = Math.max( + 1, + Math.floor(width / averageCharacterWidth), + ) + + return text.split('\n').reduce((height, paragraph) => { + const lineCount = Math.max( + 1, + Math.ceil(paragraph.length / charactersPerLine), + ) + return height + lineCount * BODY_LINE_HEIGHT + }, 0) +} + +function getPreparedMessage(message: Message) { + const key = `${message.id}:${BODY_FONT}:${BODY_LETTER_SPACING}:${message.body}` + const cached = preparedCache.get(key) + + if (cached) { + return cached + } + + const prepared = prepare(message.body, BODY_FONT, { + letterSpacing: BODY_LETTER_SPACING, + whiteSpace: 'pre-wrap', + }) + preparedCache.set(key, prepared) + return prepared +} + +function estimateMessageHeight(message: Message, viewportWidth: number) { + const bubbleWidth = Math.min( + BUBBLE_MAX_WIDTH, + Math.max(1, viewportWidth - ROW_X_PADDING), + ) + const textWidth = Math.max( + 1, + bubbleWidth - BUBBLE_X_PADDING - BUBBLE_BORDER_WIDTH, + ) + const textHeight = + typeof Intl !== 'undefined' && 'Segmenter' in Intl + ? layout(getPreparedMessage(message), textWidth, BODY_LINE_HEIGHT).height + : fallbackTextHeight(message.body, textWidth) + const bodyHeight = Math.max(BODY_LINE_HEIGHT, textHeight) + + return Math.ceil( + ROW_Y_PADDING + + BUBBLE_Y_PADDING + + BUBBLE_BORDER_WIDTH + + META_HEIGHT + + META_BODY_GAP + + bodyHeight, + ) +} + +function useElementWidth(ref: React.RefObject) { + const [width, setWidth] = React.useState(DEFAULT_VIEWPORT_WIDTH) + + React.useLayoutEffect(() => { + const element = ref.current + + if (!element) { + return + } + + const updateWidth = () => { + setWidth(Math.max(1, Math.round(element.clientWidth))) + } + + updateWidth() + + const observer = new ResizeObserver(updateWidth) + observer.observe(element) + + return () => { + observer.disconnect() + } + }, [ref]) + + return width +} + +function App() { + const parentRef = React.useRef(null) + const viewportWidth = useElementWidth(parentRef) + const [fontVersion, setFontVersion] = React.useState(0) + + const rowVirtualizer = useVirtualizer({ + count: messages.length, + estimateSize: React.useCallback( + (index) => estimateMessageHeight(messages[index]!, viewportWidth), + [fontVersion, viewportWidth], + ), + getItemKey: React.useCallback((index: number) => messages[index]!.id, []), + getScrollElement: () => parentRef.current, + overscan: 8, + }) + + React.useLayoutEffect(() => { + rowVirtualizer.measure() + }, [fontVersion, rowVirtualizer, viewportWidth]) + + React.useEffect(() => { + let cancelled = false + + document.fonts.ready.then(() => { + if (cancelled) { + return + } + + preparedCache.clear() + clearCache() + setFontVersion((value) => value + 1) + }) + + return () => { + cancelled = true + } + }, []) + + return ( +
+
+ + + +
+ {rowVirtualizer.getVirtualItems().length} rendered of{' '} + {messages.length} +
+
+ +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const message = messages[virtualRow.index]! + + return ( +
+
+
+ {message.author} + {message.time} +
+

{message.body}

+
+
+ ) + })} +
+
+
+ ) +} + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/examples/react/pretext/tsconfig.json b/examples/react/pretext/tsconfig.json new file mode 100644 index 00000000..87318025 --- /dev/null +++ b/examples/react/pretext/tsconfig.json @@ -0,0 +1,25 @@ +{ + "composite": true, + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/react/pretext/vite.config.js b/examples/react/pretext/vite.config.js new file mode 100644 index 00000000..5a33944a --- /dev/null +++ b/examples/react/pretext/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/package.json b/package.json index 6d60f159..8a29e4c4 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "format": "prettier --experimental-cli --ignore-unknown '**/*' --write", "changeset": "changeset", "changeset:version": "changeset version && pnpm install --no-frozen-lockfile && pnpm format", - "changeset:publish": "changeset publish" + "changeset:publish": "changeset publish", + "verify-links": "node scripts/verify-links.ts", + "verify-examples": "node scripts/verify-examples.ts" }, "nx": { "includedScripts": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83a164d6..28e50fe8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -791,6 +791,40 @@ importers: specifier: ^6.4.2 version: 6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.1) + examples/react/pretext: + dependencies: + '@chenglou/pretext': + specifier: ^0.0.7 + version: 0.0.7 + '@tanstack/react-virtual': + specifier: ^3.13.26 + version: link:../../../packages/react-virtual + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/node': + specifier: ^24.5.2 + version: 24.9.2 + '@types/react': + specifier: ^18.3.23 + version: 18.3.26 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.26) + '@vitejs/plugin-react': + specifier: ^4.5.2 + version: 4.7.0(vite@6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.1)) + typescript: + specifier: 5.6.3 + version: 5.6.3 + vite: + specifier: ^6.4.2 + version: 6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)(yaml@2.8.1) + examples/react/scroll-padding: dependencies: '@react-hookz/web': @@ -2335,6 +2369,9 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@chenglou/pretext@0.0.7': + resolution: {integrity: sha512-FV5hj3fGqpBlzbANUbR+s+6XuNRrghVVyuNs33zdH2SzU7MvK+7GlW6xREjDCreixKbexEn7OEkDgAFeWuu5Hg==} + '@codesandbox/vue-preview@0.1.1-alpha.16': resolution: {integrity: sha512-ZhiG66TcDMc7GegF2rhXrlL9zE/xioL1dDnn5ymxnaK+Lwt1/Lc0gwlgaa78QyjapDI8oPK2KJDEnYs8kcalUw==} hasBin: true @@ -9533,6 +9570,8 @@ snapshots: human-id: 4.1.2 prettier: 2.8.8 + '@chenglou/pretext@0.0.7': {} + '@codesandbox/vue-preview@0.1.1-alpha.16': dependencies: ws: 8.18.3 diff --git a/scripts/verify-examples.ts b/scripts/verify-examples.ts new file mode 100644 index 00000000..3d89dd4c --- /dev/null +++ b/scripts/verify-examples.ts @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { glob } from 'tinyglobby' + +type PackageJson = { + name?: string + scripts?: Record +} + +const filter = process.env.EXAMPLE_FILTER +const skipPackageBuild = process.env.SKIP_PACKAGE_BUILD === 'true' + +if (!skipPackageBuild) { + const nxBin = + process.platform === 'win32' + ? 'node_modules/.bin/nx.cmd' + : 'node_modules/.bin/nx' + + console.log('\nBuilding workspace packages') + + const packageBuild = spawnSync( + nxBin, + ['run-many', '--target=build', '--exclude=examples/**'], + { + env: { + ...process.env, + NX_DAEMON: 'false', + }, + stdio: 'inherit', + }, + ) + + if (packageBuild.status !== 0) { + console.log('\nWorkspace package build failed.') + process.exit(1) + } +} + +const packageJsonPaths = await glob('examples/*/*/package.json') +const examples = packageJsonPaths + .map((packageJsonPath) => { + const directory = path.dirname(packageJsonPath) + const packageJson = JSON.parse( + readFileSync(packageJsonPath, 'utf-8'), + ) as PackageJson + + return { + directory, + name: packageJson.name ?? directory, + hasBuild: Boolean(packageJson.scripts?.build), + } + }) + .filter((example) => !filter || example.directory.includes(filter)) + .sort((a, b) => a.directory.localeCompare(b.directory)) + +const failures: Array = [] + +for (let index = 0; index < examples.length; index++) { + const example = examples[index]! + + if (!example.hasBuild) { + failures.push(`${example.directory} is missing a build script`) + continue + } + + console.log(`\n[${index + 1}/${examples.length}] ${example.directory}`) + + const result = spawnSync('npm', ['run', 'build', '--silent'], { + cwd: example.directory, + stdio: 'inherit', + }) + + if (result.status !== 0) { + failures.push(`${example.directory} (${example.name})`) + } +} + +if (failures.length > 0) { + console.log( + `\nFailed examples:\n${failures.map((failure) => `- ${failure}`).join('\n')}`, + ) + process.exit(1) +} + +console.log(`\nBuilt ${examples.length} examples successfully.`) diff --git a/scripts/verify-links.ts b/scripts/verify-links.ts index 8a1c40dd..dcb0afdf 100644 --- a/scripts/verify-links.ts +++ b/scripts/verify-links.ts @@ -1,18 +1,22 @@ import { existsSync, readFileSync, statSync } from 'node:fs' -import { extname, resolve } from 'node:path' +import path, { dirname, resolve } from 'node:path' import { glob } from 'tinyglobby' // @ts-ignore Could not find a declaration file for module 'markdown-link-extractor'. import markdownLinkExtractor from 'markdown-link-extractor' -const errors: Array<{ - file: string +type LinkError = { link: string - resolvedPath: string reason: string -}> = [] + resolvedPath: string + source: string +} + +const docsRoot = resolve('docs') +const examplesRoot = resolve('examples') function isRelativeLink(link: string) { return ( + link && !link.startsWith('/') && !link.startsWith('http://') && !link.startsWith('https://') && @@ -22,104 +26,219 @@ function isRelativeLink(link: string) { ) } -/** Remove any trailing .md */ -function stripExtension(p: string): string { - return p.replace(`${extname(p)}`, '') +function isInside(root: string, target: string) { + const relative = path.relative(root, target) + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ) } -function relativeLinkExists(link: string, file: string): boolean { - // Remove hash if present - const linkWithoutHash = link.split('#')[0] - // If the link is empty after removing hash, it's not a file - if (!linkWithoutHash) return false +function stripHashAndQuery(link: string) { + return link.split(/[?#]/)[0] +} - // Strip the file/link extensions - const filePath = stripExtension(file) - const linkPath = stripExtension(linkWithoutHash) +function examplePathFromRoute(route: string) { + const match = route.match(/^framework\/([^/]+)\/examples\/(.+)$/) - // Resolve the path relative to the markdown file's directory - // Nav up a level to simulate how links are resolved on the web - let absPath = resolve(filePath, '..', linkPath) + if (!match) { + return null + } - // Ensure the resolved path is within /docs - const docsRoot = resolve('docs') - if (!absPath.startsWith(docsRoot)) { + return resolve(examplesRoot, match[1]!, match[2]!) +} + +function fileExistsForMarkdownLink( + link: string, + markdownFile: string, + errors: Array, +): boolean { + const filePart = stripHashAndQuery(link) + + if (!filePart) { + return true + } + + let absPath = resolve(dirname(resolve(markdownFile)), filePart) + + if (!isInside(docsRoot, absPath)) { errors.push({ link, - file, + reason: 'navigates outside docs', resolvedPath: absPath, - reason: 'Path outside /docs', + source: markdownFile, }) return false } - // Check if this is an example path - const isExample = absPath.includes('/examples/') + const docsRelativePath = path + .relative(docsRoot, absPath) + .replaceAll(path.sep, '/') + const examplePath = examplePathFromRoute(docsRelativePath) + + if (examplePath) { + const exists = + existsSync(examplePath) && statSync(examplePath).isDirectory() + + if (!exists) { + errors.push({ + link, + reason: 'example route not found', + resolvedPath: examplePath, + source: markdownFile, + }) + } - let exists = false + return exists + } - if (isExample) { - // Transform /docs/framework/{framework}/examples/ to /examples/{framework}/ - absPath = absPath.replace( - /\/docs\/framework\/([^/]+)\/examples\//, - '/examples/$1/', - ) - // For examples, we want to check if the directory exists - exists = existsSync(absPath) && statSync(absPath).isDirectory() - } else { - // For non-examples, we want to check if the .md file exists - if (!absPath.endsWith('.md')) { - absPath = `${absPath}.md` - } - exists = existsSync(absPath) + if (!path.extname(absPath)) { + absPath = `${absPath}.md` } + const exists = existsSync(absPath) + if (!exists) { errors.push({ link, - file, + reason: 'not found', resolvedPath: absPath, - reason: 'Not found', + source: markdownFile, }) } + return exists } -async function verifyMarkdownLinks() { - // Find all markdown files in docs directory +function getConfigRoutes(value: unknown, routes = new Set()) { + if (Array.isArray(value)) { + value.forEach((child) => getConfigRoutes(child, routes)) + return routes + } + + if (!value || typeof value !== 'object') { + return routes + } + + const record = value as Record + + if (typeof record.to === 'string') { + routes.add(record.to) + } + + Object.values(record).forEach((child) => getConfigRoutes(child, routes)) + + return routes +} + +function fileExistsForConfigRoute( + route: string, + errors: Array, +): boolean { + const cleanRoute = stripHashAndQuery(route) + .replace(/^\.\//, '') + .replace(/\.md$/, '') + + if (!cleanRoute || cleanRoute.startsWith('http')) { + return true + } + + const examplePath = examplePathFromRoute(cleanRoute) + const resolvedPath = examplePath ?? resolve(docsRoot, `${cleanRoute}.md`) + const exists = examplePath + ? existsSync(resolvedPath) && statSync(resolvedPath).isDirectory() + : existsSync(resolvedPath) + + if (!exists) { + errors.push({ + link: route, + reason: examplePath ? 'example route not found' : 'docs route not found', + resolvedPath, + source: 'docs/config.json', + }) + } + + return exists +} + +function extractHref(link: unknown) { + if (typeof link === 'string') { + return link + } + + if ( + link && + typeof link === 'object' && + 'href' in link && + typeof link.href === 'string' + ) { + return link.href + } + + return null +} + +async function verifyLinks() { const markdownFiles = await glob('docs/**/*.md', { ignore: ['**/node_modules/**'], }) console.log(`Found ${markdownFiles.length} markdown files\n`) - // Process each file + const errors: Array = [] + for (const file of markdownFiles) { const content = readFileSync(file, 'utf-8') - const links: Array = markdownLinkExtractor(content) + const links: Array = markdownLinkExtractor(content) + + links.forEach((link) => { + const href = extractHref(link) - const relativeLinks = links.filter((link: string) => { - return isRelativeLink(link) + if (href && isRelativeLink(href)) { + fileExistsForMarkdownLink(href, file, errors) + } }) + } - if (relativeLinks.length > 0) { - relativeLinks.forEach((link) => { - relativeLinkExists(link, file) + const config = JSON.parse(readFileSync('docs/config.json', 'utf-8')) + const configRoutes = getConfigRoutes(config) + + configRoutes.forEach((route) => { + fileExistsForConfigRoute(route, errors) + }) + + const expectedExampleRoutes = new Set( + (await glob('examples/*/*/package.json')).map((packageJson) => { + const [, framework, example] = packageJson.split('/') + return `framework/${framework}/examples/${example}` + }), + ) + + expectedExampleRoutes.forEach((route) => { + if (!configRoutes.has(route)) { + errors.push({ + link: route, + reason: 'example missing from docs config', + resolvedPath: resolve('docs/config.json'), + source: route.replace( + /^framework\/([^/]+)\/examples\/(.+)$/, + 'examples/$1/$2/package.json', + ), }) } - } + }) if (errors.length > 0) { - console.log(`\n❌ Found ${errors.length} broken links:`) + console.log(`\nFound ${errors.length} broken links or routes:`) errors.forEach((err) => { console.log( - `${err.file}\n link: ${err.link}\n resolved: ${err.resolvedPath}\n why: ${err.reason}\n`, + `${err.link}\n in: ${err.source}\n path: ${err.resolvedPath}\n why: ${err.reason}\n`, ) }) process.exit(1) } else { - console.log('\n✅ No broken links found!') + console.log('\nNo broken links or routes found!') } } -verifyMarkdownLinks().catch(console.error) +verifyLinks().catch(console.error)