diff --git a/docs/config.json b/docs/config.json index e65ba6ad2..88e513882 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": [ { @@ -124,10 +125,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" @@ -157,10 +166,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 000000000..eafcff828 --- /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 c2bfe0aac..fe69a0247 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 c2bfe0aac..fe69a0247 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 000000000..96e243e62 --- /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 000000000..84b7eb685 --- /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 000000000..085c2e0bf --- /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.9", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.13.6", + "@types/react": "^18.3.21", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "5.2.2", + "vite": "^5.4.19" + } +} diff --git a/examples/react/pretext/src/index.css b/examples/react/pretext/src/index.css new file mode 100644 index 000000000..787eef522 --- /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 000000000..7e796d0cd --- /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 000000000..87318025a --- /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 000000000..5a33944a9 --- /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 9b1f696cc..c7334c954 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "prettier:write": "pnpm run prettier --write", "changeset": "changeset", "changeset:version": "changeset version && pnpm install --no-frozen-lockfile && pnpm prettier:write", - "changeset:publish": "changeset publish" + "changeset:publish": "changeset publish", + "verify-links": "node scripts/verify-links.ts", + "verify-examples": "node scripts/verify-examples.ts" }, "nx": { "includedScripts": [ @@ -44,8 +46,10 @@ "@testing-library/jest-dom": "^6.6.3", "@types/node": "^22.13.6", "eslint": "^9.27.0", + "fast-glob": "^3.3.3", "jsdom": "^25.0.1", "knip": "^5.56.0", + "markdown-link-extractor": "^4.0.2", "nx": "^20.8.2", "premove": "^4.0.0", "prettier": "^3.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 607bf8d76..dcf8daeb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,12 +26,18 @@ importers: eslint: specifier: ^9.27.0 version: 9.27.0(jiti@2.4.2) + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 jsdom: specifier: ^25.0.1 version: 25.0.1 knip: specifier: ^5.56.0 version: 5.56.0(@types/node@22.15.3)(typescript@5.2.2) + markdown-link-extractor: + specifier: ^4.0.2 + version: 4.0.2 nx: specifier: ^20.8.2 version: 20.8.2 @@ -711,6 +717,40 @@ importers: specifier: ^5.4.19 version: 5.4.19(@types/node@22.15.3)(less@4.3.0)(sass@1.87.0)(terser@5.39.0) + examples/react/pretext: + dependencies: + '@chenglou/pretext': + specifier: ^0.0.7 + version: 0.0.7 + '@tanstack/react-virtual': + specifier: ^3.13.9 + 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: ^22.13.6 + version: 22.15.3 + '@types/react': + specifier: ^18.3.21 + version: 18.3.21 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.21) + '@vitejs/plugin-react': + specifier: ^4.4.1 + version: 4.4.1(vite@5.4.19(@types/node@22.15.3)(less@4.3.0)(sass@1.87.0)(terser@5.39.0)) + typescript: + specifier: 5.2.2 + version: 5.2.2 + vite: + specifier: ^5.4.19 + version: 5.4.19(@types/node@22.15.3)(less@4.3.0)(sass@1.87.0)(terser@5.39.0) + examples/react/scroll-padding: dependencies: '@react-hookz/web': @@ -2179,6 +2219,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 @@ -4401,6 +4444,13 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.0.0: + resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==} + engines: {node: '>=18.17'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -4823,6 +4873,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding-sniffer@0.2.0: + resolution: {integrity: sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==} + encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} @@ -5337,9 +5390,15 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-link-extractor@1.0.5: + resolution: {integrity: sha512-ADd49pudM157uWHwHQPUSX4ssMsvR/yHIswOR5CUfBdK9g9ZYGMhVSE6KZVHJ6kCkR0gH4htsfzU6zECDNVwyw==} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + http-assert@1.5.0: resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} engines: {node: '>= 0.8'} @@ -5953,6 +6012,14 @@ packages: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true + markdown-link-extractor@4.0.2: + resolution: {integrity: sha512-5cUOu4Vwx1wenJgxaudsJ8xwLUMN7747yDJX3V/L7+gi3e4MsCm7w5nbrDQQy8nEfnl4r5NV3pDXMAjhGXYXAw==} + + marked@12.0.2: + resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -6413,6 +6480,12 @@ packages: parse5-html-rewriting-stream@7.0.0: resolution: {integrity: sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + parse5-sax-parser@7.0.0: resolution: {integrity: sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==} @@ -7468,6 +7541,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@6.21.3: + resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} + engines: {node: '>=18.17'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -7944,12 +8021,12 @@ snapshots: '@vitejs/plugin-basic-ssl': 1.1.0(vite@5.4.19(@types/node@22.15.3)(less@4.2.0)(sass@1.71.1)(terser@5.29.1)) ansi-colors: 4.1.3 autoprefixer: 10.4.18(postcss@8.4.35) - babel-loader: 9.1.3(@babel/core@7.26.10)(webpack@5.94.0) + babel-loader: 9.1.3(@babel/core@7.26.10)(webpack@5.94.0(esbuild@0.20.1)) babel-plugin-istanbul: 6.1.1 browserslist: 4.24.4 - copy-webpack-plugin: 11.0.0(webpack@5.94.0) + copy-webpack-plugin: 11.0.0(webpack@5.94.0(esbuild@0.20.1)) critters: 0.0.22 - css-loader: 6.10.0(webpack@5.94.0) + css-loader: 6.10.0(webpack@5.94.0(esbuild@0.20.1)) esbuild-wasm: 0.20.1 fast-glob: 3.3.2 http-proxy-middleware: 2.0.8(@types/express@4.17.21) @@ -7958,11 +8035,11 @@ snapshots: jsonc-parser: 3.2.1 karma-source-map-support: 1.4.0 less: 4.2.0 - less-loader: 11.1.0(less@4.2.0)(webpack@5.94.0) - license-webpack-plugin: 4.0.2(webpack@5.94.0) + less-loader: 11.1.0(less@4.2.0)(webpack@5.94.0(esbuild@0.20.1)) + license-webpack-plugin: 4.0.2(webpack@5.94.0(esbuild@0.20.1)) loader-utils: 3.2.1 magic-string: 0.30.8 - mini-css-extract-plugin: 2.8.1(webpack@5.94.0) + mini-css-extract-plugin: 2.8.1(webpack@5.94.0(esbuild@0.20.1)) mrmime: 2.0.0 open: 8.4.2 ora: 5.4.1 @@ -7970,13 +8047,13 @@ snapshots: picomatch: 4.0.1 piscina: 4.4.0 postcss: 8.4.35 - postcss-loader: 8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0) + postcss-loader: 8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0(esbuild@0.20.1)) resolve-url-loader: 5.0.0 rxjs: 7.8.1 sass: 1.71.1 - sass-loader: 14.1.1(sass@1.71.1)(webpack@5.94.0) + sass-loader: 14.1.1(sass@1.71.1)(webpack@5.94.0(esbuild@0.20.1)) semver: 7.6.0 - source-map-loader: 5.0.0(webpack@5.94.0) + source-map-loader: 5.0.0(webpack@5.94.0(esbuild@0.20.1)) source-map-support: 0.5.21 terser: 5.29.1 tree-kill: 1.2.2 @@ -7985,10 +8062,10 @@ snapshots: vite: 5.4.19(@types/node@22.15.3)(less@4.2.0)(sass@1.71.1)(terser@5.29.1) watchpack: 2.4.0 webpack: 5.94.0(esbuild@0.20.1) - webpack-dev-middleware: 6.1.2(webpack@5.94.0) + webpack-dev-middleware: 6.1.2(webpack@5.94.0(esbuild@0.20.1)) webpack-dev-server: 4.15.1(webpack@5.94.0(esbuild@0.20.1)) webpack-merge: 5.10.0 - webpack-subresource-integrity: 5.1.0(webpack@5.94.0) + webpack-subresource-integrity: 5.1.0(webpack@5.94.0(esbuild@0.20.1)) optionalDependencies: esbuild: 0.20.1 ng-packagr: 17.3.0(@angular/compiler-cli@17.3.12(@angular/compiler@17.3.12(@angular/core@17.3.12(rxjs@7.8.2)(zone.js@0.15.0)))(typescript@5.2.2))(tslib@2.8.1)(typescript@5.2.2) @@ -9038,6 +9115,8 @@ snapshots: human-id: 4.1.1 prettier: 2.8.8 + '@chenglou/pretext@0.0.7': {} + '@codesandbox/vue-preview@0.1.1-alpha.16': dependencies: ws: 8.18.1 @@ -11088,7 +11167,7 @@ snapshots: axobject-query@4.1.0: {} - babel-loader@9.1.3(@babel/core@7.26.10)(webpack@5.94.0): + babel-loader@9.1.3(@babel/core@7.26.10)(webpack@5.94.0(esbuild@0.20.1)): dependencies: '@babel/core': 7.26.10 find-cache-dir: 4.0.0 @@ -11290,6 +11369,29 @@ snapshots: check-error@2.1.1: {} + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.0.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.0 + htmlparser2: 9.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 6.21.3 + whatwg-mimetype: 4.0.0 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -11445,7 +11547,7 @@ snapshots: dependencies: is-what: 3.14.1 - copy-webpack-plugin@11.0.0(webpack@5.94.0): + copy-webpack-plugin@11.0.0(webpack@5.94.0(esbuild@0.20.1)): dependencies: fast-glob: 3.3.3 glob-parent: 6.0.2 @@ -11486,7 +11588,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-loader@6.10.0(webpack@5.94.0): + css-loader@6.10.0(webpack@5.94.0(esbuild@0.20.1)): dependencies: icss-utils: 5.1.0(postcss@8.5.3) postcss: 8.5.3 @@ -11665,6 +11767,11 @@ snapshots: encodeurl@2.0.0: {} + encoding-sniffer@0.2.0: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + encoding@0.1.13: dependencies: iconv-lite: 0.6.3 @@ -12317,6 +12424,10 @@ snapshots: html-escaper@2.0.2: {} + html-link-extractor@1.0.5: + dependencies: + cheerio: 1.0.0 + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -12324,6 +12435,13 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + http-assert@1.5.0: dependencies: deep-equal: 1.0.1 @@ -12812,7 +12930,7 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.8.2 - less-loader@11.1.0(less@4.2.0)(webpack@5.94.0): + less-loader@11.1.0(less@4.2.0)(webpack@5.94.0(esbuild@0.20.1)): dependencies: klona: 2.0.6 less: 4.2.0 @@ -12851,7 +12969,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - license-webpack-plugin@4.0.2(webpack@5.94.0): + license-webpack-plugin@4.0.2(webpack@5.94.0(esbuild@0.20.1)): dependencies: webpack-sources: 3.2.3 optionalDependencies: @@ -13004,6 +13122,13 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-link-extractor@4.0.2: + dependencies: + html-link-extractor: 1.0.5 + marked: 12.0.2 + + marked@12.0.2: {} + math-intrinsics@1.1.0: {} mdn-data@2.0.30: {} @@ -13049,7 +13174,7 @@ snapshots: min-indent@1.0.1: {} - mini-css-extract-plugin@2.8.1(webpack@5.94.0): + mini-css-extract-plugin@2.8.1(webpack@5.94.0(esbuild@0.20.1)): dependencies: schema-utils: 4.3.2 tapable: 2.2.1 @@ -13540,6 +13665,15 @@ snapshots: parse5: 7.3.0 parse5-sax-parser: 7.0.0 + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + parse5-sax-parser@7.0.0: dependencies: parse5: 7.3.0 @@ -13622,7 +13756,7 @@ snapshots: mlly: 1.7.4 pathe: 2.0.3 - postcss-loader@8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0): + postcss-loader@8.1.1(postcss@8.4.35)(typescript@5.2.2)(webpack@5.94.0(esbuild@0.20.1)): dependencies: cosmiconfig: 9.0.0(typescript@5.2.2) jiti: 1.21.7 @@ -13972,7 +14106,7 @@ snapshots: safer-buffer@2.1.2: {} - sass-loader@14.1.1(sass@1.71.1)(webpack@5.94.0): + sass-loader@14.1.1(sass@1.71.1)(webpack@5.94.0(esbuild@0.20.1)): dependencies: neo-async: 2.6.2 optionalDependencies: @@ -14243,7 +14377,7 @@ snapshots: source-map-js@1.2.1: {} - source-map-loader@5.0.0(webpack@5.94.0): + source-map-loader@5.0.0(webpack@5.94.0(esbuild@0.20.1)): dependencies: iconv-lite: 0.6.3 source-map-js: 1.2.1 @@ -14591,6 +14725,8 @@ snapshots: undici-types@6.21.0: {} + undici@6.21.3: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -14882,7 +15018,7 @@ snapshots: schema-utils: 4.3.2 webpack: 5.94.0(esbuild@0.20.1) - webpack-dev-middleware@6.1.2(webpack@5.94.0): + webpack-dev-middleware@6.1.2(webpack@5.94.0(esbuild@0.20.1)): dependencies: colorette: 2.0.20 memfs: 3.5.3 @@ -14940,7 +15076,7 @@ snapshots: webpack-sources@3.2.3: {} - webpack-subresource-integrity@5.1.0(webpack@5.94.0): + webpack-subresource-integrity@5.1.0(webpack@5.94.0(esbuild@0.20.1)): dependencies: typed-assert: 1.0.9 webpack: 5.94.0(esbuild@0.20.1) diff --git a/scripts/verify-examples.ts b/scripts/verify-examples.ts new file mode 100644 index 000000000..e9193337f --- /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 fg from 'fast-glob' + +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 fg('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 new file mode 100644 index 000000000..0127d7b0f --- /dev/null +++ b/scripts/verify-links.ts @@ -0,0 +1,235 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import path, { dirname, resolve } from 'node:path' +import fg from 'fast-glob' +// @ts-ignore +import markdownLinkExtractor from 'markdown-link-extractor' + +type LinkError = { + link: 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://') && + !link.startsWith('//') && + !link.startsWith('#') && + !link.startsWith('mailto:') + ) +} + +function isInside(root: string, target: string) { + const relative = path.relative(root, target) + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ) +} + +function stripHashAndQuery(link: string) { + return link.split(/[?#]/)[0] +} + +function examplePathFromRoute(route: string) { + const match = route.match(/^framework\/([^/]+)\/examples\/(.+)$/) + + if (!match) { + return null + } + + 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, + reason: 'navigates outside docs', + resolvedPath: absPath, + source: markdownFile, + }) + return false + } + + 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, + }) + } + + return exists + } + + if (!path.extname(absPath)) { + absPath = `${absPath}.md` + } + + const exists = existsSync(absPath) + + if (!exists) { + errors.push({ + link, + reason: 'not found', + resolvedPath: absPath, + source: markdownFile, + }) + } + + return exists +} + +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 +} + +async function verifyLinks() { + const markdownFiles = await fg('docs/**/*.md', { + ignore: ['**/node_modules/**'], + }) + + console.log(`Found ${markdownFiles.length} markdown files\n`) + + const errors: Array = [] + + for (const file of markdownFiles) { + const content = readFileSync(file, 'utf-8') + const links: Array = markdownLinkExtractor(content) + + const filteredLinks = links.filter((link: any) => { + if (typeof link === 'string') { + return isRelativeLink(link) + } else if (link && typeof link.href === 'string') { + return isRelativeLink(link.href) + } + return false + }) + + if (filteredLinks.length > 0) { + filteredLinks.forEach((link: any) => { + const href = typeof link === 'string' ? link : link.href + fileExistsForMarkdownLink(href, file, errors) + }) + } + } + + 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 fg('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(`\nFound ${errors.length} broken links or routes:`) + errors.forEach((err) => { + console.log( + `${err.link}\n in: ${err.source}\n path: ${err.resolvedPath}\n why: ${err.reason}\n`, + ) + }) + process.exit(1) + } else { + console.log('\nNo broken links or routes found!') + } +} + +verifyLinks().catch(console.error)