From 3f1fdf3e97818a4ccc4e82aa49e0d743a3d89c8e Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Fri, 11 Sep 2026 18:11:52 -0600 Subject: [PATCH] feat(docs): show verified freshness and tested versions --- docs-freshness.md | 11 ++++ src/components/Doc.tsx | 21 ++++++++ .../_library/$libraryId/$version.docs.$.tsx | 3 +- .../$version.docs.framework.$framework.$.tsx | 3 +- src/utils/docs-freshness.ts | 32 +++++++++++ src/utils/docs-redirects.ts | 1 + src/utils/docs.functions.ts | 53 +++++++++++++------ src/utils/documents.server.ts | 38 ++++++++++++- src/utils/sitemap.ts | 6 ++- tests/docs-freshness.test.ts | 50 +++++++++++++++++ tests/docs-manifest-concurrency.test.ts | 52 ++++++++++++++++++ tests/docs-reference-freshness.test.ts | 36 +++++++++++++ 12 files changed, 287 insertions(+), 19 deletions(-) create mode 100644 docs-freshness.md create mode 100644 src/utils/docs-freshness.ts create mode 100644 tests/docs-freshness.test.ts create mode 100644 tests/docs-reference-freshness.test.ts diff --git a/docs-freshness.md b/docs-freshness.md new file mode 100644 index 000000000..a974433f9 --- /dev/null +++ b/docs-freshness.md @@ -0,0 +1,11 @@ +# Documentation freshness + +Documentation frontmatter supports optional `updated` and `testedWith` fields. Use a quoted YYYY-MM-DD date for `updated` and a mapping of package names to exact installed versions for `testedWith`. + +Set `updated` to the date of a substantive content change. Keep it unchanged for rebuilds, cache refreshes, formatting-only changes, and unrelated repository commits. Do not bulk-stamp old documents with today's date. The page and its sitemap entry use the same validated value; documents without one stay undated. + +Set `testedWith` only after running the documented example or recipe. Read exact installed package versions from that environment, not manifest ranges or the repository's version label. Record the command, source revision, and outcome in the pull request. Retest when the recipe changes. This field records recipe compatibility, not a claim that the whole framework test suite passed. + +A document with `ref` can replace source text or sections. It must declare its own tested versions for that rendered recipe; source-only test claims are not inherited. Its update date is the latest validated date among all documents in its reference chain, and is omitted if any source lacks a date. + +Check changed and undated page HTML, canonical sitemap entries, and reference behavior before merging. Never substitute request or deployment timestamps for missing content history. diff --git a/src/components/Doc.tsx b/src/components/Doc.tsx index a58a2df54..78c7fe852 100644 --- a/src/components/Doc.tsx +++ b/src/components/Doc.tsx @@ -1,3 +1,4 @@ +import type { readDocsFreshness } from '~/utils/docs-freshness' import * as React from 'react' import { ArrowsInLineHorizontalIcon, @@ -20,6 +21,7 @@ import { } from '~/utils/start-hosting-guide' type DocProps = { + freshness?: ReturnType title: string content: string repo: string @@ -42,6 +44,7 @@ type DocProps = { } export function Doc({ + freshness, title, content, repo, @@ -194,6 +197,24 @@ export function Doc({ ) : null } /> + {freshness && (freshness.updated || freshness.packages.length > 0) ? ( +
+ {freshness.updated ? ( +

+ Updated{' '} + +

+ ) : null} + {freshness.packages.length > 0 ? ( +

+ Tested with{' '} + {freshness.packages + .map(({ name, version }) => `${name} ${version}`) + .join(', ')} +

+ ) : null} +
+ ) : null} {footer ?? }
diff --git a/src/routes/_library/$libraryId/$version.docs.$.tsx b/src/routes/_library/$libraryId/$version.docs.$.tsx index 8b75b329b..8de53a1ef 100644 --- a/src/routes/_library/$libraryId/$version.docs.$.tsx +++ b/src/routes/_library/$libraryId/$version.docs.$.tsx @@ -129,7 +129,7 @@ export const Route = createFileRoute('/_library/$libraryId/$version/docs/$')({ function Docs() { const { version, libraryId, _splat } = Route.useParams() - const { content, filePath, title } = Route.useLoaderData() + const { content, filePath, title, freshness } = Route.useLoaderData() const versionMatch = useMatch({ from: '/_library/$libraryId/$version' }) const config = versionMatch.loaderData?.config const library = getLibrary(libraryId) @@ -140,6 +140,7 @@ function Docs() { ) { + const updated = readDate(frontmatter.updated) + const testedWith = frontmatter.testedWith + const packages = + typeof testedWith === 'object' && + testedWith !== null && + !Array.isArray(testedWith) + ? Object.entries(testedWith).flatMap(([name, version]) => + /^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(name) && + typeof version === 'string' && + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( + version, + ) + ? [{ name, version }] + : [], + ) + : [] + return { updated, packages } +} + +function readDate(value: unknown) { + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) + return undefined + const timestamp = Date.parse(`${value}T00:00:00.000Z`) + if ( + !Number.isFinite(timestamp) || + new Date(timestamp).toISOString().slice(0, 10) !== value + ) + return undefined + return value +} diff --git a/src/utils/docs-redirects.ts b/src/utils/docs-redirects.ts index 4ab83b43b..23d2a6a56 100644 --- a/src/utils/docs-redirects.ts +++ b/src/utils/docs-redirects.ts @@ -2,6 +2,7 @@ import { isValidRepoPath } from './repo-path' import { removeLeadingSlash } from './utils' export type DocsRedirectManifest = { + lastModifiedByPath?: Record paths: Array redirects: Record } diff --git a/src/utils/docs.functions.ts b/src/utils/docs.functions.ts index 5c53fbb5e..f8ac8e6a5 100644 --- a/src/utils/docs.functions.ts +++ b/src/utils/docs.functions.ts @@ -1,3 +1,4 @@ +import { readDocsFreshness } from './docs-freshness' import { notFound } from '@tanstack/react-router' import { createServerFn, createServerOnlyFn } from '@tanstack/react-start' import { setResponseHeader } from '@tanstack/react-start/server' @@ -181,23 +182,36 @@ function setDocsCacheHeaders(cdnCacheControl: string) { setResponseHeader('Cloudflare-CDN-Cache-Control', cdnCacheControl) } -function isDocsManifest(value: unknown): value is DocsManifest { - if (typeof value !== 'object' || value === null) { +export function isDocsManifest(value: unknown): value is DocsManifest { + if ( + typeof value !== 'object' || + value === null || + !('paths' in value) || + !('redirects' in value) + ) return false - } - - const candidate = value as { - paths?: unknown - redirects?: unknown - } - + if ( + !Array.isArray(value.paths) || + !value.paths.every((path) => typeof path === 'string') || + typeof value.redirects !== 'object' || + value.redirects === null || + Array.isArray(value.redirects) || + !Object.values(value.redirects).every( + (target) => typeof target === 'string', + ) + ) + return false + if ( + !('lastModifiedByPath' in value) || + value.lastModifiedByPath === undefined + ) + return true return ( - Array.isArray(candidate.paths) && - candidate.paths.every((path) => typeof path === 'string') && - typeof candidate.redirects === 'object' && - candidate.redirects !== null && - Object.entries(candidate.redirects).every( - ([key, target]) => typeof key === 'string' && typeof target === 'string', + typeof value.lastModifiedByPath === 'object' && + value.lastModifiedByPath !== null && + !Array.isArray(value.lastModifiedByPath) && + Object.values(value.lastModifiedByPath).every( + (updated) => readDocsFreshness({ updated }).updated !== undefined, ) ) } @@ -210,6 +224,7 @@ export async function collectRedirectEntriesForFile( docsRoot: string fetchFile: (filePath: string) => Promise onCanonicalPath: (canonicalPath: string) => void + onLastModified?: (canonicalPath: string, date: string) => void }, ): Promise> { const { extractFrontMatter, isRecoverableGitHubContentError } = @@ -238,6 +253,8 @@ export async function collectRedirectEntriesForFile( } const frontMatter = extractFrontMatter(file) + const updated = readDocsFreshness(frontMatter.data).updated + if (updated) opts.onLastModified?.(canonicalPath, updated) const entries: Array = [] for (const redirectFrom of frontMatter.data.redirectFrom ?? []) { @@ -280,6 +297,7 @@ async function buildDocsManifest({ node.path.endsWith('.md'), ) const paths = new Set() + const lastModifiedByPath: Record = {} // A recoverable error on one file must not fail the whole manifest build // (see collectRedirectEntriesForFile). @@ -291,11 +309,15 @@ async function buildDocsManifest({ docsRoot, fetchFile: (filePath) => fetchRepoFile(repo, branch, filePath), onCanonicalPath: (canonicalPath) => paths.add(canonicalPath), + onLastModified: (path, date) => { + lastModifiedByPath[path] = date + }, }), ) return { paths: Array.from(paths), + lastModifiedByPath, redirects: buildRedirectManifest(redirectsByFile.flat(), { label: `docs redirects for ${repo}@${branch}:${docsRoot}`, }), @@ -454,6 +476,7 @@ export const fetchDocs = createServerFn({ method: 'GET' }) frameworks: extractFrameworksFromMarkdown(frontMatter.content), filePath, frontmatter: frontMatter.data, + freshness: readDocsFreshness(frontMatter.data), } }) diff --git a/src/utils/documents.server.ts b/src/utils/documents.server.ts index 6341fec9d..0a000d907 100644 --- a/src/utils/documents.server.ts +++ b/src/utils/documents.server.ts @@ -2,7 +2,8 @@ import fs from 'node:fs' import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { parse as parseYaml } from 'yaml' +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml' +import { readDocsFreshness } from './docs-freshness' import { parseFragment } from 'parse5' import type { BlockNode, InlineNode } from '@tanstack/markdown' import { @@ -536,6 +537,7 @@ async function fetchRepoFileFromOrigin( const maxDepth = 4 let currentDepth = 1 let originFrontmatter: FrontMatterFile | undefined + const referenceMetadata: Array> = [] while (maxDepth > currentDepth) { let text: string | null @@ -557,11 +559,13 @@ async function fetchRepoFileFromOrigin( if (originFrontmatter) { text = replaceContent(text, originFrontmatter) text = replaceSections(text, originFrontmatter) + text = applyReferencedDocsFreshness(text, referenceMetadata) } return replaceProjectImageBranch(text, repoPair, ref) } + referenceMetadata.push(frontmatter.data) filepath = frontmatter.data.ref originFrontmatter = frontmatter } catch { @@ -574,6 +578,38 @@ async function fetchRepoFileFromOrigin( return null } +// A rendered reference page depends on every source in the chain. Tests of +// the source alone do not verify the referencing page's replacements. +export function applyReferencedDocsFreshness( + text: string, + references: Array>, +) { + const parsed = parseFrontMatter(text) + if (references.length === 0) return text + const facts = [parsed.data, ...references].map(readDocsFreshness) + const dates = facts.flatMap(({ updated }) => (updated ? [updated] : [])) + const updated = + dates.length === facts.length ? dates.sort().at(-1) : undefined + const packages = readDocsFreshness(references[0]).packages + if ( + !updated && + packages.length === 0 && + parsed.data.updated === undefined && + parsed.data.testedWith === undefined + ) + return text + const data = { ...parsed.data } + delete data.updated + delete data.testedWith + if (updated) data.updated = updated + if (packages.length > 0) { + data.testedWith = Object.fromEntries( + packages.map(({ name, version }) => [name, version]), + ) + } + return `---\n${stringifyYaml(data)}---\n${parsed.content}` +} + async function fetchRepoRawFileFromOrigin( repoPair: string, ref: string, diff --git a/src/utils/sitemap.ts b/src/utils/sitemap.ts index 60758d4c5..5f33ecf36 100644 --- a/src/utils/sitemap.ts +++ b/src/utils/sitemap.ts @@ -1,3 +1,4 @@ +import { readDocsFreshness } from './docs-freshness' import { getBranch, libraries } from '~/libraries' import type { LibrarySlim } from '~/libraries/types' import { getPublishedPosts } from '~/utils/blog' @@ -88,13 +89,16 @@ async function getLibraryDocsEntries( repo: library.repo, branch, docsRoot, - }).catch(() => ({ paths: [], redirects: {} })) + }).catch(() => ({ paths: [], redirects: {}, lastModifiedByPath: undefined })) return manifest.paths .filter(Boolean) .filter(isHighValueDocsSlug) .map((slug) => ({ path: `/${library.id}/latest/docs/${slug}`, + lastModified: readDocsFreshness({ + updated: manifest.lastModifiedByPath?.[slug], + }).updated, })) } diff --git a/tests/docs-freshness.test.ts b/tests/docs-freshness.test.ts new file mode 100644 index 000000000..e3b6d4f07 --- /dev/null +++ b/tests/docs-freshness.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { readDocsFreshness } from '../src/utils/docs-freshness' + +test('freshness uses authored metadata and ignores rebuild timestamps', () => { + assert.deepEqual( + readDocsFreshness({ updated: '2026-09-11', cachedAt: Date.now() }), + { updated: '2026-09-11', packages: [] }, + ) + assert.deepEqual( + readDocsFreshness({ cachedAt: Date.now(), lastFetched: '2026-09-11' }), + { updated: undefined, packages: [] }, + ) +}) + +test('invalid calendar dates and timestamp-shaped dates are omitted', () => { + for (const updated of [ + '2026-02-29', + '2026-04-31', + '2026-13-01', + 'yesterday', + '2026-09-11T00:00:00Z', + 20260911, + ]) { + assert.equal(readDocsFreshness({ updated }).updated, undefined) + } + assert.equal( + readDocsFreshness({ updated: '2024-02-29' }).updated, + '2024-02-29', + ) +}) + +test('tested packages require exact versions, not moving ranges or tags', () => { + assert.deepEqual( + readDocsFreshness({ + testedWith: { + '@tanstack/react-start': '1.168.52', + react: '19.2.3', + vite: '^8.0.14', + nitro: 'latest', + bad: 3, + }, + }).packages, + [ + { name: '@tanstack/react-start', version: '1.168.52' }, + { name: 'react', version: '19.2.3' }, + ], + ) + assert.deepEqual(readDocsFreshness({ testedWith: ['react'] }).packages, []) +}) diff --git a/tests/docs-manifest-concurrency.test.ts b/tests/docs-manifest-concurrency.test.ts index 40e7e83e1..875162bf2 100644 --- a/tests/docs-manifest-concurrency.test.ts +++ b/tests/docs-manifest-concurrency.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { collectRedirectEntriesForFile, + isDocsManifest, mapWithConcurrency, type DocsTreeNode, } from '../src/utils/docs.functions' @@ -130,3 +131,54 @@ test('a mix of one failing file and several succeeding files still produces ever }) console.log('buildDocsManifest per-file fault tolerance tests passed') + +test('manifest dates belong to individual canonical pages and preserve undated pages', async () => { + const dates: Record = {} + const contents: Record = { + 'docs/guide/a/index.md': '---\nupdated: "2026-09-01"\n---\n# A', + 'docs/guide/b.md': '---\nupdated: "2026-09-02"\n---\n# B', + 'docs/guide/c.md': '# C', + } + const collect = async () => { + for (const path of Object.keys(contents)) { + await collectRedirectEntriesForFile( + { path }, + { + docsRoot: 'docs', + fetchFile: async (filePath) => contents[filePath], + onCanonicalPath: () => {}, + onLastModified: (slug, date) => { + dates[slug] = date + }, + }, + ) + } + } + await collect() + assert.deepEqual(dates, { 'guide/a': '2026-09-01', 'guide/b': '2026-09-02' }) + contents['docs/guide/a/index.md'] = + '---\nupdated: "2026-09-03"\n---\n# A revised' + await collect() + assert.deepEqual(dates, { 'guide/a': '2026-09-03', 'guide/b': '2026-09-02' }) +}) + +test('manifest cache accepts older records and rejects invalid date maps', () => { + const base = { paths: ['guide/a'], redirects: {} } + assert.equal(isDocsManifest(base), true) + assert.equal( + isDocsManifest({ + ...base, + lastModifiedByPath: { 'guide/a': '2026-09-11' }, + }), + true, + ) + for (const lastModifiedByPath of [ + null, + [], + '2026-09-11', + { 'guide/a': '2026-02-30' }, + { 'guide/a': 42 }, + ]) { + assert.equal(isDocsManifest({ ...base, lastModifiedByPath }), false) + } +}) diff --git a/tests/docs-reference-freshness.test.ts b/tests/docs-reference-freshness.test.ts new file mode 100644 index 000000000..f96cef7f3 --- /dev/null +++ b/tests/docs-reference-freshness.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + applyReferencedDocsFreshness, + extractFrontMatter, +} from '../src/utils/documents.server' + +const source = + '---\ntitle: Shared\nupdated: "2026-09-01"\ntestedWith:\n react: "19.2.3"\n---\nThe body stays unchanged.\n' + +test('references do not inherit source-only freshness claims', () => { + const result = extractFrontMatter( + applyReferencedDocsFreshness(source, [{ ref: 'shared.md' }]), + ) + assert.equal(result.data.updated, undefined) + assert.equal(result.data.testedWith, undefined) + assert.equal(result.data.title, 'Shared') + assert.equal(result.content, 'The body stays unchanged.\n') +}) + +test('all known source dates contribute but tested versions belong to the visible wrapper', () => { + const result = extractFrontMatter( + applyReferencedDocsFreshness(source, [ + { updated: '2026-09-02', testedWith: { react: '19.2.4' } }, + { updated: '2026-09-03', testedWith: { react: '19.1.0' } }, + ]), + ) + assert.equal(result.data.updated, '2026-09-03') + assert.deepEqual(result.data.testedWith, { react: '19.2.4' }) + assert.equal( + extractFrontMatter( + applyReferencedDocsFreshness(source, [{ updated: '2026-09-02' }, {}]), + ).data.updated, + undefined, + ) +})