Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions server/api/revalidate.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion server/utils/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 19 additions & 9 deletions server/utils/content.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<ReturnType<typeof createSourceContent>>

// 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<ComarkContent> | undefined
let content: Promise<DocsContent> | undefined

// Bump CONTENT_PARSER_VERSION in `cache.ts` when these plugins or their options change cached output.
const comarkPlugins = [
Expand All @@ -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)))
},
}))()

Expand All @@ -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,
Expand Down Expand Up @@ -105,7 +115,7 @@ export async function resolveProdSha(): Promise<string> {
* 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<ComarkContent> {
export async function getProdContent(): Promise<DocsContent> {
if (['production', 'preview'].includes(process.env.VERCEL_ENV || '')) {
const sha = await resolveProdSha()
if (sha !== getHeadRef()) {
Expand Down Expand Up @@ -150,14 +160,14 @@ function contentSource(ref: string, opts: { remote?: boolean } = {}) {
}

/** Per-instance registry of preview CMS instances, keyed by `<basePath>::<sha>`. */
const contentPreviewInstances = new Map<string, Promise<ComarkContent>>()
const contentPreviewInstances = new Map<string, Promise<DocsContent>>()

// 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<ComarkContent> {
export function getPreviewContent(sha: string, basePath: string): Promise<DocsContent> {
const key = `${basePath}::${sha}`
const existing = contentPreviewInstances.get(key)
if (existing) {
Expand Down
4 changes: 2 additions & 2 deletions server/utils/markdown.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
export async function renderPageMarkdown(content: DocsContent, path: string): Promise<string | null> {
const item = await content.get(path)
if (!item || item.meta.kind !== 'document') return null

Expand Down
12 changes: 6 additions & 6 deletions server/utils/search.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ComarkContent } from 'comark-content'
import type { DocsContent } from './content'

interface SearchSection {
id: string
Expand All @@ -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<SearchSection[]> {
export async function searchDocSections(content: DocsContent, query: string, limit = 10): Promise<SearchSection[]> {
const sections = await buildSearchSections(content)
const terms = query.toLowerCase().split(/\s+/).filter(Boolean)

Expand All @@ -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<SearchSection[]> {
export async function buildSearchSections(content: DocsContent): Promise<SearchSection[]> {
const cached = await content.cache.get<SearchSection[]>(SEARCH_SECTIONS_KEY)
if (cached) return cached

Expand All @@ -60,8 +60,8 @@ export async function buildSearchSections(content: ComarkContent): Promise<Searc
return sections
}

async function collectSearchSections(content: ComarkContent): Promise<SearchSection[]> {
const docs = await content.list(['content'])
async function collectSearchSections(content: DocsContent): Promise<SearchSection[]> {
const docs = await content.list()
const sections: SearchSection[] = []

// Parsed up front rather than one await per iteration; the walk below is order-dependent.
Expand Down
19 changes: 12 additions & 7 deletions test/content-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

/** `<source>:<path-in-source>` — the cache key comark-content writes a parsed body under. */
const bodyKey = 'content:index.md'
/**
* `<name>:<path-in-source>` — 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() },
})
}
Expand All @@ -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 () => {
Expand Down Expand Up @@ -76,7 +81,7 @@ describe('comark-content contract', () => {
}))

const content = comarkContent({
sources: { content: fsSource(fixture) },
source: fsSource(fixture),
cache: { driver: memoryDriver() },
plugins: [ping()],
})
Expand Down
Loading