Skip to content
Open
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
11 changes: 11 additions & 0 deletions docs-freshness.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions src/components/Doc.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { readDocsFreshness } from '~/utils/docs-freshness'
import * as React from 'react'
import {
ArrowsInLineHorizontalIcon,
Expand All @@ -20,6 +21,7 @@ import {
} from '~/utils/start-hosting-guide'

type DocProps = {
freshness?: ReturnType<typeof readDocsFreshness>
title: string
content: string
repo: string
Expand All @@ -42,6 +44,7 @@ type DocProps = {
}

export function Doc({
freshness,
title,
content,
repo,
Expand Down Expand Up @@ -194,6 +197,24 @@ export function Doc({
) : null
}
/>
{freshness && (freshness.updated || freshness.packages.length > 0) ? (
<div className="mt-6 text-sm text-gray-600 dark:text-gray-400">
{freshness.updated ? (
<p>
Updated{' '}
<time dateTime={freshness.updated}>{freshness.updated}</time>
</p>
) : null}
{freshness.packages.length > 0 ? (
<p>
Tested with{' '}
{freshness.packages
.map(({ name, version }) => `${name} ${version}`)
.join(', ')}
</p>
) : null}
</div>
) : null}
{footer ?? <DocNavigation />}
<div className="h-4" />
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/routes/_library/$libraryId/$version.docs.$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -140,6 +140,7 @@ function Docs() {
<DocContainer>
<Doc
title={title}
freshness={freshness}
content={content}
repo={library.repo}
branch={branch}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export const Route = createFileRoute(
})

function Docs() {
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 { version, libraryId, framework } = Route.useParams()
Expand All @@ -137,6 +137,7 @@ function Docs() {
<Doc
key={filePath}
title={title}
freshness={freshness}
content={content}
repo={library.repo}
branch={branch}
Expand Down
32 changes: 32 additions & 0 deletions src/utils/docs-freshness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// These are authored source facts, never cache or request timestamps.
export function readDocsFreshness(frontmatter: Record<string, unknown>) {
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
}
1 change: 1 addition & 0 deletions src/utils/docs-redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { isValidRepoPath } from './repo-path'
import { removeLeadingSlash } from './utils'

export type DocsRedirectManifest = {
lastModifiedByPath?: Record<string, string>
paths: Array<string>
redirects: Record<string, string>
}
Expand Down
53 changes: 38 additions & 15 deletions src/utils/docs.functions.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
)
)
}
Expand All @@ -210,6 +224,7 @@ export async function collectRedirectEntriesForFile(
docsRoot: string
fetchFile: (filePath: string) => Promise<string | null>
onCanonicalPath: (canonicalPath: string) => void
onLastModified?: (canonicalPath: string, date: string) => void
},
): Promise<Array<RedirectManifestEntry>> {
const { extractFrontMatter, isRecoverableGitHubContentError } =
Expand Down Expand Up @@ -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<RedirectManifestEntry> = []

for (const redirectFrom of frontMatter.data.redirectFrom ?? []) {
Expand Down Expand Up @@ -280,6 +297,7 @@ async function buildDocsManifest({
node.path.endsWith('.md'),
)
const paths = new Set<string>()
const lastModifiedByPath: Record<string, string> = {}

// A recoverable error on one file must not fail the whole manifest build
// (see collectRedirectEntriesForFile).
Expand All @@ -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}`,
}),
Expand Down Expand Up @@ -454,6 +476,7 @@ export const fetchDocs = createServerFn({ method: 'GET' })
frameworks: extractFrameworksFromMarkdown(frontMatter.content),
filePath,
frontmatter: frontMatter.data,
freshness: readDocsFreshness(frontMatter.data),
}
})

Expand Down
38 changes: 37 additions & 1 deletion src/utils/documents.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -536,6 +537,7 @@ async function fetchRepoFileFromOrigin(
const maxDepth = 4
let currentDepth = 1
let originFrontmatter: FrontMatterFile | undefined
const referenceMetadata: Array<Record<string, unknown>> = []

while (maxDepth > currentDepth) {
let text: string | null
Expand All @@ -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 {
Expand All @@ -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<Record<string, unknown>>,
) {
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,
Expand Down
6 changes: 5 additions & 1 deletion src/utils/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
}))
}

Expand Down
50 changes: 50 additions & 0 deletions tests/docs-freshness.test.ts
Original file line number Diff line number Diff line change
@@ -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, [])
})
Loading
Loading