diff --git a/package.json b/package.json index 80785e2..913d7fa 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "ai": "^7.0.77", "beautiful-mermaid": "^1.1.3", "comark": "^0.6.2", - "comark-content": "https://pkg.pr.new/comark-content@6b8aae4", + "comark-content": "https://pkg.pr.new/comark-content@baefd4d", "defu": "^6.1.7", "exsolve": "^1.1.1", "js-yaml": "^5.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33fb7c7..524cc69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,8 +84,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@6b8aae4 - version: https://pkg.pr.new/comark-content@6b8aae4(@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@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) defu: specifier: ^6.1.7 version: 6.1.7 @@ -3275,8 +3275,8 @@ packages: colortranslator@5.0.0: resolution: {integrity: sha512-Z3UPUKasUVDFCDYAjP2fmlVRf1jFHJv1izAmPjiOa0OCIw1W7iC8PZ2GsoDa8uZv+mKyWopxxStT9q05+27h7w==} - comark-content@https://pkg.pr.new/comark-content@6b8aae4: - resolution: {integrity: sha512-xAVSgpAUw8HXop9QoqU1PpRkBmsHRu/r/3fp4IjpGW0LrffScF+VXRo7C6BG91GR3xIJlkS4t1nywPOlWR0ssQ==, tarball: https://pkg.pr.new/comark-content@6b8aae4} + 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} version: 0.3.0 hasBin: true @@ -9906,7 +9906,7 @@ snapshots: colortranslator@5.0.0: {} - comark-content@https://pkg.pr.new/comark-content@6b8aae4(@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@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): dependencies: citty: 0.2.2 comark: 0.6.2(beautiful-mermaid@1.1.3)(rangi@2.2.0)(shiki@4.4.3) diff --git a/server/api/revalidate.post.ts b/server/api/revalidate.post.ts index 70d65a8..51de3d5 100644 --- a/server/api/revalidate.post.ts +++ b/server/api/revalidate.post.ts @@ -122,7 +122,7 @@ export default defineEventHandler(async (event) => { try { const oldContent = await createSourceContent(beforeSha) await oldContent.init() - oldItems = oldContent.manifest.items + 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) @@ -133,7 +133,7 @@ export default defineEventHandler(async (event) => { // 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 = headContent.manifest.items + const newItems = (await headContent.manifest()).items const oldPaths = new Set(Object.keys(oldItems)) const newPaths = Object.keys(newItems) diff --git a/server/utils/cache.ts b/server/utils/cache.ts index 2dae593..9c686e1 100644 --- a/server/utils/cache.ts +++ b/server/utils/cache.ts @@ -17,7 +17,7 @@ function cacheAvailable(): boolean { * 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 = 'v2' +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 { diff --git a/server/utils/content.ts b/server/utils/content.ts index 9ac948d..862de45 100644 --- a/server/utils/content.ts +++ b/server/utils/content.ts @@ -1,4 +1,4 @@ -import { defineContentPlugin, type ComarkContent, type CacheOptions, comarkContent } from 'comark-content'; +import { defineContentPlugin, type CacheOptions, comarkContent } from 'comark-content'; import fs from 'comark-content/sources/fs' import github from 'comark-content/sources/github' import rangi from 'comark/plugins/rangi' @@ -11,9 +11,21 @@ import tracingOtel from 'comark-content/plugins/tracing/otel' import { contentTracer } from './tracer.ts' import { geistTheme } from '../../utils/geist-theme.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. + */ +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 +let content: Promise | undefined // Bump CONTENT_PARSER_VERSION in `cache.ts` when these plugins or their options change cached output. const comarkPlugins = [ @@ -31,7 +43,7 @@ const comarkPlugins = [ const searchSectionsPlugin = defineContentPlugin(() => ({ name: 'search-sections', setup(ctx) { - ctx.addServeHandler('search-sections', async () => Response.json(await buildSearchSections(ctx as unknown as ComarkContent))) + ctx.addServeHandler('search-sections', async () => Response.json(await buildSearchSections(ctx as unknown as DocsContent))) }, }))() @@ -51,9 +63,7 @@ export async function createSourceContent( markdown: { plugins: comarkPlugins, }, - sources: { - content: contentSource(ref, { remote: opts.remote }), - }, + source: contentSource(ref, { remote: opts.remote }), plugins: [ yaml(), // enable .navigation.yml to be detected searchSectionsPlugin, @@ -105,7 +115,7 @@ export async function resolveProdSha(): Promise { * 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. */ -export async function getProdContent(): Promise { +export async function getProdContent(): Promise { if (['production', 'preview'].includes(process.env.VERCEL_ENV || '')) { const sha = await resolveProdSha() if (sha !== getHeadRef()) { @@ -150,14 +160,14 @@ function contentSource(ref: string, opts: { remote?: boolean } = {}) { } /** Per-instance registry of preview CMS instances, keyed by `::`. */ -const contentPreviewInstances = new Map>() +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 { +export function getPreviewContent(sha: string, basePath: string): Promise { const key = `${basePath}::${sha}` const existing = contentPreviewInstances.get(key) if (existing) { diff --git a/server/utils/markdown.ts b/server/utils/markdown.ts index b17c94c..f432289 100644 --- a/server/utils/markdown.ts +++ b/server/utils/markdown.ts @@ -1,7 +1,7 @@ -import type { ComarkContent } from 'comark-content' +import type { DocsContent } from './content' import { renderMarkdown } from 'comark/render' -export async function renderPageMarkdown(content: ComarkContent, path: string): Promise { +export async function renderPageMarkdown(content: DocsContent, path: string): Promise { const item = await content.get(path) if (!item || item.meta.kind !== 'document') return null diff --git a/server/utils/search.ts b/server/utils/search.ts index af4a2c6..117d506 100644 --- a/server/utils/search.ts +++ b/server/utils/search.ts @@ -1,4 +1,4 @@ -import type { ComarkContent } from 'comark-content' +import type { DocsContent } from './content' interface SearchSection { id: string @@ -21,14 +21,14 @@ const SEARCH_SECTIONS_KEY = 'search-sections' * instance watches the working tree, so content changes under a stable ref. Called from * `watch:file:update`. */ -export function invalidateSearchSections(content: ComarkContent): void { +export function invalidateSearchSections(content: DocsContent): void { void content.cache.invalidate(SEARCH_SECTIONS_KEY).catch(() => {}) } /** * Keyword-score the search index against `query` and return the best sections. */ -export async function searchDocSections(content: ComarkContent, query: string, limit = 10): Promise { +export async function searchDocSections(content: DocsContent, query: string, limit = 10): Promise { const sections = await buildSearchSections(content) const terms = query.toLowerCase().split(/\s+/).filter(Boolean) @@ -50,7 +50,7 @@ export async function searchDocSections(content: ComarkContent, query: string, l } /** Walk every document's AST, one section per heading — the shape `UContentSearch` takes as `files`. */ -export async function buildSearchSections(content: ComarkContent): Promise { +export async function buildSearchSections(content: DocsContent): Promise { const cached = await content.cache.get(SEARCH_SECTIONS_KEY) if (cached) return cached @@ -60,8 +60,8 @@ export async function buildSearchSections(content: ComarkContent): Promise { - const docs = await content.list(['content']) +async function collectSearchSections(content: DocsContent): Promise { + const docs = await content.list() const sections: SearchSection[] = [] // Parsed up front rather than one await per iteration; the walk below is order-dependent. diff --git a/test/content-contract.test.ts b/test/content-contract.test.ts index 48b3ca6..20de79d 100644 --- a/test/content-contract.test.ts +++ b/test/content-contract.test.ts @@ -19,14 +19,17 @@ const fixture = fileURLToPath(new URL('./fixtures/content-contract', import.meta * The warm-up options from `server/api/revalidate.post.ts` — keep the two in step. * This object has to keep meaning "full init" on whichever build is installed. */ -const full = { partial: false, metaOnly: false } +const full = { partial: false } -/** `:` — the cache key comark-content writes a parsed body under. */ -const bodyKey = 'content:index.md' +/** + * `:` — the cache key comark-content writes a parsed body + * under. The prefix is the *instance* name, which defaults to `default`. + */ +const bodyKey = 'default:index.md' function createFixtureContent() { return comarkContent({ - sources: { content: fsSource(fixture) }, + source: fsSource(fixture), cache: { driver: memoryDriver() }, }) } @@ -42,8 +45,10 @@ describe('comark-content contract', () => { const content = createFixtureContent() await content.init() - expect(Object.keys(content.manifest.items)).toHaveLength(1) - expect(content.manifest.items['/']?.data?.title).toBe('Contract fixture') + // `manifest` is an async method returning saveable data, not a live property. + const manifest = await content.manifest() + expect(Object.keys(manifest.items)).toHaveLength(1) + expect(manifest.items['/']?.data?.title).toBe('Contract fixture') }) it('writes parsed bodies to the cache on the revalidate warm-up init', async () => { @@ -76,7 +81,7 @@ describe('comark-content contract', () => { })) const content = comarkContent({ - sources: { content: fsSource(fixture) }, + source: fsSource(fixture), cache: { driver: memoryDriver() }, plugins: [ping()], })