Skip to content

Commit a593fbd

Browse files
committed
improvements
1 parent 321bb5e commit a593fbd

11 files changed

Lines changed: 478 additions & 295 deletions

File tree

modules/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export interface ComarkDocsOptions {
2222
* push webhook all resolve to a path that doesn't exist. Also settable as `NUXT_DOCS_CONTENT_DIR`.
2323
*/
2424
contentDir?: string
25+
/**
26+
* Frontmatter fields kept in the manifest (visible to `list()` and `navigation()`) and the push webhook's nav-changed diff.
27+
* @default ['title', 'description', 'navigation', 'icon', 'layout']
28+
*/
29+
listingFields?: string[]
2530
codeExplorer?: {
2631
/** GitHub repos (`owner/name`) `/api/code-explorer` may read. Defaults to the content repo only. */
2732
allowRepos?: string[]
@@ -119,6 +124,7 @@ export default defineNuxtModule<ComarkDocsOptions>({
119124
contentDir,
120125
contentPath,
121126
repoRoot,
127+
listingFields: options.listingFields || ['title', 'description', 'navigation', 'icon', 'layout'],
122128
codeExplorer: {
123129
allowRepos: options.codeExplorer?.allowRepos || [],
124130
},

server/api/revalidate.post.ts

Lines changed: 179 additions & 144 deletions
Large diffs are not rendered by default.

server/utils/cache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function cacheAvailable(): boolean {
1717
* Bump when content parser/plugin configuration, relevant parser dependencies, or cached derived
1818
* data changes. Keeping this explicit lets unrelated deployments reuse immutable content artifacts.
1919
*/
20-
export const CONTENT_PARSER_VERSION = 'v2'
20+
export const CONTENT_PARSER_VERSION = 'v3'
2121

2222
/** Per-parser-version, per-content-SHA driver backing comark's manifest and parsed bodies. */
2323
export function cacheDriver(sha: string): Driver {

server/utils/content.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import security from 'comark/plugins/security'
66
import emoji from 'comark/plugins/emoji'
77
import toc from 'comark/plugins/toc'
88
import mermaid from 'comark/plugins/mermaid'
9+
import markdown from 'comark-content/plugins/markdown'
910
import yaml from 'comark-content/plugins/yaml'
1011
import tracingOtel from 'comark-content/plugins/tracing/otel'
1112
import { contentTracer } from './tracer.ts'
1213
import { geistTheme } from '../../utils/geist-theme.ts'
1314

15+
const DEFAULT_LISTING_FIELDS = ['title', 'description', 'navigation', 'icon', 'layout']
16+
1417
// Rebuilt only when the head advances (see `getProdContent`). Holds the *promise*, not the instance: the
1518
// assignment lands after the await, so two requests on a cold instance would each build a CMS.
1619
let content: Promise<ComarkContent> | undefined
@@ -28,26 +31,28 @@ const comarkPlugins = [
2831
]
2932

3033
/**
31-
* Create a new content instance reading content at `ref` (a commit SHA or branch). `remote` forces the
32-
* GitHub source, `cache` overrides comark's (in-memory by default), `watch` is dev file watching.
34+
* Create a new content instance reading content at `ref` (a commit SHA or branch).
35+
* - `remote` forces the GitHub source
36+
* - `cache` overrides comark's (in-memory by default)
37+
* - `basePath` is the base path for the content instance
38+
* - `watch` is dev file watching
3339
*/
3440
export async function createSourceContent(
3541
ref: string,
3642
opts: { remote?: boolean; cache?: CacheOptions; basePath?: string; watch?: boolean } = {}
3743
) {
38-
// A no-op unless the consumer shadows it from their own `server/utils/`. Re-typed as the layer's own
39-
// options: a consumer's hook is declared against the wide `ContentOptions`, and letting that widen the
40-
// argument would erase the source and plugin types `comarkContent` infers from the literal.
4144
const tracer = contentTracer()
45+
const listingFields = useRuntimeConfig().docs.listingFields ?? DEFAULT_LISTING_FIELDS
4246
const instance = comarkContent({
43-
markdown: {
44-
plugins: comarkPlugins,
45-
},
4647
sources: {
4748
content: contentSource(ref, { remote: opts.remote }),
4849
},
4950
plugins: [
50-
yaml(), // enable .navigation.yml to be detected
51+
markdown({
52+
comark: { plugins: comarkPlugins },
53+
listingFields,
54+
}),
55+
yaml({ listingFields }),
5156
tracer && tracingOtel({ tracer }),
5257
],
5358
cache: opts.cache,
@@ -69,12 +74,17 @@ export async function createSourceContent(
6974
}
7075

7176
/**
72-
* Fully parse and persist the snapshot artifact into this instance's per-SHA cache.
77+
* Fully parse and persist every source's snapshot artifact into this instance's per-SHA cache, so
78+
* the next reader (a fresh instance sharing the same cache namespace) pays a cache read instead of
79+
* re-parsing from GitHub. `snapshot()` defaults to `fresh: true` — it re-parses and persists.
7380
*/
74-
export async function warmSnapshot(content: ComarkContent): Promise<void> {
81+
export async function warmArtifacts(content: ComarkContent): Promise<void> {
7582
await content.init({ partial: false })
76-
const artifact = await content.cache.snapshot('content')
77-
console.log(`[content] snapshot artifact ${artifact ? `${artifact.size} bytes` : 'not produced'}`)
83+
for (const source of content.manifest.sources) {
84+
const artifact = await content.cache.snapshot(source)
85+
if (artifact) console.log(`[content] snapshot artifact "${source}" ${artifact.size} bytes`)
86+
else console.warn(`[content] no snapshot artifact produced for source "${source}"`)
87+
}
7888
}
7989

8090

server/utils/github.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface GitHubPushPayload {
1313
before?: string
1414
commits?: GitHubCommit[]
1515
head_commit?: GitHubCommit & { id?: string }
16+
repository?: { full_name?: string }
1617
}
1718

1819
/** Constant-time string comparison. */

server/utils/paths.ts

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,54 +2,3 @@
22
export function contentPrefix(): string {
33
return `${useRuntimeConfig().docs.contentDir.replace(/\/$/, '')}/`
44
}
5-
6-
/** Whether a GitHub repo path is a content markdown file. */
7-
export function isContentMd(path: string): boolean {
8-
return path.startsWith(contentPrefix()) && path.toLowerCase().endsWith('.md')
9-
}
10-
11-
/** Whether a GitHub repo path is a navigation config file (`.navigation.yml` / `.json`). */
12-
export function isNavConfig(path: string): boolean {
13-
return path.startsWith(contentPrefix()) && /\.navigation\.(?:ya?ml|json)$/i.test(path)
14-
}
15-
16-
/**
17-
* Parse a content repo path into route segments (`1.getting-started/2.intro.md` →
18-
* `['getting-started', 'intro']`). `isIndex` covers both `index.md` and `index/index.md`.
19-
*/
20-
export function slugFromPath(path: string): { isIndex: boolean; segments: string[] } | null {
21-
const prefix = contentPrefix()
22-
if (!path.startsWith(prefix) || !path.toLowerCase().endsWith('.md')) return null
23-
24-
const relative = path.slice(prefix.length, -3)
25-
const segments = relative.split('/').map((s) => s.replace(/^\d+\./, ''))
26-
const last = segments[segments.length - 1]
27-
const isIndex = last === 'index'
28-
if (isIndex) segments.pop()
29-
return { isIndex, segments }
30-
}
31-
32-
/** Frontend page route (e.g. `1.getting-started/2.intro.md` → `/getting-started/intro`, root → `/`). */
33-
export function pageUrlForPath(path: string): string | null {
34-
const result = slugFromPath(path)
35-
if (!result) return null
36-
const { isIndex, segments } = result
37-
if (isIndex && segments.length === 0) return '/'
38-
return `/${segments.join('/')}`
39-
}
40-
41-
/** Raw markdown route — the only per-file route that stays cached, as `/api/pages` is served live. */
42-
export function rawUrlForPath(path: string): string | null {
43-
const result = slugFromPath(path)
44-
if (!result) return null
45-
46-
const { isIndex, segments } = result
47-
if (isIndex && segments.length === 0) return '/raw/index.md'
48-
return `/raw/${segments.join('/')}.md`
49-
}
50-
51-
/** Nuxt payload route for a frontend page route */
52-
export function payloadUrlForRoute(route: string, buildId?: string): string {
53-
const path = `${route === '/' ? '' : route}/_payload.json`
54-
return buildId ? `${path}?${buildId}` : path
55-
}

server/utils/timing.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/** Named phase timings for one revalidate webhook run. */
2+
export interface Timings {
3+
/** Time a sync or async `fn` under `label`; records its duration and returns its result. */
4+
time<T>(label: string, fn: () => T | Promise<T>): Promise<T>
5+
/** `label=123ms label2=45ms`, in recorded order — for one log line. */
6+
format(): string
7+
/** ms since this recorder was created — spans the sync response and the background `waitUntil` phase. */
8+
since(): number
9+
}
10+
11+
export function createTimings(): Timings {
12+
const start = performance.now()
13+
const entries: { label: string; ms: number }[] = []
14+
15+
async function time<T>(label: string, fn: () => T | Promise<T>): Promise<T> {
16+
const phaseStart = performance.now()
17+
try {
18+
return await fn()
19+
} finally {
20+
entries.push({ label, ms: Math.round(performance.now() - phaseStart) })
21+
}
22+
}
23+
24+
function format(): string {
25+
return entries.map(({ label, ms }) => `${label}=${ms}ms`).join(' ')
26+
}
27+
28+
return { time, format, since: () => Math.round(performance.now() - start) }
29+
}

server/utils/webhook.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { ContentListFile } from 'comark-content'
2+
import type { GitHubCommit } from './github'
3+
import { hashManifestItem } from './json'
4+
5+
/** How a push changed the content source, already filtered to `contentDir`. */
6+
export interface ContentChanges {
7+
/** Manifest keys (`content/<stem><ext>`) of files added or modified. */
8+
upserted: string[]
9+
/** Manifest keys of files removed — only the previous manifest can resolve their paths. */
10+
removed: string[]
11+
/** A `.navigation.*` file changed, so the tree changed regardless of which pages did. */
12+
navTouched: boolean
13+
}
14+
15+
/** Files the content source can actually serve — matches the parsers installed in `content.ts`. */
16+
const CONTENT_EXTENSIONS = ['.md', '.yml', '.yaml', '.json']
17+
18+
/** The manifest's hardcoded source name (see `contentSource()` in `content.ts`). */
19+
const SOURCE_NAME = 'content'
20+
21+
/**
22+
* A push's changed content files, named by their manifest key (`content/<stem><ext>`) — the
23+
* reverse of `meta.key`, so a diff against `manifest.items` doesn't need to re-derive file → URL
24+
* mappings that comark already owns.
25+
*/
26+
export function changesForPush(contentDir: string, commits: GitHubCommit[]): ContentChanges {
27+
const upserted = new Set<string>()
28+
const removed = new Set<string>()
29+
let navTouched = false
30+
31+
const consider = (file: string, into: Set<string>) => {
32+
const key = manifestKeyFor(file, contentDir)
33+
if (!key) return
34+
35+
if (isNavConfigFile(file)) navTouched = true
36+
else into.add(key)
37+
}
38+
39+
for (const commit of commits) {
40+
for (const file of commit.added ?? []) consider(file, upserted)
41+
for (const file of commit.modified ?? []) consider(file, upserted)
42+
for (const file of commit.removed ?? []) consider(file, removed)
43+
}
44+
45+
// A path removed and re-added in the same push is an upsert, not a removal.
46+
for (const key of upserted) removed.delete(key)
47+
48+
return { upserted: [...upserted], removed: [...removed], navTouched }
49+
}
50+
51+
/** Repo-relative path → its key in the manifest, or `null` when it can't be a content file. */
52+
function manifestKeyFor(file: string, contentDir: string): string | null {
53+
const dir = contentDir.replace(/^\/+|\/+$/g, '')
54+
const prefix = dir ? `${dir}/` : ''
55+
56+
if (prefix && !file.startsWith(prefix)) return null
57+
if (!CONTENT_EXTENSIONS.some((ext) => file.toLowerCase().endsWith(ext))) return null
58+
59+
return `${SOURCE_NAME}/${file.slice(prefix.length)}`
60+
}
61+
62+
/** Directory configuration (`.navigation.yml`), which contributes to the tree rather than a page. */
63+
function isNavConfigFile(file: string): boolean {
64+
return /\.navigation\.(?:ya?ml|json)$/i.test(file)
65+
}
66+
67+
/**
68+
* The payload URL a client-side navigation fetches for `path`
69+
*/
70+
export function payloadUrlForPage(path: string, buildId?: string): string {
71+
const base = path === '/' ? '/_payload.json' : `${path.replace(/\/$/, '')}/_payload.json`
72+
return buildId ? `${base}?_b=${buildId}` : base
73+
}
74+
75+
/** `content/<stem><ext>` (a manifest key) → page path, the reverse of what the path-keyed manifest gives. */
76+
export function indexByFileKey(items: Record<string, ContentListFile>): Map<string, string> {
77+
const index = new Map<string, string>()
78+
for (const item of Object.values(items)) index.set(item.meta.key, item.path)
79+
return index
80+
}
81+
82+
/**
83+
* Which pages a push changed, and whether the tree itself moved.
84+
*/
85+
export function diffContent(
86+
changes: ContentChanges,
87+
before: Record<string, ContentListFile>,
88+
after: Record<string, ContentListFile>
89+
): { pagePaths: string[]; navChanged: boolean } {
90+
const pagePaths = new Set<string>()
91+
92+
const afterByKey = indexByFileKey(after)
93+
const beforeByKey = indexByFileKey(before)
94+
95+
for (const key of changes.upserted) {
96+
const path = afterByKey.get(key)
97+
if (path) pagePaths.add(path)
98+
}
99+
for (const key of changes.removed) {
100+
const path = beforeByKey.get(key)
101+
if (path) pagePaths.add(path)
102+
}
103+
104+
const beforeKeys = Object.keys(before)
105+
const afterKeys = Object.keys(after)
106+
const navChanged =
107+
beforeKeys.length !== afterKeys.length ||
108+
afterKeys.some((key) => !before[key]) ||
109+
// Listing fields (title, description, icon, `navigation`…) are what the tree renders from.
110+
afterKeys.some((key) => before[key] && !sameListing(before[key]!, after[key]!))
111+
112+
return { pagePaths: [...pagePaths], navChanged }
113+
}
114+
115+
function sameListing(a: ContentListFile, b: ContentListFile): boolean {
116+
return a.path === b.path && hashManifestItem(a) === hashManifestItem(b)
117+
}

test/content-contract.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ describe('comark-content contract', () => {
8181
const content = createFixtureContent()
8282
await content.init(full)
8383

84-
// `warmSnapshot` logs `artifact.size`, so a shape change there degrades to "not produced".
84+
// `warmArtifacts` logs `artifact.size`, so a shape change there degrades to "not produced".
8585
const artifact = await content.cache.snapshot('content')
8686
expect(artifact).not.toBeNull()
8787
expect(artifact!.size).toBeGreaterThan(0)

0 commit comments

Comments
 (0)