diff --git a/app/app.config.ts b/app/app.config.ts index d9c0554..b90a00f 100644 --- a/app/app.config.ts +++ b/app/app.config.ts @@ -185,7 +185,7 @@ export default defineAppConfig({ // info: 'i-tabler-info-square-rounded-filled', }, }, - // `seo.siteName`, `header.title` and `github.*` are deliberately NOT defaulted here: modules/config.ts seeds + // `seo.siteName`, `header.title` and `github.*` are deliberately NOT defaulted here: modules/config/ seeds // them into `nuxt.options.appConfig`, and app.config values — even empty strings — would win over those. header: { to: '/', diff --git a/app/app.vue b/app/app.vue index 6584301..69f9dae 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,6 +1,5 @@ + + diff --git a/app/components/AssistantChat.vue b/app/components/AssistantChat.vue index 4adcc67..591c212 100644 --- a/app/components/AssistantChat.vue +++ b/app/components/AssistantChat.vue @@ -3,7 +3,7 @@ import { DefaultChatTransport, isReasoningUIPart, isTextUIPart, isToolUIPart, ge import { useChat } from '@ai-sdk/vue' import { isPartStreaming, isToolStreaming } from '@nuxt/ui/utils/ai' import rangi from 'comark/plugins/rangi' -import { geistTheme } from '../../utils/geist-theme' +import { geistTheme } from '../../utils/geist' const MAX_INPUT = 1000 diff --git a/app/components/landing/LandingHeroDemo.vue b/app/components/landing/LandingHeroDemo.vue index da3aaef..96cddfa 100644 --- a/app/components/landing/LandingHeroDemo.vue +++ b/app/components/landing/LandingHeroDemo.vue @@ -1,6 +1,6 @@ @@ -32,11 +29,6 @@ provide('navigation', navigation) - - - + diff --git a/app/plugins/render-tracer.server.ts b/app/plugins/render-tracer.server.ts new file mode 100644 index 0000000..cfa6fd6 --- /dev/null +++ b/app/plugins/render-tracer.server.ts @@ -0,0 +1,23 @@ +import { trace } from '@opentelemetry/api' +import { finishRenderSpan, getRenderTrace, startRenderSpan } from '../../utils/render-trace' + +export default defineNuxtPlugin({ + name: 'comark-render-tracer', + enforce: 'pre', + setup(nuxtApp) { + const event = nuxtApp.ssrContext?.event + if (!event) return + + const state = getRenderTrace(event) + if (!state?.render) return + + nuxtApp.hook('app:rendered', () => { + finishRenderSpan(state, 'vue') + state.finalize = startRenderSpan( + trace.getTracer('comark-content'), + state.isPayload ? 'nuxt:render:payload' : 'nuxt:render:finalize', + state.render + ) + }) + }, +}) diff --git a/app/utils/navigation.ts b/app/utils/navigation.ts index cff2c86..1d5508e 100644 --- a/app/utils/navigation.ts +++ b/app/utils/navigation.ts @@ -23,7 +23,7 @@ function walk(items: NavigationItem[], path: string): boolean { } // Shared with the server-side `/raw/**` mirror (server/routes/raw/[...slug].md.get.ts). -export { findFirstLeaf } from '../../utils/first-leaf' +export { findFirstLeaf } from '../../utils/navigation' export interface BreadcrumbItem { title: string diff --git a/app/utils/search-sections.ts b/app/utils/search-sections.ts deleted file mode 100644 index 4aa65d9..0000000 --- a/app/utils/search-sections.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defineContentClientPlugin } from 'comark-content/client' -import { joinURL } from 'ufo' - -/** One search entry per document heading — consumed by `UContentSearch`. */ -export interface SearchSection { - id: string - title: string - titles: string[] - level: number - content: string -} - -interface SearchSectionsClientMethods { - searchSections(): Promise -} - -/** Client half of the `search-sections` serve handler (`server/utils/content.ts`); adds `content.searchSections()`. */ -export const searchSectionsClient = defineContentClientPlugin, SearchSectionsClientMethods>(() => ({ - name: 'search-sections', - setup: ({ options }) => ({ - searchSections: () => options.fetch(joinURL(options.baseURL, options.basePath, 'search-sections')), - }), -})) diff --git a/app/workers/internal/search-logger.ts b/app/workers/internal/search-logger.ts new file mode 100644 index 0000000..8917943 --- /dev/null +++ b/app/workers/internal/search-logger.ts @@ -0,0 +1,69 @@ +/** + * Logging for the search worker. + * + * Triggered by `?debug=search` param. + */ +import type { ContentFile, Logger, RelationalDatabase } from 'comark-content' + +const PREFIX = '[search:worker]' + +let debug = false + +/** Called on every `warmup`; once on, it stays on for the life of the worker. */ +export function setDebug(value: boolean): void { + debug = debug || value +} + +export function isDebug(): boolean { + return debug +} + +export function log(...args: unknown[]): void { + if (debug) console.info(PREFIX, ...args) +} + +/** Milliseconds since `from`, for log lines. */ +export function since(from: number): string { + return `${(performance.now() - from).toFixed(1)}ms` +} + +/** + * Warn and error are deliberately ungated: the FTS plugin reports a missing snapshot through this + * channel, and that failure is otherwise indistinguishable from "the query matched nothing". + */ +export const logger: Logger = { + debug: (tag, ...args) => log(`${tag}:`, ...args), + info: (tag, ...args) => log(`${tag}:`, ...args), + warn: (tag, ...args) => console.warn(`${PREFIX} ${tag}:`, ...args), + error: (tag, ...args) => console.error(`${PREFIX} ${tag}:`, ...args), +} + +/** + * What a decoded artifact holds: a snapshot decodes to the source's items, the manifest to an object + * keyed by path. `with nodes` is the number that matters — the FTS plugin indexes + * `kind === 'document' && nodes?.length`, so a bodies-less (partial) snapshot builds an empty index. + */ +export function describeArtifact(decoded: unknown): string { + if (Array.isArray(decoded)) { + const items = decoded as ContentFile[] + const documents = items.filter((item) => item.meta.kind === 'document') + const withNodes = documents.filter((item) => item.nodes?.length) + return `${items.length} item(s), ${documents.length} document(s), ${withNodes.length} with nodes` + } + const items = (decoded as { items?: Record } | null)?.items + return `${items ? Object.keys(items).length : 0} manifest item(s)` +} + +/** + * Rows in the FTS plugin's index — the one number that separates "nothing was indexed" from "the + * query found nothing", since `search()` catches SQL errors and returns `[]` either way. Reads the + * plugin's private table, so it is a diagnostic, not something to build on. + */ +export async function indexedRows(database: RelationalDatabase, source: string): Promise { + try { + const rows = await database.all<{ n: number }>('SELECT count(*) as n FROM __fts_search WHERE source = ?', [source]) + return rows?.[0]?.n ?? 'unknown' + } catch (error) { + return `unknown (${error instanceof Error ? error.message : String(error)})` + } +} diff --git a/app/workers/search.ts b/app/workers/search.ts new file mode 100644 index 0000000..46d4394 --- /dev/null +++ b/app/workers/search.ts @@ -0,0 +1,118 @@ +/** + * Search worker: owns the browser-standalone `comark-content` instance (sqlite-wasm FTS5). + * + * Hydrated from the per-commit snapshot artifacts. + */ +import { comarkContent, DEFAULT_CONTENT_NAME, readArtifact } from 'comark-content' +import sqliteWasm from 'comark-content/database/sqlite-wasm' +import snapshot from 'comark-content/sources/snapshot' +import sqliteFullTextSearch from 'comark-content/plugins/sqlite-full-text-search' +import { ofetch } from 'ofetch' +import { describeArtifact, indexedRows, isDebug, log, logger, setDebug, since } from './internal/search-logger' +import type { CacheArtifact, SearchOptions, SearchResult } from 'comark-content' + +/** + * Factored out so `SearchInstance` can be derived from its return type instead of annotated — + * `ComarkContent`'s instance-name parameter reaches `get()`'s argument type, so a bare + * `ComarkContent & SqliteFullTextSearchMethods` annotation isn't a supertype of a concrete + * instance (fails under `strictFunctionTypes`, same reason as `DocsContent` in + * `server/utils/content.ts`). + */ +function createSearchInstance(fetchArtifact: (path: string) => Promise, apiBase: string) { + const database = sqliteWasm() + return { + database, + content: comarkContent({ + // The first (full-body) tier is what the index is built from; the second (manifest) tier + // is the light one `init()` prefers, so a bare `init()` below doesn't download bodies that + // `search()` is about to fetch anyway via the snapshot tier. + source: snapshot( + () => fetchArtifact(`${apiBase}/snapshot/${DEFAULT_CONTENT_NAME}.json`), + () => fetchArtifact(`${apiBase}/manifest.json`) + ), + plugins: [sqliteFullTextSearch({ database })], + logger, + }), + } +} + +type SearchInstance = ReturnType['content'] + +let instance: SearchInstance | undefined + +/** + * The in-flight hydration. + * + * Ensures only one hydration runs at a time. + */ +let hydration: Promise | undefined + +/** Loads the database. No-op once ready; retries after a failure. */ +export function warmupSearch(apiBase: string, origin: string, debug: boolean): Promise { + setDebug(debug) + if (instance) { + log('warmup ignored — already ready') + return Promise.resolve() + } + hydration ||= loadDatabase(apiBase, origin).catch((error) => { + hydration = undefined // clears the guard so the next warmup can retry + throw error + }) + return hydration +} + +async function loadDatabase(apiBase: string, origin: string): Promise { + const started = performance.now() + try { + const fetchArtifact = async (path: string): Promise => { + const url = new URL(path, origin).href + const fetchStarted = performance.now() + try { + const artifact = await ofetch(url) + if (isDebug()) { + let contents: string + try { + contents = describeArtifact(await readArtifact(artifact)) + } catch (error) { + contents = `undecodable: ${error instanceof Error ? error.message : String(error)}` + } + log(`fetched ${path} in ${since(fetchStarted)} — ${artifact?.size ?? 0} bytes, ${contents}`) + } + return artifact + } catch (error) { + log(`failed ${path} after ${since(fetchStarted)}`, error) + throw error + } + } + + const { database, content } = createSearchInstance(fetchArtifact, apiBase) + + await content.init() + + const indexStarted = performance.now() + await content.search('') // pulls the snapshot in and builds the FTS index + log(`index built in ${since(indexStarted)} — ${await indexedRows(database, DEFAULT_CONTENT_NAME)} row(s)`) + + instance = content + log(`ready in ${since(started)}`) + } catch (error) { + log(`hydration failed after ${since(started)}`, error) + throw error + } +} + +/** Empty until hydration lands. */ +export async function searchContent(query: string, opts?: SearchOptions): Promise { + if (!instance) { + log(`dropped query "${query}" — no instance yet`) + return [] + } + const queryStarted = performance.now() + const results = await instance.search(query, { + limit: 25, + snippet: { columns: ['content'] }, + ...opts, + }) + log(`query "${query}" -> ${results.length} result(s) in ${since(queryStarted)}`) + return results +} diff --git a/docs/cold-page-request.md b/docs/cold-page-request.md index b75ca41..c324e87 100644 --- a/docs/cold-page-request.md +++ b/docs/cold-page-request.md @@ -24,7 +24,7 @@ sequenceDiagram else no pin or read failed Config-->>Content: undefined Content->>Refs: resolveContentSha(targetBranch, contentDir) - alt cache hit (within 60s TTL) + alt cache hit (no production TTL) Refs-->>Content: cached content sha else cache miss Refs->>GH: commits?sha=&path= @@ -51,13 +51,16 @@ its index from GitHub once per content revision, then parses one page. All reads immutable ``. Without a Global Config pin, code-only commits do not rebuild the content instance. -The ref cache is shared across *instances*, so GitHub is hit once per 60s TTL window -rather than once per cold start. It is **not** shared across regions — Vercel's -Runtime Cache is regional (see the note on `refCacheDriver()` in -`server/utils/cache.ts`), so the ceiling is one GitHub call per region per window. -This project runs single-region, which is what makes that distinction academic today. +The production branch pointer is shared across *instances* and has no TTL. The push webhook +refreshes it before purging ISR, so cold starts don't need to resolve the branch through GitHub. +Vercel's Runtime Cache is regional (see the note on `refCacheDriver()` in +`server/utils/cache.ts`), so this requires the project to run in one region. -A ref that doesn't resolve is cached too, for the same window, but **only** when the +Preview deployments, `/tree/:branch`, `/pr/:number`, and preview authorization decisions use a +600-second TTL because production webhooks don't update them. Negative ref lookups use the same TTL, +including for the production branch, so a temporary GitHub 404 cannot remain cached indefinitely. + +A ref that doesn't resolve is cached too, for 600 seconds, but **only** when the caller asks for it (`resolveContentSha(ref, contentDir, { cacheMisses: true })`) — the public `/tree/:branch` route does, so a nonexistent branch can't be replayed into one GitHub API call per request. The production branch above deliberately does not: @@ -68,8 +71,9 @@ failed request. **On a content push**, `server/api/revalidate.post.ts` forces a fresh `resolveContentSha()` lookup, which writes the latest branch content SHA into the same shared ref cache before fanning out ISR purges for the affected pages. Without a Global Config pin, a freshly-purged page's next render sees -the new SHA instead of waiting out the 60-second TTL. With a pin, the next render stays on the pinned -SHA, while the refreshed branch pointer is ready if the pin is removed. +the new SHA. With a pin, the next render stays on the pinned SHA, while the refreshed branch pointer +is ready if the pin is removed. The webhook is required for production freshness because that +pointer does not expire. Parsed manifests and bodies live under a parser-version + content-SHA namespace. Vercel Runtime Cache persists across deployments within an environment, so unrelated deployments can reuse diff --git a/modules/config.ts b/modules/config/index.ts similarity index 87% rename from modules/config.ts rename to modules/config/index.ts index 35aaa70..ae85e9a 100644 --- a/modules/config.ts +++ b/modules/config/index.ts @@ -1,10 +1,8 @@ import { existsSync, readdirSync } from 'node:fs' import { defineNuxtModule, useLogger } from '@nuxt/kit' import { defu } from 'defu' -import { resolveContentDir } from '../utils/content-dir' -import { getGitBranch, getGitEnv, getGitRoot, getLocalGitInfo } from '../utils/git' -import { LAYER_ICON_COLLECTIONS } from '../utils/icons' -import { getPackageJsonMetadata, inferSiteURL } from '../utils/meta' +import { getGitBranch, getGitEnv, getGitRoot, getLocalGitInfo } from '../../utils/git' +import { getPackageJsonMetadata, inferSiteURL, resolveContentDir } from './utils' const logger = useLogger('comark-docs') @@ -124,16 +122,6 @@ export default defineNuxtModule({ }, }) - // Drop layer Iconify prefixes from appConfig so @nuxt/icon keeps using the Iconify API (not `/api/_nuxt_icon`). - nuxt.hook('modules:done', () => { - const iconAppConfig = nuxtOptions.appConfig.icon as { customCollections?: string[] } | undefined - if (!iconAppConfig?.customCollections?.length) return - iconAppConfig.customCollections = iconAppConfig.customCollections.filter( - (prefix) => !LAYER_ICON_COLLECTIONS.includes(prefix) - ) - }) - - // Extend Nuxt UI components to make them global and usable in markdown by consumers. nuxt.hook('components:extend', (components) => { const globalComponents = ['UButton', 'UPageHero'] @@ -160,7 +148,7 @@ export default defineNuxtModule({ // Previews are served live (SSR) off Runtime Cache; `/blob/**` is immutable commit HTML. // `/pr/**` follows the PR's head like `/tree/**` follows a branch, so it shares the short TTL. '/tree/**': { isr, robots: 'noindex, nofollow' }, - '/blob/**': { isr: true, robots: 'noindex, nofollow' }, + '/blob/**': { isr: true, robots: 'noindex, nofollow' }, // Immutable since SHA-pinned '/pr/**': { isr, robots: 'noindex, nofollow' }, // Raw markdown mirrors of every page, for agents. '/raw/**': { isr, robots: 'noindex' }, @@ -168,11 +156,14 @@ export default defineNuxtModule({ '/llms.txt': { isr }, '/llms-full.txt': { isr }, '/rss.xml': { isr }, - // Fetched on every page hydration (see app.vue) and parses every doc body, so cache it. - '/api/content/blob/*/search-sections': { isr: true }, - '/api/content/tree/*/search-sections': { isr }, - '/api/content/pr/*/search-sections': { isr }, - '/api/content/search-sections': { isr }, + // Per-commit artifacts hydrating the client-side search database (see `useSearch`) + '/api/content/blob/*/manifest.json': { isr: true }, // Immutable since SHA-pinned + '/api/content/blob/*/snapshot/*': { isr: true }, // Immutable since SHA-pinned + '/api/content/tree/*/manifest.json': { isr }, + '/api/content/tree/*/snapshot/*': { isr }, + // `/pr/*` follows the PR head, so it gets the short TTL like `/tree/*`. + '/api/content/pr/*/manifest.json': { isr }, + '/api/content/pr/*/snapshot/*': { isr }, '/api/code-explorer/**': { isr }, '/_payload.json': { headers: { 'cache-control': `public, max-age=${isr}, s-maxage=${isr}, stale-while-revalidate=60` }, diff --git a/test/content-dir.test.ts b/modules/config/test/config.test.ts similarity index 60% rename from test/content-dir.test.ts rename to modules/config/test/config.test.ts index 993b7cf..be00c54 100644 --- a/test/content-dir.test.ts +++ b/modules/config/test/config.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { resolveContentDir } from '../utils/content-dir' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { inferSiteURL, resolveContentDir } from '../utils' describe('resolveContentDir', () => { it('relativises against the git root for an app in a subdirectory', () => { @@ -64,3 +64,54 @@ describe('resolveContentDir', () => { }) }) }) + +describe('inferSiteURL', () => { + const keys = [ + 'NUXT_PUBLIC_SITE_URL', + 'NUXT_SITE_URL', + 'VERCEL_PROJECT_PRODUCTION_URL', + 'VERCEL_BRANCH_URL', + 'VERCEL_URL', + 'URL', + 'CI_PAGES_URL', + 'CF_PAGES_URL', + ] + let saved: Record + + // `Reflect.deleteProperty` rather than `delete process.env[key]`: same effect, + // without tripping `no-dynamic-delete`. + const unset = (key: string) => Reflect.deleteProperty(process.env, key) + + beforeEach(() => { + saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])) + for (const key of keys) unset(key) + }) + + afterEach(() => { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) unset(key) + else process.env[key] = value + } + }) + + it('returns undefined when nothing is set', () => { + expect(inferSiteURL()).toBeUndefined() + }) + + it('adds https to a bare Vercel host', () => { + process.env.VERCEL_URL = 'my-app-abc123.vercel.app' + expect(inferSiteURL()).toBe('https://my-app-abc123.vercel.app') + }) + + it('prefers the explicit override over the platform value', () => { + process.env.VERCEL_URL = 'my-app-abc123.vercel.app' + process.env.NUXT_PUBLIC_SITE_URL = 'https://docs.example.com' + expect(inferSiteURL()).toBe('https://docs.example.com') + }) + + it('prefers the production URL over the per-branch one', () => { + process.env.VERCEL_BRANCH_URL = 'branch.vercel.app' + process.env.VERCEL_PROJECT_PRODUCTION_URL = 'docs.comark.dev' + expect(inferSiteURL()).toBe('https://docs.comark.dev') + }) +}) diff --git a/utils/content-dir.ts b/modules/config/utils.ts similarity index 61% rename from utils/content-dir.ts rename to modules/config/utils.ts index 1dcc9f1..f0c884c 100644 --- a/utils/content-dir.ts +++ b/modules/config/utils.ts @@ -1,4 +1,6 @@ -import { join, normalize, relative } from 'pathe' +import { readFile } from 'node:fs/promises' +import { join, normalize, relative, resolve } from 'pathe' +import { withHttps } from 'ufo' export interface ContentDirInput { rootDir: string @@ -37,3 +39,28 @@ export function resolveContentDir({ rootDir, gitRoot, explicit }: ContentDirInpu return { contentPath, contentDir: 'content', source: 'assumed' } } + +/** Infer the public site URL from the deployment platform env. */ +export function inferSiteURL(): string | undefined { + // https://github.com/unjs/std-env/issues/59 + const url = + process.env.NUXT_PUBLIC_SITE_URL || + process.env.NUXT_SITE_URL || + process.env.VERCEL_PROJECT_PRODUCTION_URL || + process.env.VERCEL_BRANCH_URL || + process.env.VERCEL_URL || + process.env.URL || // Netlify + process.env.CI_PAGES_URL || // GitLab Pages + process.env.CF_PAGES_URL // Cloudflare Pages + + return url ? withHttps(url) : undefined +} + +export async function getPackageJsonMetadata(dir: string): Promise<{ name?: string; description?: string }> { + try { + const parsed = JSON.parse(await readFile(resolve(dir, 'package.json'), 'utf-8')) + return { name: parsed.name, description: parsed.description } + } catch { + return {} + } +} diff --git a/modules/markdown-rewrite.ts b/modules/markdown-rewrite/index.ts similarity index 95% rename from modules/markdown-rewrite.ts rename to modules/markdown-rewrite/index.ts index 463a1a0..c1513e4 100644 --- a/modules/markdown-rewrite.ts +++ b/modules/markdown-rewrite/index.ts @@ -1,7 +1,7 @@ import { readFile, writeFile } from 'node:fs/promises' import { defineNuxtModule, useLogger } from '@nuxt/kit' import { resolve } from 'pathe' -import { buildMarkdownRewriteRoutes } from '../utils/markdown-rewrite' +import { buildMarkdownRewriteRoutes } from './utils' const logger = useLogger('comark-docs') diff --git a/test/markdown-rewrite.test.ts b/modules/markdown-rewrite/test/markdown-rewrite.test.ts similarity index 99% rename from test/markdown-rewrite.test.ts rename to modules/markdown-rewrite/test/markdown-rewrite.test.ts index 107845c..2b1db74 100644 --- a/test/markdown-rewrite.test.ts +++ b/modules/markdown-rewrite/test/markdown-rewrite.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildMarkdownRewriteRoutes, type VercelRoute } from '../utils/markdown-rewrite' +import { buildMarkdownRewriteRoutes, type VercelRoute } from '../utils' // Vercel resolves `$n` in `headers.Location` from the capture groups of `src` — replicate that to // assert on the final redirect target rather than on regex internals. diff --git a/utils/markdown-rewrite.ts b/modules/markdown-rewrite/utils.ts similarity index 100% rename from utils/markdown-rewrite.ts rename to modules/markdown-rewrite/utils.ts diff --git a/modules/snapshot/index.ts b/modules/snapshot/index.ts new file mode 100644 index 0000000..ce2b70f --- /dev/null +++ b/modules/snapshot/index.ts @@ -0,0 +1,78 @@ +import { mkdir, stat } from 'node:fs/promises' +import { defineNuxtModule, useLogger } from '@nuxt/kit' +import { DEFAULT_CONTENT_NAME } from 'comark-content' +import { writeSnapshots } from 'comark-content/build' +import fs from 'comark-content/sources/fs' +import { join } from 'pathe' +import { createBuildContentInstance } from '../../utils/content' +import { resolveSnapshotSha } from './utils' + +const logger = useLogger('comark-docs') + +/** Where the snapshot lives in the build, and the server-asset namespace it is read back through. */ +const ASSET_BASE = 'comark-content' + +/** + * Writes a build-time snapshot into the function bundle stamped with the commit it was parsed at. + * A cold start at that commit hydrates from it instead of walking the content repository. + * At a later commit it still supplies every unchanged body. + */ +export default defineNuxtModule({ + meta: { name: 'comark-docs:snapshot' }, + setup(_options, nuxt) { + // Do not run in dev or prepare. + if (nuxt.options.dev || nuxt.options._prepare) return + + const dir = join(nuxt.options.buildDir, ASSET_BASE) + + nuxt.hook('modules:done', async () => { + await mkdir(dir, { recursive: true }) + nuxt.options.nitro.serverAssets = [ + ...(nuxt.options.nitro.serverAssets ?? []), + { baseName: ASSET_BASE, dir }, + ] + }) + + nuxt.hook('build:before', async () => { + const { docs } = nuxt.options.runtimeConfig + const { repoRoot, contentDir, contentPath, github } = docs + + const resolveStart = performance.now() + const sha = await resolveSnapshotSha({ + repoRoot, + contentDir, + repo: `${github.owner}/${github.repo}`, + token: docs.githubToken || process.env.NUXT_DOCS_GITHUB_TOKEN || process.env.GITHUB_TOKEN, + warn: (message) => logger.warn(message), + }) + const resolveMs = Math.round(performance.now() - resolveStart) + if (!sha) { + logger.warn( + 'No commit in this checkout could be confirmed to hold the content being built, ' + + 'so no snapshot is shipped: cold starts will walk the content repository.' + ) + return + } + + // `withRef` stamps the artifact with the commit. + // At runtime, even with a different commit, we can reuse unchanged bodies. + const content = createBuildContentInstance({ source: fs(contentPath) }).withRef(sha) + + try { + const writeStart = performance.now() + await writeSnapshots(content, { dir }) + const writeMs = Math.round(performance.now() - writeStart) + + // Size is the number to watch: the snapshot is inlined into the bundle as a string. + // Every cold start that reads it pays for that. + const { size } = await stat(join(dir, DEFAULT_CONTENT_NAME, 'snapshot.json')) + logger.success( + `Content snapshot ${sha.slice(0, 7)}: ${Math.round(size / 1024)} kB parsed and written in ${writeMs}ms ` + + `(ref resolved in ${resolveMs}ms)` + ) + } catch (error) { + logger.warn('Could not write the content snapshot — cold starts will walk the content repository.', error) + } + }) + }, +}) diff --git a/modules/snapshot/test/snapshot.test.ts b/modules/snapshot/test/snapshot.test.ts new file mode 100644 index 0000000..65cd868 --- /dev/null +++ b/modules/snapshot/test/snapshot.test.ts @@ -0,0 +1,116 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveSnapshotSha } from '../utils' + +const SHA = (char: string) => char.repeat(40) + +describe('resolveSnapshotSha', () => { + let repo: string + let contentCommit: string + let head: string + + const run = (...args: string[]) => + execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() + + const write = async (file: string, body: string) => { + await mkdir(dirname(join(repo, file)), { recursive: true }) + await writeFile(join(repo, file), body, 'utf8') + } + + /** Answers the commits query with `sha` per requested ref; `null` means 404. */ + function stubApi(bySha: Record) { + return vi.fn(async (url: string | URL) => { + const ref = new URL(String(url)).searchParams.get('sha') ?? '' + const answer = bySha[ref] + if (answer === undefined || answer === null) return new Response('[]', { status: 404 }) + return new Response(JSON.stringify([{ sha: answer }]), { status: 200 }) + }) + } + + beforeEach(async () => { + repo = await mkdtemp(join(tmpdir(), 'comark-snapshot-sha-')) + run('init', '-q', '-b', 'main') + run('config', 'user.email', 'test@example.com') + run('config', 'user.name', 'Test') + + await write('content/index.md', '# one\n') + run('add', '-A') + run('commit', '-qm', 'add content') + contentCommit = run('rev-parse', 'HEAD') + + // A later commit that leaves `content/` alone, so HEAD is not the last content commit. + await write('src/app.ts', 'export const a = 1\n') + run('add', '-A') + run('commit', '-qm', 'add code') + head = run('rev-parse', 'HEAD') + }) + + afterEach(async () => { + await rm(repo, { recursive: true, force: true }) + vi.unstubAllGlobals() + }) + + const input = () => ({ repoRoot: repo, contentDir: 'content', repo: 'owner/name', token: 'tok' }) + + it('walks from the built commit, not the branch', async () => { + // The distinction that keeps a mid-build push (or a redeploy of an older commit) from labelling + // the snapshot with content it does not hold. + const fetchMock = stubApi({ [head]: contentCommit, main: SHA('f') }) + vi.stubGlobal('fetch', fetchMock) + + expect(await resolveSnapshotSha(input())).toBe(contentCommit) + + const requested = new URL(String(fetchMock.mock.calls[0]![0])).searchParams + expect(requested.get('sha')).toBe(head) + expect(requested.get('path')).toBe('content') + expect(requested.get('per_page')).toBe('1') + }) + + it('falls back to a tree-verified git answer when the API fails', async () => { + vi.stubGlobal('fetch', stubApi({})) + + // Full history here, so git finds the true commit and its content tree matches HEAD's. + expect(await resolveSnapshotSha(input())).toBe(contentCommit) + }) + + it('skips the API when no repository is known', async () => { + const fetchMock = stubApi({ [head]: SHA('c') }) + vi.stubGlobal('fetch', fetchMock) + + expect(await resolveSnapshotSha({ ...input(), repo: '' })).toBe(contentCommit) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('ships nothing when neither the API nor git can name the content', async () => { + vi.stubGlobal('fetch', stubApi({})) + + expect(await resolveSnapshotSha({ ...input(), contentDir: 'nope' })).toBeUndefined() + }) + + it('warns on the git fallback when the answer is a shallow boundary', async () => { + vi.stubGlobal('fetch', stubApi({})) + const warn = vi.fn() + + // A one-commit repo: its only commit is parentless, which is what a depth-1 clone looks like. + const shallow = await mkdtemp(join(tmpdir(), 'comark-shallow-')) + try { + const at = (...args: string[]) => execFileSync('git', args, { cwd: shallow, stdio: 'ignore' }) + at('init', '-q', '-b', 'main') + at('config', 'user.email', 'test@example.com') + at('config', 'user.name', 'Test') + await mkdir(join(shallow, 'content'), { recursive: true }) + await writeFile(join(shallow, 'content/index.md'), '# one\n', 'utf8') + at('add', '-A') + at('commit', '-qm', 'init') + + expect(await resolveSnapshotSha({ ...input(), repoRoot: shallow, warn })).toMatch(/^[0-9a-f]{40}$/) + expect(warn).toHaveBeenCalledOnce() + expect(warn.mock.calls[0]![0]).toContain('shallow clone boundary') + } finally { + await rm(shallow, { recursive: true, force: true }) + } + }) +}) diff --git a/modules/snapshot/utils.ts b/modules/snapshot/utils.ts new file mode 100644 index 0000000..af03c95 --- /dev/null +++ b/modules/snapshot/utils.ts @@ -0,0 +1,62 @@ +import { getLastCommit, getTreeSha, hasParent, headCommit } from '../../utils/git' +import { fetchLastContentCommit } from '../../utils/github' + +export interface SnapshotShaInput { + /** Repository root of the checkout being built. */ + repoRoot: string + /** Content directory, relative to the repository root. */ + contentDir: string + /** `owner/name` of the content repository. Empty when the checkout has no usable remote. */ + repo: string + /** GitHub token, if the build has one. Without it only the git fallback runs. */ + token?: string + /** Reported to the caller; defaults to `console.warn`. */ + warn?: (message: string) => void +} + +/** {@link fetchLastContentCommit}, but never throwing: a build-time optimization must not fail a build. */ +async function lastContentCommit( + repo: string, + contentDir: string, + ref: string, + token?: string +): Promise { + try { + const sha = await fetchLastContentCommit({ repo, path: contentDir, ref, token }) + // Validated here rather than in the shared query: this one names a directory in the build. + return sha && /^[0-9a-f]{40}$/.test(sha) ? sha : undefined + } catch { + return undefined + } +} + +/** + * The commit whose `contentDir` holds the content being parsed. + * The only ref the snapshot may be stored under. + * The same one `resolveContentSha()` resolves at runtime. + */ +export async function resolveSnapshotSha(input: SnapshotShaInput): Promise { + const { repoRoot, contentDir, repo, token } = input + const warn = input.warn ?? ((message: string) => console.warn(message)) + + const head = headCommit(repoRoot) + const fromApi = head && repo ? await lastContentCommit(repo, contentDir, head, token) : undefined + if (fromApi) return fromApi + + // No API answer: fall back to git, which needs the tree check to be trustworthy. + const parsed = getTreeSha(repoRoot, 'HEAD', contentDir) + const fromGit = getLastCommit(repoRoot, contentDir) + if (!parsed || !fromGit) return undefined + + if (getTreeSha(repoRoot, fromGit, contentDir) !== parsed) return undefined + + if (!hasParent(repoRoot, fromGit)) { + warn( + `Could not reach the GitHub API, and git labels the snapshot ${fromGit.slice(0, 7)}, ` + + `which has no parent in this checkout — a shallow clone boundary.\n` + + ` The snapshot is safe, but probably will not be looked up under that commit at runtime.` + ) + } + + return fromGit +} diff --git a/nuxt.config.ts b/nuxt.config.ts index ae1c915..e83de27 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,6 +1,6 @@ import { resolveModulePath } from 'exsolve' import { defineNuxtConfig } from 'nuxt/config' -import { layerIconCollections } from './utils/icons' +import { LAYER_ICON_COLLECTIONS, layerIconAliases } from './utils/icons' export default defineNuxtConfig({ compatibilityDate: '2026-06-09', @@ -14,6 +14,7 @@ export default defineNuxtConfig({ 'nuxt-og-image', '@nuxtjs/mcp-toolkit', 'nuxt-llms', + 'nuxt-workers', ], ignore: ['content/**'], ui: { content: true, prose: true }, @@ -22,29 +23,31 @@ export default defineNuxtConfig({ }, ogImage: { zeroRuntime: false }, icon: { - provider: 'iconify', - customCollections: layerIconCollections() as never, - clientBundle: { - scan: true, - includeCustomCollections: false - }, + provider: 'server', + fallbackToApi: 'client-only', + serverBundle: { collections: LAYER_ICON_COLLECTIONS }, + clientBundle: { scan: true }, }, vite: { resolve: { alias: { 'beautiful-mermaid': resolveModulePath('beautiful-mermaid', { from: import.meta.url }) }, }, + worker: { format: 'es' }, optimizeDeps: { include: [ 'beautiful-mermaid', 'comark-docs > ai > @ai-sdk/gateway > @vercel/oidc', 'js-yaml' ], + // Pre-bundling would break the wasm/worker assets sqlite loads relative to its module URL. + exclude: ['@sqlite.org/sqlite-wasm'], }, }, llms: { prerender: false, }, nitro: { + alias: layerIconAliases(), vercel: { config: { bypassToken: process.env.VERCEL_BYPASS_TOKEN, diff --git a/package.json b/package.json index 99d5a6c..8782d3d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,10 @@ "url": "git+https://github.com/comarkdown/comark-docs.git" }, "license": "MIT", + "packageManager": "pnpm@11.25.0", + "engines": { + "node": "^22.19.0 || ^24.11.0 || >=26.0.0" + }, "files": [ "app", "server", @@ -37,18 +41,21 @@ "@ai-sdk/gateway": "^4.0.62", "@ai-sdk/vue": "^4.0.77", "@comark/nuxt": "^0.6.2", + "@iconify-json/logos": "^1.2.14", "@iconify-json/lucide": "^1.2.125", "@iconify-json/simple-icons": "^1.2.93", + "@iconify-json/unjs": "^1.2.4", "@iconify-json/vscode-icons": "^1.2.74", "@iconify/vue": "^5.0.1", "@nuxt/kit": "^4.5.2", - "@nuxt/ui": "https://pkg.pr.new/@nuxt/ui@fbb9e22", + "@nuxt/ui": "^4.11.1", "@nuxtjs/mcp-toolkit": "^0.18.1", "@nuxtjs/robots": "^6.2.0", "@nuxtjs/sitemap": "^8.5.0", "@octokit/webhooks-methods": "^6.0.0", "@opentelemetry/api": "^1.9.1", "@resvg/resvg-js": "^2.6.2", + "@sqlite.org/sqlite-wasm": "3.53.0-build1", "@vercel/analytics": "^2.0.1", "@vercel/functions": "^3.9.5", "@vercel/global-config": "^1.5.1", @@ -58,7 +65,7 @@ "ai": "^7.0.77", "beautiful-mermaid": "^1.1.3", "comark": "^0.6.2", - "comark-content": "https://pkg.pr.new/comark-content@baefd4d", + "comark-content": "https://pkg.pr.new/comark-content@a64a262", "defu": "^6.1.7", "exsolve": "^1.1.1", "js-yaml": "^5.3.0", @@ -66,6 +73,7 @@ "nuxt-llms": "https://pkg.pr.new/nuxt-content/nuxt-llms/nuxt-llms@f6a9730", "nuxt-og-image": "^6.7.8", "nuxt-seo-utils": "^8.4.2", + "nuxt-workers": "^0.1.0", "pathe": "^2.0.3", "rangi": "^2.2.0", "satori": "^0.29.1", diff --git a/playground/content/3.concepts/1.architecture.md b/playground/content/3.concepts/1.architecture.md index 56455cb..5f661e2 100644 --- a/playground/content/3.concepts/1.architecture.md +++ b/playground/content/3.concepts/1.architecture.md @@ -23,7 +23,7 @@ In development, none of this applies: content is read straight from your working Production doesn't read "whatever is on `main` right now." On each server render, comark-docs first checks a connected Vercel Global Config store for a `contentSha` value. When the value exists, every production content read is pinned to that commit. You can use this override to hold production on a reviewed version or roll content back without changing the production branch. See [Pin production content](/deployment/vercel#pin-production-content) for setup and cache timing. -Without a `contentSha` value, the server resolves the latest commit **touching the content directory** on the production branch. A shared, 60-second-TTL cache keeps this to about one GitHub call per minute. If Global Config is unavailable, comark-docs also falls back to this branch-based resolution. +Without a `contentSha` value, the server resolves the latest commit **touching the content directory** on the production branch. The shared pointer does not expire in production; the push webhook refreshes it before purging affected pages. If Global Config is unavailable, comark-docs also falls back to this branch-based resolution. The Global Config pin applies only to the production Vercel environment. Preview deployments continue to follow their target branch, and local development reads from your working tree. @@ -69,7 +69,7 @@ sequenceDiagram The handler verifies the webhook signature with `WEBHOOK_SECRET`, resolves the new content SHA, diffs the file manifests to find affected pages, and purges exactly those from the ISR cache. The next request renders from the new commit — typically live within seconds of the push. -Without the webhook, the site still updates: ISR entries expire on their own after the `isr` window. The webhook just makes it immediate. +The webhook is required to advance the production pointer. ISR entries still expire after the `isr` window, but they render from the existing content SHA until a webhook refreshes it. ## Markdown for agents diff --git a/playground/content/3.concepts/2.versioned-previews.md b/playground/content/3.concepts/2.versioned-previews.md index d054546..91ba73e 100644 --- a/playground/content/3.concepts/2.versioned-previews.md +++ b/playground/content/3.concepts/2.versioned-previews.md @@ -29,7 +29,7 @@ So a commit only renders under `/blob/:sha` when at least one of these holds: - A pull request from your own repository contains it. Contributors with push access could publish a `/tree/` preview anyway, so their PRs need no extra step. - A pull request from a fork contains it **and** a maintainer added the `preview:enabled` label to that PR. -`/pr/:number` follows the same rule: same-repo PRs always render, fork PRs only with the `preview:enabled` label. Removing the label revokes both within about a minute (the decision cache's TTL). +`/pr/:number` follows the same rule: same-repo PRs always render, fork PRs only with the `preview:enabled` label. Removing the label revokes both within 10 minutes (the decision cache's TTL). Every other SHA answers 404, and `/tree/` rejects GitHub's hidden `pull//head` refs, so the label check can't be sidestepped through a branch preview. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1de8c1..82e1fb9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,18 @@ importers: '@comark/nuxt': specifier: ^0.6.2 version: 0.6.2(a7af4bed794ccaa8b491cb4661979ebb) + '@iconify-json/logos': + specifier: ^1.2.14 + version: 1.2.14 '@iconify-json/lucide': specifier: ^1.2.125 version: 1.2.125 '@iconify-json/simple-icons': specifier: ^1.2.93 version: 1.2.93 + '@iconify-json/unjs': + specifier: ^1.2.4 + version: 1.2.4 '@iconify-json/vscode-icons': specifier: ^1.2.74 version: 1.2.74 @@ -36,8 +42,8 @@ importers: specifier: ^4.5.2 version: 4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.5)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.5)(rollup@4.62.5)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))) '@nuxt/ui': - specifier: https://pkg.pr.new/@nuxt/ui@fbb9e22 - version: https://pkg.pr.new/@nuxt/ui@fbb9e22(7fbc16f2b6c23b42dc4bd9c4885c5233) + specifier: ^4.11.1 + version: 4.11.1(7fbc16f2b6c23b42dc4bd9c4885c5233) '@nuxtjs/mcp-toolkit': specifier: ^0.18.1 version: 0.18.1(@vue/compiler-sfc@3.5.41)(h3@1.15.11)(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.5)(rollup@4.62.5)(supports-color@10.2.2)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.5)(rollup@4.62.5)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)))(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(zod@4.4.3) @@ -56,6 +62,9 @@ importers: '@resvg/resvg-js': specifier: ^2.6.2 version: 2.6.2 + '@sqlite.org/sqlite-wasm': + specifier: 3.53.0-build1 + version: 3.53.0-build1 '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.146.0)(@types/node@26.2.0)(@vercel/functions@3.9.5(ws@8.21.3))(@vue/compiler-sfc@3.5.41)(db0@0.3.4)(esbuild@0.28.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.5)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.5)(rollup@4.62.5))(rollup@4.62.5)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.50.0)(typescript@6.0.3)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3))(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) @@ -84,8 +93,8 @@ importers: specifier: ^0.6.2 version: 0.6.2(beautiful-mermaid@1.1.3)(rangi@2.2.0)(shiki@4.4.3) comark-content: - specifier: https://pkg.pr.new/comark-content@baefd4d - version: https://pkg.pr.new/comark-content@baefd4d(@vercel/functions@3.9.5(ws@8.21.3))(beautiful-mermaid@1.1.3)(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(rangi@2.2.0)(shiki@4.4.3) + specifier: https://pkg.pr.new/comark-content@a64a262 + version: https://pkg.pr.new/comark-content@a64a262(@vercel/functions@3.9.5(ws@8.21.3))(beautiful-mermaid@1.1.3)(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(rangi@2.2.0)(shiki@4.4.3) defu: specifier: ^6.1.7 version: 6.1.7 @@ -107,6 +116,9 @@ importers: nuxt-seo-utils: specifier: ^8.4.2 version: 8.4.2(6c2732a7424285fd39f1e74aab3b66f7) + nuxt-workers: + specifier: ^0.1.0 + version: 0.1.0(magicast@0.5.4) pathe: specifier: ^2.0.3 version: 2.0.3 @@ -840,12 +852,18 @@ packages: '@iconify-json/carbon@1.2.25': resolution: {integrity: sha512-7GLgXnmi47Skh8DcPPiP8U3vgm3rOVziEe+flCdG/kwiVaeb649U3Eqt/ej4f3NvlZK8BrOxpR3xQlWFLZYblQ==} + '@iconify-json/logos@1.2.14': + resolution: {integrity: sha512-O36DicXkgAMT6NAsH7MTWlOql8NktHz9fBCItTjz6L/9gyRXw4vQj6qTeVgww5UZWiVugUV1MhVFA/g9HWIkBw==} + '@iconify-json/lucide@1.2.125': resolution: {integrity: sha512-tOCk1QKMtKnCfPAgZRHgjRkQTP7wF5IO+iPKvvp8vxGZYPkSLhx4HTV3Ng0pIZ3wNWrS6kVpHkunJ1dc19L1og==} '@iconify-json/simple-icons@1.2.93': resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==} + '@iconify-json/unjs@1.2.4': + resolution: {integrity: sha512-ueSrChjHps8u6jTSDYTAp+4OQ/k+E11PbKZQjKkDVnOxNpC+/fUXXkYgrnqEUgT0rSvUiS0B7X5A8GcsP/PjOA==} + '@iconify-json/vscode-icons@1.2.74': resolution: {integrity: sha512-ZVf1IM5sOvvY+0Gc6jtuD5okkd51mJe/BoVMhWJCu1WqZLSF6XgbSguxUOyuBmMjJXNPSw1OBvPwEVq1bXfiTw==} @@ -1173,6 +1191,10 @@ packages: '@nuxt/icon@2.5.1': resolution: {integrity: sha512-zBP72Po7BS+tXzoeDRA/Y9TTY77OIcNCyzfgXsOmep7zZShTEoe4p1WZBXce/9oJDK3YXmbYC3ILQ7XvqM4/XA==} + '@nuxt/kit@3.21.11': + resolution: {integrity: sha512-0Xi3tgwN77w43Q8GCPIrvWmF1J7Peehkts44E0uKNIml9lB8WoUn8YxyUjxBv47XtVR86NWoWALAT+/IEMHJEA==} + engines: {node: '>=18.12.0'} + '@nuxt/kit@4.5.2': resolution: {integrity: sha512-l66LU9DcJYjmNwqwAj2I5UGRrUbnG2DOKGChnN70zIGtn0eq/z87gi/FRgha6eMb9/FmB1PFHgtx6PWVml1C2Q==} engines: {node: '>=18.12.0'} @@ -1263,9 +1285,8 @@ packages: zod: optional: true - '@nuxt/ui@https://pkg.pr.new/@nuxt/ui@fbb9e22': - resolution: {integrity: sha512-/Jso4/TOeffrAdGOR9AYTHWVYAMMmxR+oH5duIB4n7H2Itg6X7l7AhRXNbk3yL8cO47oIuolfgT74GFuXKKp+A==, tarball: https://pkg.pr.new/@nuxt/ui@fbb9e22} - version: 4.11.0 + '@nuxt/ui@4.11.1': + resolution: {integrity: sha512-6/xTMQNO4bcVesaaiIorH65cZ3eu0xenH+gC2L/b9pfmeVc0aMGXDKpW2JxoVUlrlcHo/beoUTI0B164HcwweQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2069,6 +2090,10 @@ packages: '@speed-highlight/core@1.2.24': resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@sqlite.org/sqlite-wasm@3.53.0-build1': + resolution: {integrity: sha512-PfWPWN2n+/37doa8oh2/oUXk4OOsRYZsxc1W1sDXIGb/Pu5Yrb+f2eyYpgQMGITVX7HVgxhs9P18Rc6I97ym/g==} + engines: {node: '>=22'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3335,8 +3360,8 @@ packages: colortranslator@5.0.0: resolution: {integrity: sha512-Z3UPUKasUVDFCDYAjP2fmlVRf1jFHJv1izAmPjiOa0OCIw1W7iC8PZ2GsoDa8uZv+mKyWopxxStT9q05+27h7w==} - comark-content@https://pkg.pr.new/comark-content@baefd4d: - resolution: {integrity: sha512-7E/3OIIKPBI5XJ3s/aEtptAP49Of9W/EQVjzsbGnAZgCxvtlpqeSq1l+8GUbMDYqNy20rLWXlk7JdXTd8rsj/g==, tarball: https://pkg.pr.new/comark-content@baefd4d} + comark-content@https://pkg.pr.new/comark-content@a64a262: + resolution: {integrity: sha512-PzuI3E1wqWT1etatfVKfEmQx0qNCYa3spezEf+9eXeuKawbi66zVieM5XCLSFyUuSivQMC/S7zO1uipKC9bxjw==, tarball: https://pkg.pr.new/comark-content@a64a262} version: 0.3.0 hasBin: true @@ -4872,6 +4897,12 @@ packages: '@vueuse/core': '>=10.0.0' vue: '>=3.0.0' + motion-v@2.4.2: + resolution: {integrity: sha512-I3+pa3s1iCtk1hS7En7+ZYH1E165FQHAC4ZRjv6wsQPkd7wqyE+ooOPqal3FXQOCCYesmUs5BS6KLx4Fz83m+A==} + peerDependencies: + '@vueuse/core': '>=10.0.0' + vue: '>=3.0.0' + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -5051,6 +5082,9 @@ packages: peerDependencies: vue: ^3.5.30 + nuxt-workers@0.1.0: + resolution: {integrity: sha512-npsxy72FRQZkxHV1Y+KCkuSvb2Y/7Tcp7xGXPHXXVQ5/oIZ5+69VAudfdhpuwPN1JZJn9ULr01vKRLamENsTew==} + nuxt@4.5.2: resolution: {integrity: sha512-tR3fcqeHlHmmkLMpIg3V7Y+1ltr302lW8djMw/iy+myfo7QSSz+BVJDuQhg5j73b9oteSyBfOKTDYTgvMtj6TA==} engines: {node: ^22.19.0 || ^24.11.0 || >=26.0.0} @@ -7338,6 +7372,10 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@iconify-json/logos@1.2.14': + dependencies: + '@iconify/types': 2.0.0 + '@iconify-json/lucide@1.2.125': dependencies: '@iconify/types': 2.0.0 @@ -7346,6 +7384,10 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@iconify-json/unjs@1.2.4': + dependencies: + '@iconify/types': 2.0.0 + '@iconify-json/vscode-icons@1.2.74': dependencies: '@iconify/types': 2.0.0 @@ -7857,6 +7899,32 @@ snapshots: - vite - vue + '@nuxt/kit@3.21.11(magicast@0.5.4)': + dependencies: + c12: 3.3.4(magicast@0.5.4) + consola: 3.4.2 + defu: 6.1.7 + destr: 2.0.5 + errx: 0.1.2 + exsolve: 1.1.1 + ignore: 7.0.6 + jiti: 2.7.0 + klona: 2.0.6 + knitwork: 1.3.0 + mlly: 1.8.2 + ohash: 2.0.12 + pathe: 2.0.3 + pkg-types: 2.3.1 + rc9: 3.0.1 + scule: 1.3.0 + semver: 7.8.5 + tinyglobby: 0.2.17 + ufo: 1.6.4 + unctx: 2.5.0 + untyped: 2.0.0 + transitivePeerDependencies: + - magicast + '@nuxt/kit@4.5.2(magic-string@1.2.3)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.5)(unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.5)(rollup@4.62.5)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)))': dependencies: c12: 3.3.4(magicast@0.5.4) @@ -8114,7 +8182,7 @@ snapshots: - vue - webpack - '@nuxt/ui@https://pkg.pr.new/@nuxt/ui@fbb9e22(7fbc16f2b6c23b42dc4bd9c4885c5233)': + '@nuxt/ui@4.11.1(7fbc16f2b6c23b42dc4bd9c4885c5233)': dependencies: '@floating-ui/dom': 1.8.0 '@iconify/vue': 5.0.1(vue@3.5.41(typescript@6.0.3)) @@ -8164,7 +8232,7 @@ snapshots: knitwork: 1.3.0 magic-string: 1.2.3 mlly: 1.8.2 - motion-v: 2.4.0(@vueuse/core@14.4.0(vue@3.5.41(typescript@6.0.3)))(vue@3.5.41(typescript@6.0.3)) + motion-v: 2.4.2(@vueuse/core@14.4.0(vue@3.5.41(typescript@6.0.3)))(vue@3.5.41(typescript@6.0.3)) ohash: 2.0.12 pathe: 2.0.3 reka-ui: 2.10.4(vue@3.5.41(typescript@6.0.3)) @@ -8865,6 +8933,8 @@ snapshots: '@speed-highlight/core@1.2.24': {} + '@sqlite.org/sqlite-wasm@3.53.0-build1': {} + '@standard-schema/spec@1.1.0': {} '@stylistic/eslint-plugin@5.10.0(eslint@10.9.0(jiti@2.7.0)(supports-color@10.2.2))': @@ -10094,7 +10164,7 @@ snapshots: colortranslator@5.0.0: {} - comark-content@https://pkg.pr.new/comark-content@baefd4d(@vercel/functions@3.9.5(ws@8.21.3))(beautiful-mermaid@1.1.3)(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(rangi@2.2.0)(shiki@4.4.3): + comark-content@https://pkg.pr.new/comark-content@a64a262(@vercel/functions@3.9.5(ws@8.21.3))(beautiful-mermaid@1.1.3)(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2))(rangi@2.2.0)(shiki@4.4.3): dependencies: citty: 0.2.2 comark: 0.6.2(beautiful-mermaid@1.1.3)(rangi@2.2.0)(shiki@4.4.3) @@ -11701,6 +11771,18 @@ snapshots: - react - react-dom + motion-v@2.4.2(@vueuse/core@14.4.0(vue@3.5.41(typescript@6.0.3)))(vue@3.5.41(typescript@6.0.3)): + dependencies: + '@vueuse/core': 14.4.0(vue@3.5.41(typescript@6.0.3)) + framer-motion: 13.1.1 + hey-listen: 1.0.8 + motion-dom: 13.1.1 + motion-utils: 13.0.0 + vue: 3.5.41(typescript@6.0.3) + transitivePeerDependencies: + - react + - react-dom + mrmime@2.0.1: {} ms@2.1.3: {} @@ -12011,6 +12093,17 @@ snapshots: - vite - zod + nuxt-workers@0.1.0(magicast@0.5.4): + dependencies: + '@nuxt/kit': 3.21.11(magicast@0.5.4) + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + ufo: 1.6.4 + unplugin: 2.3.11 + transitivePeerDependencies: + - magicast + nuxt@4.5.2(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2)))(@oxc-project/types@0.146.0)(@types/node@26.2.0)(@vercel/functions@3.9.5(ws@8.21.3))(@vue/compiler-sfc@3.5.41)(db0@0.3.4)(esbuild@0.28.2)(eslint@10.9.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.11.1(supports-color@10.2.2))(lightningcss@1.33.0)(magicast@0.5.4)(optionator@0.9.4)(oxc-parser@0.143.0)(rolldown@1.2.5)(rollup-plugin-visualizer@7.1.1(rolldown@1.2.5)(rollup@4.62.5))(rollup@4.62.5)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.50.0)(typescript@6.0.3)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(vue-tsc@3.3.11(typescript@6.0.3))(yaml@2.9.0): dependencies: '@dxup/nuxt': 0.5.10(esbuild@0.28.2)(magicast@0.5.4)(oxc-parser@0.143.0)(rolldown@1.2.5)(rollup@4.62.5)(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 669fb7c..6d179ac 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,15 +9,10 @@ allowBuilds: blockExoticSubdeps: false minimumReleaseAgeExclude: - - "@nuxt/kit@4.5.1" - - "@nuxt/schema@4.5.1" - - "@nuxt/nitro-server@4.5.1" - - "@nuxt/vite-builder@4.5.1" - - nuxt@4.5.1 - - '@comark/nuxt@0.6.0 || 0.6.1 || 0.6.2' - - '@comark/vue@0.6.0 || 0.6.1 || 0.6.2' - - comark@0.6.0 || 0.6.1 || 0.6.2 - - comark-content@0.3.0 + # First-party packages, published and consumed here in the same session. + - '@comark/*' + - comark + - comark-content overrides: # Keep the workspace on a single h3 major (v1), matching comark-content. diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..1342f21 --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["github>nuxt/renovate-config-nuxt"] +} diff --git a/server/api/code-explorer/[...path].get.ts b/server/api/code-explorer/[...path].get.ts index 6edb4bb..1bd84ed 100644 --- a/server/api/code-explorer/[...path].get.ts +++ b/server/api/code-explorer/[...path].get.ts @@ -3,7 +3,7 @@ import { parseMarkdown, type MarkdownDocument } from 'comark' import rangi from 'comark/plugins/rangi' import fs from 'comark-content/sources/fs' import github from 'comark-content/sources/github' -import { geistTheme } from '../../../utils/geist-theme.ts' +import { geistTheme } from '../../../utils/geist.ts' // A read source for one example directory. Dev: working tree. Prod: authenticated GitHub — the repo may be // private, so jsDelivr / unauthenticated raw are out. Mirrors {@link contentSource}'s dev/prod split. diff --git a/server/api/content/[...path].get.ts b/server/api/content/[...path].get.ts index e10db39..c4f39d7 100644 --- a/server/api/content/[...path].get.ts +++ b/server/api/content/[...path].get.ts @@ -1,6 +1,6 @@ /** - * Single data endpoint: `content.handler()` dispatches `get`, `navigation`, `list` and custom handlers - * (e.g. `search-sections`). Cached per-URL — see `routeRules`. + * Single data endpoint: `content.handler()` dispatches `get`, `navigation`, `list`, `manifest` + * and `snapshot`. Must be cached per-URL by layer consumer. */ export default defineEventHandler(async (event) => { const content = await getProdContent() diff --git a/server/api/content/blob/[sha]/[...path].get.ts b/server/api/content/blob/[sha]/[...path].get.ts index a72ef7f..173c7ee 100644 --- a/server/api/content/blob/[sha]/[...path].get.ts +++ b/server/api/content/blob/[sha]/[...path].get.ts @@ -20,7 +20,6 @@ export default defineEventHandler(async (event) => { // Also resolves short SHAs so one commit pins one content instance. const fullSha = await authorizePreviewSha(sha) - const content = await getPreviewContent(fullSha, `/api/content/blob/${sha}`) - - return await content.handler(toWebRequest(event)) + // Head-of-branch requests reuse the shared prod instance; `servePreview()` handles that. + return servePreview(event, fullSha, `/blob/${rawSha}`) }) diff --git a/server/api/content/head.get.ts b/server/api/content/head.get.ts new file mode 100644 index 0000000..493c5bb --- /dev/null +++ b/server/api/content/head.get.ts @@ -0,0 +1,10 @@ +/** + * The commit SHA production content is pinned to, or `null` in dev. + */ +export default defineEventHandler(async () => { + if (import.meta.dev) return { sha: null } + + // Same resolution as the pages (`getProdContent`), so the search artifacts the client hydrates + // from can't come from a different commit than the rendered content — notably under a pin. + return { sha: await resolveProdSha() } +}) diff --git a/server/api/content/pr/[number]/[...path].get.ts b/server/api/content/pr/[number]/[...path].get.ts index 486a679..75de79e 100644 --- a/server/api/content/pr/[number]/[...path].get.ts +++ b/server/api/content/pr/[number]/[...path].get.ts @@ -1,6 +1,6 @@ /** * Per-pull-request data endpoint: `/pr/:number` previews the PR's head commit. Follows new pushes - * (the number → head SHA pointer lives in the short-TTL ref cache) and enforces the preview + * (the number → head SHA pointer lives in the 10-minute ref cache) and enforces the preview * authorization: same-repo PRs always, fork PRs only with the `preview:enabled` label. */ export default defineEventHandler(async (event) => { @@ -17,7 +17,5 @@ export default defineEventHandler(async (event) => { } const sha = await resolvePullPreviewSha(number) - const content = await getPreviewContent(sha, `/api/content/pr/${number}`) - - return await content.handler(toWebRequest(event)) + return servePreview(event, sha, `/pr/${rawNumber}`) }) diff --git a/server/api/content/tree/[branch]/[...path].get.ts b/server/api/content/tree/[branch]/[...path].get.ts index 362414c..0899003 100644 --- a/server/api/content/tree/[branch]/[...path].get.ts +++ b/server/api/content/tree/[branch]/[...path].get.ts @@ -14,7 +14,5 @@ export default defineEventHandler(async (event) => { // `cacheMisses`: the ref comes from the URL, so a miss must not re-cost a GitHub call each time. const sha = await resolveContentSha(branch, useRuntimeConfig(event).docs.contentDir, { cacheMisses: true }) - const content = await getPreviewContent(sha, `/api/content/tree/${encodeURIComponent(branch)}`) - - return await content.handler(toWebRequest(event)) + return servePreview(event, sha, `/tree/${rawBranch}`) }) diff --git a/server/api/revalidate.post.ts b/server/api/revalidate.post.ts index 51de3d5..d21e7ac 100644 --- a/server/api/revalidate.post.ts +++ b/server/api/revalidate.post.ts @@ -1,30 +1,25 @@ -import type { ContentListFile } from 'comark-content' import { verify } from '@octokit/webhooks-methods' +import { DEFAULT_CONTENT_NAME } from 'comark-content' import { waitUntil } from '@vercel/functions' /** Each re-render hits this same deployment, so the ceiling is about not stampeding ourselves. */ const REVALIDATE_CONCURRENCY = 8 -/** `Promise.allSettled` over `items`, at most `size` in flight. */ -async function settleInBatches( - items: T[], - size: number, - fn: (item: T) => Promise -): Promise[]> { - const results: PromiseSettledResult[] = [] - for (let i = 0; i < items.length; i += size) { - // Not `.map(fn)` — `map` passes the index, which lands in the callee's optional parameter. - results.push(...(await Promise.allSettled(items.slice(i, i + size).map((item) => fn(item))))) - } - return results -} +/** Why a route was purged — one value per `addPath` call site below. */ +type PurgeReason = 'page' | 'payload' | 'raw' | 'nav' | 'global' + +/** Display/response order. */ +const REASON_ORDER: PurgeReason[] = ['page', 'payload', 'raw', 'nav', 'global'] + +/** `nav` is the only unbounded reason (the whole site can be thousands of pages) — cap what the log prints. */ +const MAX_LOGGED_PATHS_PER_REASON = 5 export default defineEventHandler(async (event) => { const { docs } = useRuntimeConfig(event) const secret = docs.webhookSecret || process.env.WEBHOOK_SECRET const bypassToken = docs.bypassToken || process.env.VERCEL_BYPASS_TOKEN if (!secret || !bypassToken) { - throw createError({ statusCode: 500, statusMessage: 'Webhook not configured' }) + throw createError({ statusCode: 501, statusMessage: 'Revalidation webhook is not configured' }) } const signature = getHeader(event, 'x-hub-signature-256') @@ -41,181 +36,223 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 401, statusMessage: 'Invalid signature' }) } + const requestId = getHeader(event, 'x-vercel-id') ?? getHeader(event, 'x-request-id') ?? 'local' + const deliveryId = getHeader(event, 'x-github-delivery') + const tag = `[revalidate:${requestId}${deliveryId ? `:${deliveryId}` : ''}]` + const timings = createTimings() + + const githubEvent = getHeader(event, 'x-github-event') + if (githubEvent !== 'push') { + console.log(`${tag} skipped: ${githubEvent ?? 'unknown'} event`) + return { ok: true, skipped: 'not-a-push-event', event: githubEvent } + } + const payload = JSON.parse(raw) as GitHubPushPayload const branch = targetBranch() const contentDir = docs.contentDir const expectedRef = `refs/heads/${branch}` - if (payload.ref !== expectedRef) { - console.log(`[content] revalidate push skipped (ref=${payload.ref} !== expected=${expectedRef})`) - return { - ok: true, - skipped: 'non-target-branch', - expected: expectedRef, - received: payload.ref, - } + const repo = githubRepo() + if (payload.repository?.full_name && payload.repository.full_name !== repo) { + console.log(`${tag} skipped: repo=${payload.repository.full_name} !== expected=${repo}`) + return { ok: true, skipped: 'wrong-repo', expected: repo, received: payload.repository.full_name } } - // Classify changed content files. A file added in one commit and modified in - // another counts as added; `.navigation.*` config files always touch navigation. - const added = new Set() - const removed = new Set() - const modified = new Set() - let navConfigTouched = false - for (const commit of payload.commits ?? []) { - for (const f of commit.added ?? []) { - if (isContentMd(f)) added.add(f) - else if (isNavConfig(f)) navConfigTouched = true - } - for (const f of commit.modified ?? []) { - if (isContentMd(f)) modified.add(f) - else if (isNavConfig(f)) navConfigTouched = true - } - for (const f of commit.removed ?? []) { - if (isContentMd(f)) removed.add(f) - else if (isNavConfig(f)) navConfigTouched = true - } + if (payload.ref !== expectedRef) { + console.log(`${tag} skipped: ref=${payload.ref} !== expected=${expectedRef}`) + return { ok: true, skipped: 'non-target-branch', expected: expectedRef, received: payload.ref } } - for (const f of added) modified.delete(f) - const changedFiles = [...added, ...modified, ...removed] - if (changedFiles.length === 0 && !navConfigTouched) { + const changes = changesForPush(contentDir, payload.commits ?? []) + if (!changes.upserted.length && !changes.removed.length && !changes.navTouched) { return { ok: true, skipped: 'no-content-changes' } } - const protocol = getRequestProtocol(event) - const host = getRequestHost(event, { xForwardedHost: true }) - const baseURL = `${protocol}://${host}` - - // `x-vercel-protection-bypass` bypasses the SSO wall when the handler calls itself - const readHeaders: Record = {} - if (process.env.VERCEL_AUTOMATION_BYPASS_SECRET) { - readHeaders['x-vercel-protection-bypass'] = process.env.VERCEL_AUTOMATION_BYPASS_SECRET - } - - // `x-prerender-revalidate` purges the ISR cache - const headers: Record = { - ...readHeaders, - 'x-prerender-revalidate': bypassToken, - } + const buildId = useRuntimeConfig(event).app.buildId + const pathsToPurge = new Set() + const byReason = new Map>() - const headSha = payload.head_commit?.id - if (!headSha) { - throw createError({ statusCode: 400, statusMessage: 'Missing head commit SHA' }) + /** Add a path to the purge set, and track it by reason for the breakdown log. */ + const addPath = (reason: PurgeReason, path: string): void => { + if (pathsToPurge.has(path)) return + pathsToPurge.add(path) + const paths = byReason.get(reason) ?? new Set() + paths.add(path) + byReason.set(reason, paths) } - // Bypass the short ref cache and write the canonical path-filtered revision before the purge fan-out, - // so a freshly-purged page cannot re-render against a stale or payload-order-dependent content SHA. - const contentSha = await resolveContentSha(branch, contentDir, { refresh: true }) - - console.log(`[content] revalidate push headSha=${headSha} contentSha=${contentSha}`) + // Diffed against the live prod instance, already warm + const { headSha, newItems, pagePaths, navChanged } = await timings.time('rebuild', async () => { + const outdated = await getProdContent() + await outdated.init() + const oldItems = { ...(await outdated.manifest()).items } - const requestId = getHeader(event, 'x-vercel-id') ?? getHeader(event, 'x-request-id') ?? 'local' - const tag = `[revalidate:${requestId}]` - - // Planning before we respond costs two `init()` passes against GitHub's ~10s delivery timeout, - // in exchange for diagnostics in the webhook body. Safe because everything here is idempotent, - // so a retried delivery only repeats work. If it gets slow, move this into `waitUntil`. - const beforeSha = payload.before - let oldItems: Record = {} - if (beforeSha && !/^0+$/.test(beforeSha)) { - try { - const oldContent = await createSourceContent(beforeSha) - await oldContent.init() - oldItems = (await oldContent.manifest()).items - } catch (err) { - const message = err instanceof Error ? err.message : err - console.warn(`${tag} no before-manifest (${beforeSha}) — treating as full revalidate:`, message) - } - } + // Refresh the content SHA + const headSha = await resolveContentSha(branch, contentDir, { refresh: true }) - // The head snapshot has the same content directory as `contentSha`; populate the namespace that - // production instances will read even when later commits in this push only changed code. - const headContent = await createSourceContent(headSha, { cache: { driver: cacheDriver(contentSha) } }) - await headContent.init() - const newItems = (await headContent.manifest()).items + // Throwaway instance: the diff needs the index only. + // It lands in the commit's cache namespace to be reused by the prod warm below. + const fresh = contentAt(headSha) + await fresh.init() + const newItems = (await fresh.manifest()).items + await fresh.dispose() - const oldPaths = new Set(Object.keys(oldItems)) - const newPaths = Object.keys(newItems) - const addedPaths = newPaths.filter((p) => !oldPaths.has(p)) - const removedPaths = [...oldPaths].filter((p) => !(p in newItems)) + return { headSha, newItems, ...diffContent(changes, oldItems, newItems) } + }) - const metaChangedPaths: string[] = [] - for (const p of newPaths) { - if (oldPaths.has(p) && hashManifestItem(oldItems[p]) !== hashManifestItem(newItems[p])) metaChangedPaths.push(p) - } - const navChanged = navConfigTouched || addedPaths.length > 0 || removedPaths.length > 0 || metaChangedPaths.length > 0 - - // Payload routes are keyed by the build-id query on some deployments, so purge the exact - // URL the browser loads (`…/_payload.json?`). - const buildId = useRuntimeConfig(event).app.buildId - - // Any content change invalidates the llms indexes, the feed, and the body-derived search index. - const paths = new Set(['/llms.txt', '/llms-full.txt', '/rss.xml', '/api/content/search-sections']) - for (const f of changedFiles) { - const pageUrl = pageUrlForPath(f) - if (pageUrl) { - paths.add(payloadUrlForRoute(pageUrl, buildId)) - paths.add(pageUrl) - } - const rawUrl = rawUrlForPath(f) - if (rawUrl) paths.add(rawUrl) + for (const path of pagePaths) { + addPath('page', path) + addPath('payload', payloadUrlForPage(path, buildId)) + addPath('raw', rawUrlForPage(path)) } // Navigation renders on every page, so a change to it re-renders all of them. if (navChanged) { for (const item of Object.values(newItems)) { - if (item.meta.kind === 'document') { - paths.add(item.path) - paths.add(payloadUrlForRoute(item.path, buildId)) - } + if (item.meta.kind !== 'document') continue + addPath('nav', item.path) + addPath('nav', payloadUrlForPage(item.path, buildId)) + addPath('nav', rawUrlForPage(item.path)) } } + // Per-commit search artifacts (ISR, immutable). + const artifactBase = `/api/content/blob/${headSha}` + const pathsToWarm = [`${artifactBase}/manifest.json`, `${artifactBase}/snapshot/${DEFAULT_CONTENT_NAME}.json`] + + // Any content change invalidates the global indexes: each is rebuilt from the whole tree. + for (const path of ['/llms.txt', '/llms-full.txt', '/rss.xml', '/sitemap.xml']) { + addPath('global', path) + } + console.log( `${tag} navChanged=${navChanged} ` + - `(added=${addedPaths.length}, removed=${removedPaths.length}, meta=${metaChangedPaths.length}, navConfig=${navConfigTouched}) | ` + - `files: +${added.size} ~${modified.size} -${removed.size} | ${paths.size} route(s)` + `(upserted=${changes.upserted.length}, removed=${changes.removed.length}, navConfig=${changes.navTouched}) | ` + + `${pathsToPurge.size} to purge, ${pathsToWarm.length} to warm | ${timings.format()}` ) - if (metaChangedPaths.length) console.log(`${tag} meta changed: ${metaChangedPaths.join(', ')}`) - if (addedPaths.length) console.log(`${tag} added: ${addedPaths.join(', ')}`) - if (removedPaths.length) console.log(`${tag} removed: ${removedPaths.join(', ')}`) + + logBreakdown(tag, byReason) + for (const path of pathsToWarm) console.log(`${tag} warm\t${path}`) + + // Dev has no ISR cache to purge + if (import.meta.dev) { + return { + ok: true, + requestId, + deliveryId, + navChanged, + routes: routesBreakdown(byReason), + warm: pathsToWarm.length, + dev: true, + } + } + + const protocol = getRequestProtocol(event) + const host = getRequestHost(event, { xForwardedHost: true }) + const baseURL = `${protocol}://${host}` + + // Lets the deployment call itself while Vercel Authentication is on (preview deploys). + const selfCall: Record = process.env.VERCEL_AUTOMATION_BYPASS_SECRET + ? { 'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET } + : {} + + // `x-prerender-revalidate` regenerates the ISR entry for the URL being fetched. + const purgeHeaders = { ...selfCall, 'x-prerender-revalidate': bypassToken } // Vercel's native waitUntil, not Nitro's `event.waitUntil` — that one can orphan async work here. waitUntil( (async () => { - const revalidate = (path: string, extra: Record = {}) => - $fetch(path, { baseURL, method: 'GET', headers: { ...headers, ...extra } }).catch((err) => { - console.error(`${tag} ✗ ${path}`, err?.statusCode ?? err?.message ?? err) - throw err - }) - - // Warm the per-SHA body cache so cold instances skip re-parsing from GitHub. - // `metaOnly` became `partial` in comark-content 0.2.0 with no alias and consumers straddle both, - // so send both keys — each version ignores the other's. Not inlined: as a literal, - // excess-property checking rejects whichever key the installed types don't declare. - const full = { partial: false, metaOnly: false } - await headContent.init(full).catch((err) => { - console.error(`${tag} cache warm failed`, err?.message ?? err) - }) - - await useStorage('cache:nuxt:payload').clear() - - // Bounded: a nav change queues two URLs per page, and every one re-enters this function. - const results = await settleInBatches([...paths], REVALIDATE_CONCURRENCY, revalidate) - const ok = results.filter((r) => r.status === 'fulfilled').length - console.log(`${tag} complete: ${ok}/${results.length} succeeded`) + const absent: string[] = [] + + // The warm runs first: + // - ISR cache manifest and snapshot for the new SHA + // - Cache parsed items for the pages to purge and re-render + const warmResults = await timings.time('warm', () => + settleInBatches(pathsToWarm, REVALIDATE_CONCURRENCY, (path) => + $fetch(path, { baseURL, method: 'GET', headers: selfCall }).catch((error) => { + console.error(`${tag} ✗ ${path}`, error?.statusCode ?? error?.message ?? error) + throw error + }) + ) + ) + + const purgeResults = await timings.time('purge', () => + settleInBatches([...pathsToPurge], REVALIDATE_CONCURRENCY, (path) => + $fetch(path, { baseURL, method: 'GET', headers: purgeHeaders }).catch((error) => { + // Content with no page of its own (e.g. a partial) has nothing cached to purge. + if (error?.statusCode === 404) { + absent.push(path) + return + } + console.error(`${tag} ✗ ${path}`, error?.statusCode ?? error?.message ?? error) + throw error + }) + ) + ) + + const warmed = warmResults.filter((r) => r.status === 'fulfilled').length + const failed = [...warmResults, ...purgeResults].filter((r) => r.status === 'rejected').length + const purged = purgeResults.filter((r) => r.status === 'fulfilled').length - absent.length + console.log( + `${tag} complete: ${warmed} warmed, ${purged} purged, ${absent.length} absent, ` + + `${failed} failed | ${timings.format()} | total=${timings.since()}ms` + ) + logAbsent(tag, absent) })() ) return { ok: true, requestId, + deliveryId, navChanged, - manifest: { - added: addedPaths, - removed: removedPaths, - metaChanged: metaChangedPaths, - }, + manifest: { upserted: changes.upserted, removed: changes.removed }, + routes: routesBreakdown(byReason), } }) + +/** One log line per purged path, grouped by reason. */ +function logBreakdown(tag: string, byReason: Map>): void { + for (const reason of REASON_ORDER) { + const paths = byReason.get(reason) + if (!paths?.size) continue + + const sorted = [...paths].sort() + for (const path of sorted.slice(0, MAX_LOGGED_PATHS_PER_REASON)) { + console.log(`${tag} ${reason}\t${path}`) + } + if (sorted.length > MAX_LOGGED_PATHS_PER_REASON) { + console.log(`${tag} ${reason}\t... (${sorted.length} total)`) + } + } +} + +/** One log line per absent path — expected to be empty. */ +function logAbsent(tag: string, absent: string[]): void { + for (const path of [...absent].sort()) { + console.log(`${tag} absent\t${path}`) + } +} + +/** Route counts by reason. */ +function routesBreakdown(byReason: Map>): { total: number } & Partial> { + const counts: Partial> = {} + for (const [reason, paths] of byReason) counts[reason] = paths.size + + const total = Object.values(counts).reduce((sum, count) => sum + (count ?? 0), 0) + return { total, ...counts } +} + +/** `Promise.allSettled` over `items`, at most `size` in flight. */ +async function settleInBatches( + items: T[], + size: number, + fn: (item: T) => Promise +): Promise[]> { + const results: PromiseSettledResult[] = [] + for (let i = 0; i < items.length; i += size) { + // Not `.map(fn)` — `map` passes the index, which lands in the callee's optional parameter. + results.push(...(await Promise.allSettled(items.slice(i, i + size).map((item) => fn(item))))) + } + return results +} diff --git a/server/plugins/tracer.ts b/server/plugins/tracer.ts index 91266a7..a87f027 100644 --- a/server/plugins/tracer.ts +++ b/server/plugins/tracer.ts @@ -1,5 +1,13 @@ -import { context, propagation } from '@opentelemetry/api' -import { ensureLocalTracer, localTraceEnabled, shutdownLocalTracer } from '../utils/tracer' +import { context, propagation, SpanStatusCode } from '@opentelemetry/api' +import type { H3Event } from 'h3' +import { + createRenderTrace, + finishRenderSpan, + getRenderTrace, + startRenderSpan, + type RenderTraceState, +} from '../../utils/render-trace' +import { contentTracer, ensureLocalTracer, localTraceEnabled, shutdownLocalTracer } from '../utils/tracer' import { registerOTel } from '@vercel/otel' export default defineNitroPlugin((nitro) => { @@ -18,4 +26,92 @@ export default defineNitroPlugin((nitro) => { const requestContext = propagation.extract(context.active(), getRequestHeaders(event)) return context.with(requestContext, () => handler(event)) } + + const tracer = contentTracer() + if (!tracer) return + + const startRequestTrace = (event: H3Event) => { + const path = event.path + const isPayload = /(?:^|\/)_payload\.json(?:\?|$)/.test(path) + const state = createRenderTrace(event, isPayload) + state.request = startRenderSpan(tracer, 'nitro:request', undefined, { + 'http.request.method': event.method, + 'url.path': path, + 'nuxt.render.payload': isPayload, + }) + return state + } + + nitro.hooks.hook('request', (event) => { + const accept = getRequestHeader(event, 'accept') || '' + const isPayload = /(?:^|\/)_payload\.json(?:\?|$)/.test(event.path) + if (isPayload || accept.includes('text/html')) startRequestTrace(event) + }) + + nitro.hooks.hook('render:before', ({ event }) => { + const state = getRenderTrace(event) || startRequestTrace(event) + state.render = startRenderSpan(tracer, 'nuxt:render', state.request, { + 'nuxt.render.payload': state.isPayload, + 'nitro.cache.enabled': Boolean(event.context.cache), + }) + }) + + nitro.hooks.hook('render:route', ({ canStream, prefersStream }, { event }) => { + const state = getRenderTrace(event) + if (!state?.render) return + state.vue = startRenderSpan(tracer, 'nuxt:render:vue', state.render, { + 'nuxt.render.can_stream': canStream, + 'nuxt.render.prefers_stream': prefersStream, + }) + }) + + nitro.hooks.hook('render:html', (_html, { event, streaming }) => { + // Streaming calls this hook before Vue renders the body. ISR and payload + // responses are buffered, which gives us the post-render boundary below. + if (streaming) return + const state = getRenderTrace(event) + if (!state?.render) return + finishRenderSpan(state, 'finalize') + state.html = startRenderSpan(tracer, 'nuxt:render:html', state.render) + }) + + nitro.hooks.hook('render:response', (response, { event }) => { + const state = getRenderTrace(event) + if (!state) return + + finishRenderSpan(state, 'vue') + finishRenderSpan(state, 'finalize') + finishRenderSpan(state, 'html') + if (response.statusCode) state.render?.setAttribute('http.response.status_code', response.statusCode) + if (typeof response.body === 'string') { + state.render?.setAttribute('http.response.body.size', new TextEncoder().encode(response.body).byteLength) + } + finishRenderSpan(state, 'render') + state.response = startRenderSpan(tracer, 'nitro:response', state.request) + }) + + nitro.hooks.hook('afterResponse', (event) => { + const state = getRenderTrace(event) + if (!state) return + finishRenderSpan(state, 'response') + finishRenderSpan(state, 'request') + }) + + nitro.hooks.hook('error', (error, { event }) => { + if (!event) return + const state = getRenderTrace(event) + if (!state) return + markRenderTraceFailed(state, error) + }) }) + +function markRenderTraceFailed(state: RenderTraceState, error: unknown): void { + const exception = error instanceof Error ? error : String(error) + for (const key of ['vue', 'finalize', 'html', 'render', 'response', 'request'] as const) { + const span = state[key] + if (!span) continue + span.recordException(exception) + span.setStatus({ code: SpanStatusCode.ERROR }) + finishRenderSpan(state, key) + } +} diff --git a/server/routes/raw/[...slug].md.get.ts b/server/routes/raw/[...slug].md.get.ts index a871320..ef71111 100644 --- a/server/routes/raw/[...slug].md.get.ts +++ b/server/routes/raw/[...slug].md.get.ts @@ -1,4 +1,4 @@ -import { findFirstLeaf } from '../../../utils/first-leaf' +import { findFirstLeaf } from '../../../utils/navigation' export default defineEventHandler(async (event) => { const slug = getRouterParams(event)['slug.md'] diff --git a/server/utils/cache.ts b/server/utils/cache.ts index 9c686e1..82360a7 100644 --- a/server/utils/cache.ts +++ b/server/utils/cache.ts @@ -5,52 +5,49 @@ import vercelRuntimeCache from 'unstorage/drivers/vercel-runtime-cache' /** SHA-pinned content is immutable, so it can be cached for a long time. */ const TTL = 60 * 60 * 24 -/** Content refs move with their branches, so the pointer cache uses a short TTL. */ -const REF_TTL = 60 - /** Whether the Vercel Runtime Cache is available (i.e. running on Vercel). */ function cacheAvailable(): boolean { return !import.meta.dev && Boolean(process.env.VERCEL) } +/** Every namespace below degrades to per-process memory off Vercel if not available. */ +function runtimeCacheDriver(base: string, ttl?: number): Driver { + if (!cacheAvailable()) return memoryDriver() + return vercelRuntimeCache({ base, ttl }) +} + /** * Bump when content parser/plugin configuration, relevant parser dependencies, or cached derived * data changes. Keeping this explicit lets unrelated deployments reuse immutable content artifacts. */ export const CONTENT_PARSER_VERSION = 'v3' -/** Per-parser-version, per-content-SHA driver backing comark's manifest and parsed bodies. */ -export function cacheDriver(sha: string): Driver { - if (!cacheAvailable()) return memoryDriver() - return vercelRuntimeCache({ - base: `content:${CONTENT_PARSER_VERSION}:${sha}`, - ttl: TTL, - }) -} - /** - * Ad-hoc per-SHA storage for non-content data (commit history, RSS dates). Shares comark's - * `cacheDriver(sha)` namespace rather than a separate unconfigured mount; `gh:...` keys can't - * collide with comark's `:`. + * Comark cache: index, parsed bodies and artifacts of every commit. + * Sharing content across all perser versions BUT keys are per-sha. */ -export function shaCacheStorage(sha: string): Storage { - return createStorage({ driver: cacheDriver(sha) }) +export function contentCacheDriver(): Driver { + return runtimeCacheDriver(`content:${CONTENT_PARSER_VERSION}`, TTL) } /** - * Shared driver backing the branch + content directory → content commit pointer - * (`resolveContentSha` in `github.ts`), in its own namespace so every instance reads one pointer - * instead of keeping its own timer. + * Shared driver backing branch pointers and preview authorization decisions (`github.ts`). TTLs are + * set per item: the production branch pointer is webhook-owned and does not expire, while previews + * and negative decisions remain bounded. * - * Vercel Runtime Cache is **regional**, not global (https://vercel.com/docs/caching/runtime-cache): - * this assumes Functions run in a single region (no `regions` in `vercel.json`/`nuxt.config.ts`). - * Multi-region would confine the webhook's forced refresh to its region — others self-heal on TTL, - * so reach for a globally replicated store (e.g. Edge Config) only if that day comes. + * TODO: Vercel Runtime Cache is **regional**, not global (https://vercel.com/docs/caching/runtime-cache): + * It assumes Functions run in a single region. + * Multi-region would confine the webhook's forced refresh to its region (others self-heal on TTL) + * We should reach for a globally replicated store (e.g. Edge Config). */ export function refCacheDriver(): Driver { - if (!cacheAvailable()) return memoryDriver() - return vercelRuntimeCache({ - base: 'content:refs', - ttl: REF_TTL, - }) + return runtimeCacheDriver('content:refs') +} + +/** + * Per-commit data Comark knows nothing about (commit history, RSS dates). + * Namespace is per-sha and keys start with `gh:`. + */ +export function shaCacheStorage(sha: string): Storage { + return createStorage({ driver: runtimeCacheDriver(`content:${CONTENT_PARSER_VERSION}:${sha}`, TTL) }) } diff --git a/server/utils/content.ts b/server/utils/content.ts index 862de45..8c7455e 100644 --- a/server/utils/content.ts +++ b/server/utils/content.ts @@ -1,103 +1,64 @@ -import { defineContentPlugin, type CacheOptions, comarkContent } from 'comark-content'; +import { type ContentSource, DEFAULT_CONTENT_NAME } from 'comark-content' import fs from 'comark-content/sources/fs' import github from 'comark-content/sources/github' -import rangi from 'comark/plugins/rangi' -import security from 'comark/plugins/security' -import emoji from 'comark/plugins/emoji' -import toc from 'comark/plugins/toc' -import mermaid from 'comark/plugins/mermaid' -import yaml from 'comark-content/plugins/yaml' -import tracingOtel from 'comark-content/plugins/tracing/otel' -import { contentTracer } from './tracer.ts' -import { geistTheme } from '../../utils/geist-theme.ts' +import { withSnapshot } from 'comark-content/sources/snapshot' +import { createRuntimeContentInstance } from '../../utils/content.ts' /** - * The instance this layer builds, derived from the factory rather than written - * out. - * - * `ComarkContent` is the *unnarrowed* shape: its instance-name parameter drives - * the conditional types behind `get()` and `list()`, so a concrete instance is - * not assignable to it. Deriving instead of annotating keeps the narrowing that - * `comark-content prepare` generates — `get('/known/path')` stays typed all the - * way through the layer. + * The instance serving requests in this layer. */ -export type DocsContent = Awaited> - -// Rebuilt only when the head advances (see `getProdContent`). Holds the *promise*, not the instance: the -// assignment lands after the await, so two requests on a cold instance would each build a CMS. -let content: Promise | undefined - -// Bump CONTENT_PARSER_VERSION in `cache.ts` when these plugins or their options change cached output. -const comarkPlugins = [ - mermaid({ theme: 'zinc-light', themeDark: 'zinc-dark' }), - rangi({ theme: geistTheme }), - toc({ depth: 3 }), - emoji(), - security({ - blockedTags: ['script', 'iframe', 'embed', 'form', 'base', 'meta', 'link', 'style'], - allowDataImages: false, - }), -] - -// Bound to THIS instance so a preview content instance serves its own version's sections, not production's. -const searchSectionsPlugin = defineContentPlugin(() => ({ - name: 'search-sections', - setup(ctx) { - ctx.addServeHandler('search-sections', async () => Response.json(await buildSearchSections(ctx as unknown as DocsContent))) - }, -}))() +export type DocsContent = ReturnType /** - * Create a new content instance reading content at `ref` (a commit SHA or branch). `remote` forces the - * GitHub source, `cache` overrides comark's (in-memory by default), `watch` is dev file watching. + * Holds the source, the plugins and the cache driver. + * Once per function invocation. + * Base for all instances "cloned" with `withRef(sha)` in `contentAt()`. */ -export async function createSourceContent( - ref: string, - opts: { remote?: boolean; cache?: CacheOptions; basePath?: string; watch?: boolean } = {} -) { - // A no-op unless the consumer shadows it from their own `server/utils/`. Re-typed as the layer's own - // options: a consumer's hook is declared against the wide `ContentOptions`, and letting that widen the - // argument would erase the source and plugin types `comarkContent` infers from the literal. - const tracer = contentTracer() - const instance = comarkContent({ - markdown: { - plugins: comarkPlugins, - }, - source: contentSource(ref, { remote: opts.remote }), - plugins: [ - yaml(), // enable .navigation.yml to be detected - searchSectionsPlugin, - tracer && tracingOtel({ tracer }), - ], - cache: opts.cache, - basePath: opts.basePath, - }) +let base: DocsContent | undefined - // Only the default instance watches: others read a fixed ref that can't change, and retaining - // `watch()`'s stop function to release the watcher would leak once the preview entry is evicted. - if (import.meta.dev && opts.watch) { - await instance.watch() - instance.hooks.hook('watch:file:update', (_source:string, key: string) => { - invalidateSearchSections(instance) - console.log(`${key} updated`) - }) - instance.hooks.hook('watch:file:remove', () => invalidateSearchSections(instance)) - } +function getBaseContent(): DocsContent { + base ??= createBaseContent() + return base +} - return instance +function createBaseContent() { + return createRuntimeContentInstance({ + source: contentSource(), + cache: { driver: contentCacheDriver() }, + }) +} + +/** + * Base instance cloned and pinned to a SHA. + * Nothing is read until the first call. + * `dispose()` it when you replace it. +*/ +export function contentAt(sha: string): DocsContent { + return getBaseContent().withRef(sha) } -// The content commit this instance is pinned to. Pinning GitHub reads to an immutable SHA rather -// than the branch name bypasses the stale `raw.githubusercontent.com/` CDN. -let headRef: string | undefined +// The content commit currently served, once `getProdContent()` has resolved one. +// Pin GitHub reads to an immutable SHA: +// bypasses the stale `raw.githubusercontent.com/` CDN. +let headSha: string | undefined +/** + * The pinned head commit, or nothing while none is resolved (dev, off-Vercel, pre-first-resolve). + */ +export function getHeadSha(): string | undefined { + return headSha +} + +/** + * The pinned head SHA, falling back to the branch while none is resolved. + * The fallback only ever applies off-Vercel (self-hosted, `nuxt preview`, `vercel dev`). + */ export function getHeadRef(): string { - headRef ??= targetBranch() - return headRef + return headSha ?? targetBranch() } /** - * The SHA production should currently serve: + * The SHA prod instance currently serves: * - global config pin if one is set (production only) * - latest commit touching the content directory via `resolveContentSha()` */ @@ -110,88 +71,125 @@ export async function resolveProdSha(): Promise { return resolveContentSha(targetBranch(), contentDir) } +// Rebuild the promise when the head advances. +// Holds the promise to ensure two requests on a cold process don't each build one. +let prod: Promise | undefined + /** - * Shared content instance for the lifetime of this server instance, pinned to `headRef`. In production every - * call resolves the current head via `resolveProdSha()` — a shared, short-TTL cache, not a per-instance - * timer — and rebuilds when that advances. Previews stay pinned. + * Shared instance for the lifetime of the process, pinned to `headSha`. + * Always resolves the head via `resolveProdSha()`. + * Swaps to a new pinned instance when the head advances. */ export async function getProdContent(): Promise { if (['production', 'preview'].includes(process.env.VERCEL_ENV || '')) { const sha = await resolveProdSha() - if (sha !== getHeadRef()) { - console.log(`[content] head ${getHeadRef()} -> ${sha}`) - headRef = sha - content = undefined // the old instance baked its source at the old commit + if (sha !== headSha) { + if (headSha) { + console.log(`[comark-docs] New head: ${headSha} -> ${sha}`) + void prod?.then((instance) => instance.dispose()).catch(() => {}) + prod = undefined + } + headSha = sha } } - if (!content) { - content = createSourceContent(getHeadRef(), { - watch: true, - cache: { - driver: cacheDriver(getHeadRef()), - }, - }).catch((error) => { - // Don't memoize a failed build — the next request should retry. - content = undefined + if (!prod) { + prod = (async () => { + const instance = import.meta.dev ? await watchedDevContent() : contentAt(getHeadRef()) + const startedAt = performance.now() + await instance.init() + recordDuration('content.init.ms', startedAt) + return instance + })().catch((error) => { + prod = undefined throw error }) } - return content + return prod } -function contentSource(ref: string, opts: { remote?: boolean } = {}) { +/** The unpinned base in development — it reads the working tree and follows file changes. */ +async function watchedDevContent(): Promise { + const instance = getBaseContent() + await instance.watch() + instance.hooks.hook('watch:file:update', (_source: string, key: string) => { + invalidateSearchSections(instance) + console.log(`[comark-docs] ${key} updated`) + }) + instance.hooks.hook('watch:file:remove', () => invalidateSearchSections(instance)) + return instance +} + +/** + * The source every instance derives from. + * `withRef(sha)` pins it to a commit. + * + * Production: + * - GitHub reads the tree and files at `sha` + * - the build snapshot supplies every body whose source hash is unchanged + * + * Development: + * - unpinned reads the working tree, which `watch()` follows + * - pinned reads the repo at that commit + * - no snapshot + */ +function contentSource(): ContentSource { const { docs } = useRuntimeConfig() if (import.meta.dev) { - if (opts.remote) return gitLocalSource(ref, docs.contentDir) - - return fs(docs.contentPath) + return { + ...fs(docs.contentPath), + withRef: (ref) => gitLocalSource(ref, docs.contentDir), + } } - return github({ + const source = github({ repo: githubRepo(), - branch: ref, + branch: targetBranch(), path: docs.contentDir, token: githubToken(), - // `ref` is an immutable commit SHA => we can cache hard. + // Reads happen through `withRef()`, an immutable commit => cache hard. ttl: 60 * 60 * 24, }) + + // Snaphot build during build time by `modules/snapshot/` is used. + // Snpahost is pinned to the latest commit at the time of the build. + // First head moves, only the bodies whose source hash matches are reused. + return withSnapshot(source, () => readSnapshot(), () => readManifest()) } -/** Per-instance registry of preview CMS instances, keyed by `::`. */ -const contentPreviewInstances = new Map>() - -// Bound required: each entry is a content instance with its own manifest and parsed bodies, and public -// `/tree/:branch` / `/blob/:sha` let a crawler mint one per SHA. Evicted refs just rebuild, their -// bodies surviving in the per-SHA Runtime Cache. -const MAX_PREVIEW_INSTANCES = 8 - -export function getPreviewContent(sha: string, basePath: string): Promise { - const key = `${basePath}::${sha}` - const existing = contentPreviewInstances.get(key) - if (existing) { - // `Map` preserves insertion order, which is the whole LRU: re-insert so the MRU key is last. - contentPreviewInstances.delete(key) - contentPreviewInstances.set(key, existing) - return existing +/** + * Read the build-time snapshot, or nothing when this deployment did not ship one. + */ +async function readSnapshot(): Promise { + const span = contentTracer()?.startSpan('snapshot:read') + const startedAt = performance.now() + try { + // Untyped read: unstorage runs every value through `destr`, so this arrives already parsed. + const data = await useStorage('assets:comark-content').get(`${DEFAULT_CONTENT_NAME}/snapshot.json`) + const hit = data != null + span?.setAttribute('comark.snapshot.hit', hit) + recordDuration('content.snapshot.read.ms', startedAt, { hit: String(hit) }) + return data + } finally { + span?.end() } +} - const instance = createSourceContent(sha, { - remote: true, - basePath, - cache: { driver: cacheDriver(sha) }, - }).catch((error) => { - contentPreviewInstances.delete(key) - throw error - }) - contentPreviewInstances.set(key, instance) - - while (contentPreviewInstances.size > MAX_PREVIEW_INSTANCES) { - const oldest = contentPreviewInstances.keys().next() - if (oldest.done) break - contentPreviewInstances.delete(oldest.value) +/** + * Read the build-time snapshot, or nothing when this deployment did not ship one. + */ +async function readManifest(): Promise { + const span = contentTracer()?.startSpan('manifest:read') + const startedAt = performance.now() + try { + // Untyped read: unstorage runs every value through `destr`, so this arrives already parsed. + const data = await useStorage('assets:comark-content').get(`${DEFAULT_CONTENT_NAME}/manifest.json`) + const hit = data != null + span?.setAttribute('comark.manifest.hit', hit) + recordDuration('content.manifest.read.ms', startedAt, { hit: String(hit) }) + return data + } finally { + span?.end() } - - return instance } diff --git a/server/utils/github.ts b/server/utils/github.ts index eb995a5..4cdf51f 100644 --- a/server/utils/github.ts +++ b/server/utils/github.ts @@ -1,5 +1,6 @@ import { createHash, timingSafeEqual } from 'node:crypto' import { createStorage } from 'unstorage' +import { fetchLastContentCommit } from '../../utils/github' export interface GitHubCommit { added?: string[] @@ -13,6 +14,7 @@ export interface GitHubPushPayload { before?: string commits?: GitHubCommit[] head_commit?: GitHubCommit & { id?: string } + repository?: { full_name?: string } } /** Constant-time string comparison. */ @@ -44,13 +46,22 @@ export function targetBranch(): string { return process.env.VERCEL_GIT_COMMIT_REF || useRuntimeConfig().docs.github.branch || 'main' } -// Branch + content directory → content commit SHA pointer, shared across every instance so only one -// pays for the GitHub API call per TTL window. See `refCacheDriver()` for the single-region assumption. +/** Moving previews and negative decisions eventually refresh or recover. */ +const PREVIEW_REF_TTL = 600 + +// Branch + content directory → content commit SHA pointer, shared across every instance. The +// production branch is refreshed by its push webhook; other branches refresh on this TTL. const refStorage = createStorage({ driver: refCacheDriver() }) const normalizeContentDir = (contentDir: string) => contentDir.replace(/^\/+|\/+$/g, '') const refKey = (branch: string, contentDir: string) => `branch:${encodeURIComponent(branch)}:path:${encodeURIComponent(normalizeContentDir(contentDir))}` +/** The production webhook owns its target branch pointer; all other refs remain time-bounded. */ +function refTtl(branch: string): number | undefined { + if (process.env.VERCEL_ENV === 'production' && branch === targetBranch()) return undefined + return PREVIEW_REF_TTL +} + /** Sentinel for "this ref doesn't resolve" — see the negative caching in `resolveContentSha`. */ const UNRESOLVED = '\0unresolved' @@ -79,38 +90,34 @@ export async function resolveContentSha( if (cached) return cached } - const token = githubToken() - let commits: Array<{ sha: string }> + // Shared with the build-time snapshot, which walks the built commit instead of a branch — see + // `fetchLastContentCommit()`. One query, so the two cannot drift apart. + let sha: string | undefined try { - commits = await $fetch>(`https://api.github.com/repos/${githubRepo()}/commits`, { - headers: { - Accept: 'application/vnd.github+json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - query: { - sha: branch, - path: normalizeContentDir(contentDir), - per_page: 1, - }, + sha = await fetchLastContentCommit({ + repo: githubRepo(), + path: contentDir, + ref: branch, + token: githubToken(), }) } catch (error: unknown) { // Only a definitive 404 is cacheable; a 5xx, rate-limit 403 or network blip stays retryable. const failure = error as { statusCode?: number; response?: { status?: number } } const status = failure.statusCode ?? failure.response?.status if (status === 404) { - if (opts.cacheMisses) await refStorage.setItem(key, UNRESOLVED) + if (opts.cacheMisses) await refStorage.setItem(key, UNRESOLVED, { ttl: PREVIEW_REF_TTL }) throw createError({ statusCode: 404, statusMessage: `Ref not found: ${branch}` }) } throw error } - const sha = commits[0]?.sha if (!sha) { - if (opts.cacheMisses) await refStorage.setItem(key, UNRESOLVED) + if (opts.cacheMisses) await refStorage.setItem(key, UNRESOLVED, { ttl: PREVIEW_REF_TTL }) throw createError({ statusCode: 404, statusMessage: `Content not found at ref: ${branch}` }) } - await refStorage.setItem(key, sha) + const ttl = refTtl(branch) + await refStorage.setItem(key, sha, ttl ? { ttl } : undefined) return sha } @@ -158,8 +165,8 @@ function pullAllowsPreview(pull: GitHubPullSummary): boolean { * 1. an associated PR allows it (same-repo PR, or a fork PR carrying `preview:enabled`), or * 2. the commit is in the production branch's history (version-history links). * - * Decisions live in the short-TTL ref cache — positive ones too, so removing the label revokes - * access within a TTL. Skipped in dev, where refs resolve against the local checkout instead. + * Decisions live in the preview ref cache — positive ones too, so removing the label revokes + * access within 10 minutes. Skipped in dev, where refs resolve against the local checkout instead. */ export async function authorizePreviewSha(sha: string): Promise { if (import.meta.dev) return sha @@ -172,7 +179,7 @@ export async function authorizePreviewSha(sha: string): Promise { if (cached) return cached const deny = async (): Promise => { - await refStorage.setItem(key, UNRESOLVED) + await refStorage.setItem(key, UNRESOLVED, { ttl: PREVIEW_REF_TTL }) throw createError({ statusCode: 404, statusMessage: `No preview available for commit: ${sha}` }) } @@ -211,7 +218,7 @@ export async function authorizePreviewSha(sha: string): Promise { if (!allowed) return deny() - await refStorage.setItem(key, fullSha) + await refStorage.setItem(key, fullSha, { ttl: PREVIEW_REF_TTL }) return fullSha } @@ -219,8 +226,8 @@ export async function authorizePreviewSha(sha: string): Promise { * Authorize a `/pr/:number` preview and resolve it to the PR's head commit SHA. * * Same rule as `authorizePreviewSha`: same-repo PRs are always previewable, fork PRs only with the - * `preview:enabled` label. Cached in the short-TTL ref cache so the preview follows new pushes and - * label removal revokes it within a TTL. + * `preview:enabled` label. Cached for 10 minutes so the preview follows new pushes and label removal + * revokes it within the same bound. */ export async function resolvePullPreviewSha(number: number): Promise { const key = `preview:pr:${number}` @@ -231,7 +238,7 @@ export async function resolvePullPreviewSha(number: number): Promise { if (cached) return cached const deny = async (): Promise => { - await refStorage.setItem(key, UNRESOLVED) + await refStorage.setItem(key, UNRESOLVED, { ttl: PREVIEW_REF_TTL }) throw createError({ statusCode: 404, statusMessage: `No preview available for PR #${number}` }) } @@ -248,7 +255,7 @@ export async function resolvePullPreviewSha(number: number): Promise { const sha = pull.head?.sha if (!sha || !pullAllowsPreview(pull)) return deny() - await refStorage.setItem(key, sha) + await refStorage.setItem(key, sha, { ttl: PREVIEW_REF_TTL }) return sha } diff --git a/server/utils/local.ts b/server/utils/local.ts index 8d7ae1b..5e92c9b 100644 --- a/server/utils/local.ts +++ b/server/utils/local.ts @@ -4,7 +4,7 @@ import type { Source } from 'comark-content' import type { PageCommit } from './github' const exec = promisify(execFile) -/** Root of the git repository holding the content (resolved at build time by modules/config.ts). */ +/** Root of the git repository holding the content (resolved at build time by modules/config/). */ function repoRoot(): string { return useRuntimeConfig().docs.repoRoot } diff --git a/server/utils/metrics.ts b/server/utils/metrics.ts new file mode 100644 index 0000000..ba656d1 --- /dev/null +++ b/server/utils/metrics.ts @@ -0,0 +1,13 @@ +import { metric } from '@vercel/functions' + +/** + * Report an elapsed time to Vercel Observability, and hand it back for logging. + * + * `metric()` talks to the runtime over an IPC global that only exists on Vercel, so this is a + * no-op locally rather than an error. + */ +export function recordDuration(name: string, startedAt: number, tags?: Record): number { + const ms = Math.round(performance.now() - startedAt) + metric(name, ms, tags) + return ms +} diff --git a/server/utils/paths.ts b/server/utils/paths.ts index ae8f710..4988fa2 100644 --- a/server/utils/paths.ts +++ b/server/utils/paths.ts @@ -1,55 +1,4 @@ -/** Repo-relative content prefix (e.g. `docs/content/`), derived at build time by modules/config.ts. */ +/** Repo-relative content prefix (e.g. `docs/content/`), derived at build time by modules/config/. */ export function contentPrefix(): string { return `${useRuntimeConfig().docs.contentDir.replace(/\/$/, '')}/` } - -/** Whether a GitHub repo path is a content markdown file. */ -export function isContentMd(path: string): boolean { - return path.startsWith(contentPrefix()) && path.toLowerCase().endsWith('.md') -} - -/** Whether a GitHub repo path is a navigation config file (`.navigation.yml` / `.json`). */ -export function isNavConfig(path: string): boolean { - return path.startsWith(contentPrefix()) && /\.navigation\.(?:ya?ml|json)$/i.test(path) -} - -/** - * Parse a content repo path into route segments (`1.getting-started/2.intro.md` → - * `['getting-started', 'intro']`). `isIndex` covers both `index.md` and `index/index.md`. - */ -export function slugFromPath(path: string): { isIndex: boolean; segments: string[] } | null { - const prefix = contentPrefix() - if (!path.startsWith(prefix) || !path.toLowerCase().endsWith('.md')) return null - - const relative = path.slice(prefix.length, -3) - const segments = relative.split('/').map((s) => s.replace(/^\d+\./, '')) - const last = segments[segments.length - 1] - const isIndex = last === 'index' - if (isIndex) segments.pop() - return { isIndex, segments } -} - -/** Frontend page route (e.g. `1.getting-started/2.intro.md` → `/getting-started/intro`, root → `/`). */ -export function pageUrlForPath(path: string): string | null { - const result = slugFromPath(path) - if (!result) return null - const { isIndex, segments } = result - if (isIndex && segments.length === 0) return '/' - return `/${segments.join('/')}` -} - -/** Raw markdown route — the only per-file route that stays cached, as `/api/pages` is served live. */ -export function rawUrlForPath(path: string): string | null { - const result = slugFromPath(path) - if (!result) return null - - const { isIndex, segments } = result - if (isIndex && segments.length === 0) return '/raw/index.md' - return `/raw/${segments.join('/')}.md` -} - -/** Nuxt payload route for a frontend page route */ -export function payloadUrlForRoute(route: string, buildId?: string): string { - const path = `${route === '/' ? '' : route}/_payload.json` - return buildId ? `${path}?${buildId}` : path -} diff --git a/server/utils/preview.ts b/server/utils/preview.ts new file mode 100644 index 0000000..280e252 --- /dev/null +++ b/server/utils/preview.ts @@ -0,0 +1,45 @@ +import type { DocsContent } from './content' + +// Preview instances for `/blob/:sha`, `/tree/:branch` and `/pr/:number`. +const previews = new Map() + +const MAX_PREVIEW_INSTANCES = 8 + +function getPreviewContent(sha: string): DocsContent { + const existing = previews.get(sha) + if (existing) { + previews.delete(sha) + previews.set(sha, existing) + return existing + } + + const instance = contentAt(sha) + previews.set(sha, instance) + + while (previews.size > MAX_PREVIEW_INSTANCES) { + const oldest = previews.keys().next() + if (oldest.done) break + const evicted = previews.get(oldest.value) + previews.delete(oldest.value) + void evicted?.dispose().catch(() => {}) + } + + return instance +} + +/** + * Serve a preview request through the instance pinned to `sha`. + */ +export async function servePreview(event: Parameters[0], sha: string, segment: string) { + const request = toWebRequest(event) + const url = new URL(request.url) + url.pathname = url.pathname.replace(segment, '') + const rewritten = new Request(url, request) + + if (sha === getHeadSha()) { + const instance = await getProdContent() + // Recheck after promise resolves. + if (sha === getHeadSha()) return instance.handler(rewritten) + } + return getPreviewContent(sha).handler(rewritten) +} diff --git a/server/utils/timing.ts b/server/utils/timing.ts new file mode 100644 index 0000000..17a4cce --- /dev/null +++ b/server/utils/timing.ts @@ -0,0 +1,29 @@ +/** Named phase timings for one revalidate webhook run. */ +export interface Timings { + /** Time a sync or async `fn` under `label`; records its duration and returns its result. */ + time(label: string, fn: () => T | Promise): Promise + /** `label=123ms label2=45ms`, in recorded order — for one log line. */ + format(): string + /** ms since this recorder was created — spans the sync response and the background `waitUntil` phase. */ + since(): number +} + +export function createTimings(): Timings { + const start = performance.now() + const entries: { label: string; ms: number }[] = [] + + async function time(label: string, fn: () => T | Promise): Promise { + const phaseStart = performance.now() + try { + return await fn() + } finally { + entries.push({ label, ms: Math.round(performance.now() - phaseStart) }) + } + } + + function format(): string { + return entries.map(({ label, ms }) => `${label}=${ms}ms`).join(' ') + } + + return { time, format, since: () => Math.round(performance.now() - start) } +} diff --git a/server/utils/webhook.ts b/server/utils/webhook.ts new file mode 100644 index 0000000..d08b8fb --- /dev/null +++ b/server/utils/webhook.ts @@ -0,0 +1,117 @@ +import { DEFAULT_CONTENT_NAME, type ContentListFile } from 'comark-content' +import type { GitHubCommit } from './github' +import { hashManifestItem } from './json' + +/** How a push changed the content source, already filtered to `contentDir`. */ +export interface ContentChanges { + /** Manifest keys (`default/`) of files added or modified. */ + upserted: string[] + /** Manifest keys of files removed — only the previous manifest can resolve their paths. */ + removed: string[] + /** A `.navigation.*` file changed, so the tree changed regardless of which pages did. */ + navTouched: boolean +} + +/** Files the content source can actually serve — matches the parsers installed in `content.ts`. */ +const CONTENT_EXTENSIONS = ['.md', '.yml', '.yaml', '.json'] + +/** The content instance's name (see `createBaseContent()` in `content.ts`) — unnamed, so `default`. */ +const SOURCE_NAME = DEFAULT_CONTENT_NAME + +/** + * A push's changed content files, named by their manifest key (`default/`) — the + * reverse of `meta.key`, so a diff against `manifest.items` doesn't need to re-derive file → URL + * mappings that comark already owns. + */ +export function changesForPush(contentDir: string, commits: GitHubCommit[]): ContentChanges { + const upserted = new Set() + const removed = new Set() + let navTouched = false + + const consider = (file: string, into: Set) => { + const key = manifestKeyFor(file, contentDir) + if (!key) return + + if (isNavConfigFile(file)) navTouched = true + else into.add(key) + } + + for (const commit of commits) { + for (const file of commit.added ?? []) consider(file, upserted) + for (const file of commit.modified ?? []) consider(file, upserted) + for (const file of commit.removed ?? []) consider(file, removed) + } + + // A path removed and re-added in the same push is an upsert, not a removal. + for (const key of upserted) removed.delete(key) + + return { upserted: [...upserted], removed: [...removed], navTouched } +} + +/** Repo-relative path → its key in the manifest, or `null` when it can't be a content file. */ +function manifestKeyFor(file: string, contentDir: string): string | null { + const dir = contentDir.replace(/^\/+|\/+$/g, '') + const prefix = dir ? `${dir}/` : '' + + if (prefix && !file.startsWith(prefix)) return null + if (!CONTENT_EXTENSIONS.some((ext) => file.toLowerCase().endsWith(ext))) return null + + return `${SOURCE_NAME}/${file.slice(prefix.length)}` +} + +/** Directory configuration (`.navigation.yml`), which contributes to the tree rather than a page. */ +function isNavConfigFile(file: string): boolean { + return /\.navigation\.(?:ya?ml|json)$/i.test(file) +} + +/** + * The payload URL a client-side navigation fetches for `path` + */ +export function payloadUrlForPage(path: string, buildId?: string): string { + const base = path === '/' ? '/_payload.json' : `${path.replace(/\/$/, '')}/_payload.json` + return buildId ? `${base}?_b=${buildId}` : base +} + +/** `default/` (a manifest key) → page path, the reverse of what the path-keyed manifest gives. */ +export function indexByFileKey(items: Record): Map { + const index = new Map() + for (const item of Object.values(items)) index.set(item.meta.key, item.path) + return index +} + +/** + * Which pages a push changed, and whether the tree itself moved. + */ +export function diffContent( + changes: ContentChanges, + before: Record, + after: Record +): { pagePaths: string[]; navChanged: boolean } { + const pagePaths = new Set() + + const afterByKey = indexByFileKey(after) + const beforeByKey = indexByFileKey(before) + + for (const key of changes.upserted) { + const path = afterByKey.get(key) + if (path) pagePaths.add(path) + } + for (const key of changes.removed) { + const path = beforeByKey.get(key) + if (path) pagePaths.add(path) + } + + const beforeKeys = Object.keys(before) + const afterKeys = Object.keys(after) + const navChanged = + beforeKeys.length !== afterKeys.length || + afterKeys.some((key) => !before[key]) || + // Listing fields (title, description, icon, `navigation`…) are what the tree renders from. + afterKeys.some((key) => before[key] && !sameListing(before[key]!, after[key]!)) + + return { pagePaths: [...pagePaths], navChanged } +} + +function sameListing(a: ContentListFile, b: ContentListFile): boolean { + return a.path === b.path && hashManifestItem(a) === hashManifestItem(b) +} diff --git a/test/content-contract.test.ts b/test/content-contract.test.ts index 20de79d..0a55916 100644 --- a/test/content-contract.test.ts +++ b/test/content-contract.test.ts @@ -5,12 +5,18 @@ * which is how comark-content#77's `metaOnly` -> `partial` rename silently degraded * the webhook's body warm-up. This exercises the surface the layer depends on. */ +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { comarkContent, defineContentPlugin } from 'comark-content' +import { comarkContent, DEFAULT_CONTENT_NAME, readArtifact, writeSnapshots } from 'comark-content' import fsSource from 'comark-content/sources/fs' import githubSource from 'comark-content/sources/github' -import { createContentClient, defineContentClientPlugin } from 'comark-content/client' +import snapshot, { withSnapshot } from 'comark-content/sources/snapshot' +import { createContentClient } from 'comark-content/client' +import sqliteWasm from 'comark-content/database/sqlite-wasm' +import sqliteFullTextSearch from 'comark-content/plugins/sqlite-full-text-search' import memoryDriver from 'unstorage/drivers/memory' const fixture = fileURLToPath(new URL('./fixtures/content-contract', import.meta.url)) @@ -36,7 +42,16 @@ function createFixtureContent() { describe('comark-content contract', () => { it('exposes every entrypoint the layer imports', () => { - for (const entry of [comarkContent, defineContentPlugin, fsSource, githubSource, createContentClient, defineContentClientPlugin]) { + for (const entry of [ + comarkContent, + readArtifact, + fsSource, + githubSource, + createContentClient, + // Browser-only at runtime, but the subpaths resolve under node — enough to catch a rename. + sqliteWasm, + sqliteFullTextSearch, + ]) { expect(typeof entry).toBe('function') } }) @@ -71,24 +86,104 @@ describe('comark-content contract', () => { expect(cached!.nodes.length).toBeGreaterThan(0) }) - it('dispatches plugin serve handlers through content.handler', async () => { - // Mirrors the `search-sections` plugin in server/utils/content.ts. - const ping = defineContentPlugin(() => ({ - name: 'ping', - setup(ctx) { - ctx.addServeHandler('ping', async () => Response.json({ ok: true })) - }, - })) - - const content = comarkContent({ - source: fsSource(fixture), - cache: { driver: memoryDriver() }, - plugins: [ping()], + it('serves the manifest and snapshot artifacts through content.handler', async () => { + const content = createFixtureContent() + await content.init(full) + + // The exact paths the search worker fetches and `modules/config/` declares ISR rules for. + for (const path of ['manifest.json', `snapshot/${DEFAULT_CONTENT_NAME}.json`]) { + const response = await content.handler(new Request(`http://localhost/api/content/${path}`)) + expect(response.status, path).toBe(200) + const artifact = await response.json() + expect(Object.keys(artifact), path).toContain('checksum') + expect(Object.keys(await readArtifact(artifact)).length, path).toBeGreaterThan(0) + } + }) + + it('hydrates a sourceless instance from those artifacts', async () => { + const server = createFixtureContent() + await server.init(full) + + const fetchArtifact = async (path: string) => + await (await server.handler(new Request(`http://localhost/api/content/${path}`))).json() + + // The search feature is this round-trip, so a break here is a silently empty search index. + // `snapshot()`'s first argument is the full-body tier; the second (optional) manifest tier + // lets a bare `init()` skip downloading bodies until a document is actually requested. + const client = comarkContent({ + source: snapshot( + () => fetchArtifact(`snapshot/${DEFAULT_CONTENT_NAME}.json`), + () => fetchArtifact('manifest.json') + ), + }) + await client.init() + + expect(Object.keys((await client.manifest()).items)).toEqual(['/']) + + // Bodies have to arrive parsed: the client has no source to read a document from. + const doc = await client.get('/') + expect(doc?.data?.title).toBe('Contract fixture') + expect(doc?.nodes?.length).toBeGreaterThan(0) + }) + + describe('build-time snapshot', () => { + /** + * `modules/snapshot/` writes it with `writeSnapshots()`, and + * `server/utils/content.ts` reads it back through a Nitro server asset. Three things here are + * layout, not behaviour, and all are silent when wrong: the per-instance subdirectory, the fact + * that a server asset hands back JSON *text*, and `manifest: false` suppressing the light tier + * the layer does not read. + */ + async function writeSnapshotFile() { + const dir = await mkdtemp(join(tmpdir(), 'comark-snapshot-')) + await writeSnapshots(createFixtureContent(), { dir, manifest: false }) + // One directory per instance, named after it — ours is unnamed, so `default`. + const read = (file: string) => readFile(join(dir, DEFAULT_CONTENT_NAME, file), 'utf8') + return { snapshot: () => read('snapshot.json'), manifest: () => read('manifest.json') } + } + + it('writes the snapshot alone when the manifest tier is off', async () => { + const stored = await writeSnapshotFile() + + await expect(stored.snapshot()).resolves.toContain('Contract fixture') + await expect(stored.manifest()).rejects.toThrow() }) - const response = await content.handler(new Request('http://localhost/api/content/ping')) + it('hydrates a withSnapshot instance from the snapshot without reading the source', async () => { + const stored = await writeSnapshotFile() + + // A source that throws on any read: hydrating from the snapshot must not touch it. This is the + // cold start being bought — in production the reads it stands in for are GitHub API calls. + const unreachable = { + ...fsSource(fixture), + keys: () => { + throw new Error('the origin was walked') + }, + } + + const content = comarkContent({ + source: withSnapshot(unreachable, stored.snapshot), + cache: { driver: memoryDriver() }, + }) + await content.init(full) + + expect(Object.keys((await content.manifest()).items)).toEqual(['/']) + const doc = await content.get('/') + expect(doc?.data?.title).toBe('Contract fixture') + expect(doc?.nodes?.length).toBeGreaterThan(0) + }) - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ ok: true }) + it('falls back to the source when no snapshot is stored', async () => { + // What every ref other than the build commit gets: loaders return `null`, so the origin is + // the only provider. A snapshot that cannot prove it belongs to this ref must never be used. + const content = comarkContent({ + source: withSnapshot(fsSource(fixture), () => null), + cache: { driver: memoryDriver() }, + }) + await content.init(full) + + expect(Object.keys((await content.manifest()).items)).toEqual(['/']) + expect((await content.get('/'))?.data?.title).toBe('Contract fixture') + }) }) }) diff --git a/test/geist-theme.test.ts b/test/geist.test.ts similarity index 99% rename from test/geist-theme.test.ts rename to test/geist.test.ts index a24a6e6..bcb48ab 100644 --- a/test/geist-theme.test.ts +++ b/test/geist.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { parseMarkdown } from 'comark' import rangi from 'comark/plugins/rangi' -import { geistDark, geistLight, geistTheme } from '../utils/geist-theme' +import { geistDark, geistLight, geistTheme } from '../utils/geist' describe('Geist syntax theme', () => { it('uses the live Geist light syntax roles', () => { diff --git a/test/git.test.ts b/test/git.test.ts index 54f9356..7728da7 100644 --- a/test/git.test.ts +++ b/test/git.test.ts @@ -1,6 +1,9 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { parseGitRemote } from '../utils/git' -import { inferSiteURL } from '../utils/meta' +import { getLastCommit, getTreeSha, hasParent, parseGitRemote } from '../utils/git' describe('parseGitRemote', () => { it('parses SSH remotes', () => { @@ -31,53 +34,76 @@ describe('parseGitRemote', () => { }) }) -describe('inferSiteURL', () => { - const keys = [ - 'NUXT_PUBLIC_SITE_URL', - 'NUXT_SITE_URL', - 'VERCEL_PROJECT_PRODUCTION_URL', - 'VERCEL_BRANCH_URL', - 'VERCEL_URL', - 'URL', - 'CI_PAGES_URL', - 'CF_PAGES_URL', - ] - let saved: Record - - // `Reflect.deleteProperty` rather than `delete process.env[key]`: same effect, - // without tripping `no-dynamic-delete`. - const unset = (key: string) => Reflect.deleteProperty(process.env, key) - - beforeEach(() => { - saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])) - for (const key of keys) unset(key) + +describe('commit and tree helpers', () => { + let repo: string + + const run = (...args: string[]) => execFileSync('git', args, { cwd: repo, stdio: 'ignore' }) + const write = async (file: string, body: string) => { + await mkdir(dirname(join(repo, file)), { recursive: true }) + await writeFile(join(repo, file), body, 'utf8') + } + + beforeEach(async () => { + repo = await mkdtemp(join(tmpdir(), 'comark-git-')) + run('init', '-q', '-b', 'main') + run('config', 'user.email', 'test@example.com') + run('config', 'user.name', 'Test') + + await write('content/index.md', '# one\n') + run('add', '-A') + run('commit', '-qm', 'add content') + + // A later commit that leaves `content/` untouched, so HEAD is not the last content commit. + await write('src/app.ts', 'export const a = 1\n') + run('add', '-A') + run('commit', '-qm', 'add code') }) - afterEach(() => { - for (const [key, value] of Object.entries(saved)) { - if (value === undefined) unset(key) - else process.env[key] = value - } + afterEach(async () => { + await rm(repo, { recursive: true, force: true }) }) - it('returns undefined when nothing is set', () => { - expect(inferSiteURL()).toBeUndefined() + it('finds the last commit touching a directory, not HEAD', () => { + const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim() + const last = getLastCommit(repo, 'content') + + expect(last).toMatch(/^[0-9a-f]{40}$/) + expect(last).not.toBe(head) }) - it('adds https to a bare Vercel host', () => { - process.env.VERCEL_URL = 'my-app-abc123.vercel.app' - expect(inferSiteURL()).toBe('https://my-app-abc123.vercel.app') + it('returns the same tree for a ref whose content matches HEAD', () => { + // The whole safety property of the build-time snapshot: the commit it is labelled with has to hold + // the content that was parsed. Here the code commit did not touch `content/`, so both agree. + const last = getLastCommit(repo, 'content')! + expect(getTreeSha(repo, last, 'content')).toBe(getTreeSha(repo, 'HEAD', 'content')) }) - it('prefers the explicit override over the platform value', () => { - process.env.VERCEL_URL = 'my-app-abc123.vercel.app' - process.env.NUXT_PUBLIC_SITE_URL = 'https://docs.example.com' - expect(inferSiteURL()).toBe('https://docs.example.com') + it('returns a different tree once the content changes', async () => { + const before = getTreeSha(repo, 'HEAD', 'content')! + + await write('content/index.md', '# two\n') + run('add', '-A') + run('commit', '-qm', 'edit content') + + expect(getTreeSha(repo, 'HEAD', 'content')).not.toBe(before) + // A stale label is what the snapshot must never be written under. + expect(getTreeSha(repo, 'HEAD~1', 'content')).toBe(before) }) - it('prefers the production URL over the per-branch one', () => { - process.env.VERCEL_BRANCH_URL = 'branch.vercel.app' - process.env.VERCEL_PROJECT_PRODUCTION_URL = 'docs.comark.dev' - expect(inferSiteURL()).toBe('https://docs.comark.dev') + it('returns undefined for a ref or path outside the checkout', () => { + expect(getTreeSha(repo, 'HEAD', 'nope')).toBeUndefined() + expect(getTreeSha(repo, 'a'.repeat(40), 'content')).toBeUndefined() + expect(getLastCommit(repo, 'nope')).toBeUndefined() + }) + + it('reports a missing parent at the root commit', () => { + const root = execFileSync('git', ['rev-list', '--max-parents=0', 'HEAD'], { + cwd: repo, + encoding: 'utf8', + }).trim() + + expect(hasParent(repo, 'HEAD')).toBe(true) + expect(hasParent(repo, root)).toBe(false) }) }) diff --git a/test/github.test.ts b/test/github.test.ts index b39103d..24c4904 100644 --- a/test/github.test.ts +++ b/test/github.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { resolveContentSha } from '../server/utils/github' +/** One commits-query response. `resolveContentSha` reads only `sha`. */ +const commits = (sha: string) => new Response(JSON.stringify([{ sha }]), { status: 200 }) + afterEach(() => { vi.unstubAllEnvs() vi.unstubAllGlobals() @@ -8,21 +11,23 @@ afterEach(() => { describe('resolveContentSha', () => { it('resolves the latest commit touching the configured content directory', async () => { - const fetch = vi.fn().mockResolvedValue([{ sha: 'content-sha' }]) - vi.stubGlobal('$fetch', fetch) + const fetch = vi.fn().mockResolvedValue(commits('content-sha')) + vi.stubGlobal('fetch', fetch) await expect(resolveContentSha('feat/docs', '/docs/content/')).resolves.toBe('content-sha') - expect(fetch).toHaveBeenCalledWith( - 'https://api.github.com/repos/comarkdown/comark-docs/commits', - expect.objectContaining({ - query: { sha: 'feat/docs', path: 'docs/content', per_page: 1 }, - }) - ) + + const requested = new URL(String(fetch.mock.calls[0]![0])) + expect(requested.pathname).toBe('/repos/comarkdown/comark-docs/commits') + expect(Object.fromEntries(requested.searchParams)).toEqual({ + sha: 'feat/docs', + path: 'docs/content', + per_page: '1', + }) }) it('caches each branch and content directory independently', async () => { - const fetch = vi.fn().mockResolvedValueOnce([{ sha: 'docs-sha' }]).mockResolvedValueOnce([{ sha: 'api-sha' }]) - vi.stubGlobal('$fetch', fetch) + const fetch = vi.fn().mockResolvedValueOnce(commits('docs-sha')).mockResolvedValueOnce(commits('api-sha')) + vi.stubGlobal('fetch', fetch) await expect(resolveContentSha('test/cache-key', 'docs/content')).resolves.toBe('docs-sha') await expect(resolveContentSha('test/cache-key', 'docs/content')).resolves.toBe('docs-sha') @@ -31,8 +36,8 @@ describe('resolveContentSha', () => { }) it('can refresh a cached content revision for the push webhook', async () => { - const fetch = vi.fn().mockResolvedValueOnce([{ sha: 'before' }]).mockResolvedValueOnce([{ sha: 'after' }]) - vi.stubGlobal('$fetch', fetch) + const fetch = vi.fn().mockResolvedValueOnce(commits('before')).mockResolvedValueOnce(commits('after')) + vi.stubGlobal('fetch', fetch) await expect(resolveContentSha('test/refresh', 'docs/content')).resolves.toBe('before') await expect(resolveContentSha('test/refresh', 'docs/content')).resolves.toBe('before') diff --git a/test/paths.test.ts b/test/paths.test.ts index e8c96bb..36f36e9 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -1,14 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { resetRuntimeConfig, setRuntimeConfig } from './setup' -import { - contentPrefix, - isContentMd, - isNavConfig, - pageUrlForPath, - payloadUrlForRoute, - rawUrlForPath, - slugFromPath, -} from '../server/utils/paths' +import { contentPrefix } from '../server/utils/paths' afterEach(resetRuntimeConfig) @@ -19,79 +11,3 @@ describe('contentPrefix', () => { expect(contentPrefix()).toBe('docs/content/') }) }) - -describe('isContentMd', () => { - it('matches markdown under the content dir only', () => { - expect(isContentMd('content/index.md')).toBe(true) - expect(isContentMd('content/1.guide/2.intro.MD')).toBe(true) - expect(isContentMd('content/.navigation.yml')).toBe(false) - expect(isContentMd('README.md')).toBe(false) - expect(isContentMd('other/content/x.md')).toBe(false) - }) - - it('follows a nested content dir', () => { - setRuntimeConfig({ contentDir: 'docs/content' }) - expect(isContentMd('docs/content/x.md')).toBe(true) - expect(isContentMd('content/x.md')).toBe(false) - }) -}) - -describe('isNavConfig', () => { - it('matches the yml/yaml/json navigation files', () => { - expect(isNavConfig('content/1.guide/.navigation.yml')).toBe(true) - expect(isNavConfig('content/.navigation.yaml')).toBe(true) - expect(isNavConfig('content/.navigation.json')).toBe(true) - expect(isNavConfig('content/navigation.yml')).toBe(false) - expect(isNavConfig('content/x.md')).toBe(false) - }) -}) - -describe('slugFromPath', () => { - it('strips numeric ordering prefixes at every level', () => { - expect(slugFromPath('content/1.getting-started/2.intro.md')).toEqual({ - isIndex: false, - segments: ['getting-started', 'intro'], - }) - }) - - it('treats index files as their parent', () => { - expect(slugFromPath('content/index.md')).toEqual({ isIndex: true, segments: [] }) - expect(slugFromPath('content/1.guide/index.md')).toEqual({ isIndex: true, segments: ['guide'] }) - }) - - it('returns null for anything outside the content dir', () => { - expect(slugFromPath('README.md')).toBeNull() - expect(slugFromPath('content/.navigation.yml')).toBeNull() - }) -}) - -describe('pageUrlForPath', () => { - it('maps content files to page routes', () => { - expect(pageUrlForPath('content/index.md')).toBe('/') - expect(pageUrlForPath('content/1.guide/index.md')).toBe('/guide') - expect(pageUrlForPath('content/1.guide/2.intro.md')).toBe('/guide/intro') - expect(pageUrlForPath('content/x.yml')).toBeNull() - }) -}) - -describe('rawUrlForPath', () => { - it('maps content files to their raw markdown mirror', () => { - expect(rawUrlForPath('content/index.md')).toBe('/raw/index.md') - expect(rawUrlForPath('content/1.guide/2.intro.md')).toBe('/raw/guide/intro.md') - expect(rawUrlForPath('content/1.guide/index.md')).toBe('/raw/guide.md') - expect(rawUrlForPath('README.md')).toBeNull() - }) -}) - -describe('payloadUrlForRoute', () => { - it('builds the payload URL the browser actually requests', () => { - expect(payloadUrlForRoute('/')).toBe('/_payload.json') - expect(payloadUrlForRoute('/guide/intro')).toBe('/guide/intro/_payload.json') - }) - - it('appends the build id when there is one', () => { - // The webhook has to purge the exact keyed URL, not the bare path. - expect(payloadUrlForRoute('/guide', 'abc123')).toBe('/guide/_payload.json?abc123') - expect(payloadUrlForRoute('/', 'abc123')).toBe('/_payload.json?abc123') - }) -}) diff --git a/test/setup.ts b/test/setup.ts index ba5873c..d5a4cde 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,7 +1,7 @@ // Nitro auto-imports, provided by hand: modules under `server/` are written against Nitro's globals, so // importing one directly in a test leaves those names undefined. Declaring the few the tests touch here beats // pulling in the whole Nuxt/Nitro harness for a handful of pure functions. `useRuntimeConfig` returns the shape -// `modules/config.ts` seeds. +// `modules/config/` seeds. import memoryDriver from 'unstorage/drivers/memory' export interface TestRuntimeConfig { diff --git a/test/webhook.test.ts b/test/webhook.test.ts new file mode 100644 index 0000000..258fd39 --- /dev/null +++ b/test/webhook.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import type { ContentListFile } from 'comark-content' +import type { GitHubCommit } from '../server/utils/github' +import { changesForPush, diffContent, indexByFileKey, payloadUrlForPage } from '../server/utils/webhook' +import { rawUrlForPage } from '../server/utils/markdown' + +const commit = (partial: GitHubCommit): GitHubCommit => partial + +describe('changesForPush', () => { + it('classifies added/modified/removed content files, keyed by their manifest key', () => { + const commits = [ + commit({ + added: ['content/1.guide/2.intro.md'], + modified: ['content/index.md'], + removed: ['content/old.md'], + }), + ] + expect(changesForPush('content', commits)).toEqual({ + upserted: ['default/1.guide/2.intro.md', 'default/index.md'], + removed: ['default/old.md'], + navTouched: false, + }) + }) + + it('ignores files outside the content dir', () => { + expect(changesForPush('content', [commit({ modified: ['README.md', 'other/content/x.md'] })])).toEqual({ + upserted: [], + removed: [], + navTouched: false, + }) + }) + + it('follows a nested content dir', () => { + expect(changesForPush('docs/content', [commit({ modified: ['docs/content/x.md'] })])).toEqual({ + upserted: ['default/x.md'], + removed: [], + navTouched: false, + }) + }) + + it('covers every parser extension, not just markdown', () => { + const commits = [commit({ added: ['content/data.yml', 'content/data.yaml', 'content/data.json'] })] + expect(changesForPush('content', commits).upserted).toEqual([ + 'default/data.yml', + 'default/data.yaml', + 'default/data.json', + ]) + }) + + it('flags a navigation config file instead of collecting it', () => { + const commits = [commit({ modified: ['content/1.guide/.navigation.yml'] })] + expect(changesForPush('content', commits)).toEqual({ upserted: [], removed: [], navTouched: true }) + }) + + it('treats a path removed and re-added in the same push as an upsert', () => { + const commits = [commit({ added: ['content/index.md'], removed: ['content/index.md'] })] + expect(changesForPush('content', commits)).toEqual({ + upserted: ['default/index.md'], + removed: [], + navTouched: false, + }) + }) +}) + +describe('payloadUrlForPage', () => { + it('matches the `_b` query param Nuxt requests (`nuxt/dist/app/composables/payload.js`)', () => { + expect(payloadUrlForPage('/')).toBe('/_payload.json') + expect(payloadUrlForPage('/guide/intro')).toBe('/guide/intro/_payload.json') + expect(payloadUrlForPage('/guide', 'abc123')).toBe('/guide/_payload.json?_b=abc123') + expect(payloadUrlForPage('/', 'abc123')).toBe('/_payload.json?_b=abc123') + }) +}) + +describe('rawUrlForPage', () => { + it('is the exact inverse of pagePathFromRawSlug', () => { + expect(rawUrlForPage('/')).toBe('/raw/index.md') + expect(rawUrlForPage('/guide/intro')).toBe('/raw/guide/intro.md') + }) +}) + +describe('indexByFileKey', () => { + it('maps a manifest key back to its page path', () => { + const items: Record = { + '/guide/intro': { path: '/guide/intro', data: {}, meta: { key: 'content/1.guide/2.intro.md' } } as never, + } + expect(indexByFileKey(items).get('content/1.guide/2.intro.md')).toBe('/guide/intro') + }) +}) + +describe('diffContent', () => { + const file = (path: string, key: string, data: Record = {}): ContentListFile => + ({ path, data, meta: { key } }) as never + + it('resolves upserted/removed manifest keys to page paths', () => { + const before = { '/old': file('/old', 'content/old.md') } + const after = { '/guide/intro': file('/guide/intro', 'content/1.guide/2.intro.md') } + const changes = { upserted: ['content/1.guide/2.intro.md'], removed: ['content/old.md'], navTouched: false } + expect(diffContent(changes, before, after).pagePaths.sort()).toEqual(['/guide/intro', '/old']) + }) + + it('flags navChanged when a page is added or removed', () => { + const before = { '/a': file('/a', 'content/a.md') } + const after = { '/a': file('/a', 'content/a.md'), '/b': file('/b', 'content/b.md') } + expect(diffContent({ upserted: [], removed: [], navTouched: false }, before, after).navChanged).toBe(true) + }) + + it('flags navChanged when listing data changes, even with the same page set', () => { + const before = { '/a': file('/a', 'content/a.md', { title: 'A' }) } + const after = { '/a': file('/a', 'content/a.md', { title: 'B' }) } + expect(diffContent({ upserted: [], removed: [], navTouched: false }, before, after).navChanged).toBe(true) + }) + + it('does not flag navChanged when nothing listing-relevant moved', () => { + const before = { '/a': file('/a', 'content/a.md', { title: 'A' }) } + const after = { '/a': file('/a', 'content/a.md', { title: 'A' }) } + expect(diffContent({ upserted: ['content/a.md'], removed: [], navTouched: false }, before, after).navChanged).toBe( + false + ) + }) +}) diff --git a/utils/content.ts b/utils/content.ts new file mode 100644 index 0000000..120ac52 --- /dev/null +++ b/utils/content.ts @@ -0,0 +1,60 @@ +import type { Tracer } from '@opentelemetry/api' +import { type ContentOptions, comarkContent } from 'comark-content' +import markdown from 'comark-content/plugins/markdown' +import yaml from 'comark-content/plugins/yaml' +import tracingOtel from 'comark-content/plugins/tracing/otel' +import rangi from 'comark/plugins/rangi' +import security from 'comark/plugins/security' +import emoji from 'comark/plugins/emoji' +import toc from 'comark/plugins/toc' +import mermaid from 'comark/plugins/mermaid' +import { geistTheme } from './geist.ts' +import { contentTracer } from '../server/utils/tracer.ts' + +/** Frontmatter kept in the manifest, so `list()` and `navigation()` render without reading bodies. */ +const LISTING_FIELDS = ['title', 'description', 'navigation', 'icon', 'layout'] + +// Bump CONTENT_PARSER_VERSION in `server/utils/cache.ts` when these plugins or their options change cached output. +const comarkPlugins = [ + mermaid({ theme: 'zinc-light', themeDark: 'zinc-dark' }), + rangi({ theme: geistTheme }), + toc({ depth: 3 }), + emoji(), + security({ + blockedTags: ['script', 'iframe', 'embed', 'form', 'base', 'meta', 'link', 'style'], + allowDataImages: false, + }), +] + +/** + * The parser, in one place for: + * - The build-time snapshot + * - The runtime instance + */ +function create(options: Pick, tracer?: Tracer) { + return comarkContent({ + source: options.source, + plugins: [ + markdown({ + comark: { plugins: comarkPlugins }, + listingFields: LISTING_FIELDS, + }), + yaml({ listingFields: LISTING_FIELDS }), + tracer && tracingOtel({ tracer }), + ], + cache: options.cache, + basePath: options.basePath, + }) +} + +/** An instance serving requests: traced, and cached per content SHA. */ +export function createRuntimeContentInstance(options: Pick) { + return create(options, contentTracer()) +} + +/** + * The throwaway instance the build-time snapshot is parsed with (`modules/snapshot/`). + */ +export function createBuildContentInstance(options: Pick) { + return create(options) +} diff --git a/utils/geist-theme.ts b/utils/geist.ts similarity index 100% rename from utils/geist-theme.ts rename to utils/geist.ts diff --git a/utils/git.ts b/utils/git.ts index ad1a7ba..e323fa6 100644 --- a/utils/git.ts +++ b/utils/git.ts @@ -1,4 +1,4 @@ -import { execSync } from 'node:child_process' +import { execFileSync } from 'node:child_process' export interface GitInfo { name: string @@ -6,11 +6,10 @@ export interface GitInfo { url: string } -function git(command: string, cwd: string): string | undefined { +/** Run git with an argv array — no shell, so paths with spaces need no quoting. */ +function git(args: string[], cwd: string): string | undefined { try { - return execSync(`git ${command}`, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }) - .toString() - .trim() + return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { return undefined } @@ -27,19 +26,19 @@ export function getGitBranch(cwd: string): string { if (envName && envName !== 'HEAD') return envName - const branch = git('rev-parse --abbrev-ref HEAD', cwd) + const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd) return branch && branch !== 'HEAD' ? branch : 'main' } /** Absolute path of the git repository root containing `cwd`, if any. */ export function getGitRoot(cwd: string): string | undefined { - return git('rev-parse --show-toplevel', cwd) + return git(['rev-parse', '--show-toplevel'], cwd) } /** * Owner/name/url from a git remote URL, in both forms `git remote get-url` emits (`git@host:owner/name.git`, * `https://host/owner/name(.git)`). Split out from `getLocalGitInfo` so the regex is testable without a - * checkout — every inferred default in `modules/config.ts` (site name, edit links, webhook repo) flows from it. + * checkout — every inferred default in `modules/config/` (site name, edit links, webhook repo) flows from it. */ export function parseGitRemote(remote: string): GitInfo | undefined { const match = remote.trim().match(/^(?:git@|https?:\/\/)([^/:]+)[/:]([^/]+)\/(.+?)(?:\.git)?$/) @@ -51,7 +50,7 @@ export function parseGitRemote(remote: string): GitInfo | undefined { /** Owner/name/url parsed from the `origin` remote of the local checkout. */ export function getLocalGitInfo(cwd: string): GitInfo | undefined { - const remote = git('remote get-url origin', cwd) + const remote = git(['remote', 'get-url', 'origin'], cwd) return remote ? parseGitRemote(remote) : undefined } @@ -74,3 +73,32 @@ export function getGitEnv(): GitInfo | undefined { return { name, owner, url: `https://${provider || 'github'}.com/${owner}/${name}` } } + +/** + * The last commit touching `dir`, or `undefined`. + * + * Unverified on purpose. CI clones shallowly, and when the last commit touching `dir` predates the + * fetched window git answers with the shallow boundary commit rather than nothing — at depth 1, + * that is HEAD for every path. Confirm the answer with {@link getTreeSha} before trusting it to + * name a commit's content. + */ +export function getLastCommit(cwd: string, dir: string): string | undefined { + const sha = git(['log', '-1', '--format=%H', '--', dir], cwd) + return sha && /^[0-9a-f]{40}$/.test(sha) ? sha : undefined +} + +/** Tree object id of `:`, or `undefined` when the ref or the path is not in this checkout. */ +export function getTreeSha(cwd: string, ref: string, dir: string): string | undefined { + return git(['rev-parse', `${ref}:${dir}`], cwd) +} + +/** Whether `ref` has a parent in this checkout. `false` at a shallow-clone boundary. */ +export function hasParent(cwd: string, ref: string): boolean { + return Boolean(git(['rev-parse', '--verify', `${ref}^`], cwd)) +} + +/** The commit checked out here, falling back to the CI-provided one. */ +export function headCommit(cwd: string): string | undefined { + const sha = git(['rev-parse', 'HEAD'], cwd) || process.env.VERCEL_GIT_COMMIT_SHA + return sha && /^[0-9a-f]{40}$/.test(sha) ? sha : undefined +} diff --git a/utils/github.ts b/utils/github.ts new file mode 100644 index 0000000..24cfec0 --- /dev/null +++ b/utils/github.ts @@ -0,0 +1,36 @@ +export interface LastContentCommitOptions { + /** `owner/name` of the content repository. */ + repo: string + /** Content directory; leading and trailing slashes are trimmed. */ + path: string + /** Branch or commit to walk history from. */ + ref: string + token?: string +} + +/** + * The last commit reachable from `ref` that touched `path`. + */ +export async function fetchLastContentCommit(opts: LastContentCommitOptions): Promise { + const query = new URLSearchParams({ + sha: opts.ref, + path: opts.path.replace(/^\/+|\/+$/g, ''), + per_page: '1', + }) + + const response = await fetch(`https://api.github.com/repos/${opts.repo}/commits?${query}`, { + headers: { + Accept: 'application/vnd.github+json', + ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}), + }, + }) + + if (!response.ok) { + throw Object.assign(new Error(`GitHub commits query failed with ${response.status}`), { + statusCode: response.status, + }) + } + + const commits = (await response.json()) as Array<{ sha?: string }> + return commits[0]?.sha +} diff --git a/utils/icons.ts b/utils/icons.ts index 4c3d517..88e761e 100644 --- a/utils/icons.ts +++ b/utils/icons.ts @@ -1,33 +1,19 @@ -import { readFileSync } from 'node:fs' import { resolveModulePath } from 'exsolve' -// Icon collections this layer's components draw from: `lucide` (UI affordances), `simple-icons` (brand marks), -// `vscode-icons` (the file-type icons Nuxt UI's `CodeIcon` derives from a filename). -export const LAYER_ICON_COLLECTIONS = ['lucide', 'simple-icons', 'vscode-icons'] - -/** Parsed collections, memoized — the client-bundle template regenerates in dev. */ -let cache: IconifyJSONish[] | undefined - -/** The shape `@nuxt/icon` accepts in `customCollections` (a raw `IconifyJSON`). */ -interface IconifyJSONish { - prefix: string - icons: Record - [key: string]: unknown -} +// Collections served by this layer's `/api/_nuxt_icon` endpoint. +// - `lucide`: UI affordances. +// - `simple-icons`, `logos`: brand marks, mostly reached from consumer content. +// - `vscode-icons`: the file-type icons Nuxt UI's `CodeIcon` derives from a filename. +// - `unjs`: icons from UnJS's `unjs/icons` repository. +// - `logos`: icons from logos. +export const LAYER_ICON_COLLECTIONS = ['lucide', 'simple-icons', 'vscode-icons', 'logos', 'unjs'] /** - * Load the layer's icon collections as data, resolved from the layer itself. - * - * `@nuxt/icon` discovers `@iconify-json/*` only by walking `node_modules/@iconify-json` up from the consuming - * app's `rootDir`/`workspaceDir` (`modulesDir` is never consulted — see `getResolvePaths`). These packages are - * dependencies of *this layer*, so under a non-hoisting install (pnpm's default `isolated` linker) they sit in - * the virtual store, out of reach: zero collections, an empty client bundle, every icon fetched from - * api.iconify.design at runtime. Done unconditionally, so behaviour is the same however the layer is consumed. + * Nitro aliases pinning each collection to the copy installed beside this layer. */ -export function layerIconCollections(): IconifyJSONish[] { - cache ??= LAYER_ICON_COLLECTIONS.map((prefix) => { - const path = resolveModulePath(`@iconify-json/${prefix}/icons.json`, { from: import.meta.url }) - return JSON.parse(readFileSync(path, 'utf8')) as IconifyJSONish - }) - return cache +export function layerIconAliases(): Record { + return Object.fromEntries(LAYER_ICON_COLLECTIONS.map((prefix) => { + const id = `@iconify-json/${prefix}/icons.json` + return [id, resolveModulePath(id, { from: import.meta.url })] + })) } diff --git a/utils/meta.ts b/utils/meta.ts deleted file mode 100644 index 505f57f..0000000 --- a/utils/meta.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { resolve } from 'pathe' -import { withHttps } from 'ufo' - -/** Infer the public site URL from the deployment platform env. */ -export function inferSiteURL(): string | undefined { - // https://github.com/unjs/std-env/issues/59 - const url = - process.env.NUXT_PUBLIC_SITE_URL || - process.env.NUXT_SITE_URL || - process.env.VERCEL_PROJECT_PRODUCTION_URL || - process.env.VERCEL_BRANCH_URL || - process.env.VERCEL_URL || - process.env.URL || // Netlify - process.env.CI_PAGES_URL || // GitLab Pages - process.env.CF_PAGES_URL // Cloudflare Pages - - return url ? withHttps(url) : undefined -} - -export async function getPackageJsonMetadata(dir: string): Promise<{ name?: string; description?: string }> { - try { - const parsed = JSON.parse(await readFile(resolve(dir, 'package.json'), 'utf-8')) - return { name: parsed.name, description: parsed.description } - } catch { - return {} - } -} diff --git a/utils/first-leaf.ts b/utils/navigation.ts similarity index 100% rename from utils/first-leaf.ts rename to utils/navigation.ts diff --git a/utils/render-trace.ts b/utils/render-trace.ts new file mode 100644 index 0000000..841d78c --- /dev/null +++ b/utils/render-trace.ts @@ -0,0 +1,44 @@ +import { context, trace, type Attributes, type Span, type Tracer } from '@opentelemetry/api' +import type { H3Event } from 'h3' + +export interface RenderTraceState { + request?: Span + render?: Span + vue?: Span + finalize?: Span + html?: Span + response?: Span + isPayload: boolean +} + +type TracedEventContext = H3Event['context'] & { + _comarkRenderTrace?: RenderTraceState +} + +export function getRenderTrace(event: H3Event): RenderTraceState | undefined { + return (event.context as TracedEventContext)._comarkRenderTrace +} + +export function createRenderTrace(event: H3Event, isPayload: boolean): RenderTraceState { + const state = { isPayload } + ;(event.context as TracedEventContext)._comarkRenderTrace = state + return state +} + +export function startRenderSpan( + tracer: Tracer, + name: string, + parent?: Span, + attributes?: Attributes +): Span { + const parentContext = parent ? trace.setSpan(context.active(), parent) : context.active() + return tracer.startSpan(name, { attributes }, parentContext) +} + +export function finishRenderSpan( + state: RenderTraceState, + key: Exclude +): void { + state[key]?.end() + state[key] = undefined +}