import { parseFrontmatter } from './frontmatter.ts' import type { ContentSource, ContentPage, NavNode } from './types.ts' import type { ContentCache } from './cache.ts' import { MemoryContentCache } from './cache.ts' import { buildNavTree } from './nav-builder.ts' /** Parsed R2 file ready for use */ interface ParsedFile { /** Relative path from content root, e.g. 'docs/getting-started.md' */ path: string meta: Record body: string raw: string } export interface R2ContentSourceConfig { /** R2 bucket binding */ bucket: R2Bucket /** Key prefix for content (e.g. 'sites/abc123/') */ basePath?: string /** Optional cache layer (defaults to in-memory). Pass KVContentCache for durable caching. */ cache?: ContentCache } // ─── R2 type stubs (not available outside Cloudflare Workers) ──────────────── interface R2Bucket { get: (key: string) => Promise list: (options?: R2ListOptions) => Promise } interface R2Object { key: string uploaded: Date size: number text: () => Promise } interface R2ObjectList { objects: R2Object[] truncated: boolean cursor?: string } interface R2ListOptions { prefix?: string cursor?: string limit?: number } // ─── Helpers ───────────────────────────────────────────────────────────────── async function listAllMdFiles (bucket: R2Bucket, prefix: string): Promise { const objects: R2Object[] = [] let cursor: string | undefined do { const result = await bucket.list({ prefix, cursor }) objects.push(...result.objects.filter(o => o.key.endsWith('.md'))) cursor = result.truncated ? result.cursor : undefined } while (cursor != null) return objects } /** Convert a URL slug to a relative content key (no basePath prefix) */ function slugToRelKey (slug: string): string { const stripped = slug.replace(/^\/+|\.md$/g, '').replace(/\/+$/, '') return stripped === '' ? 'index' : stripped } function keyToRelPath (key: string, basePath: string): string { return basePath !== '' && key.startsWith(basePath) ? key.slice(basePath.length) : key } // ─── R2ContentSource ───────────────────────────────────────────────────────── /** * Content source that reads .md files from a Cloudflare R2 bucket. * * Lazy prefetch: on first access, lists all .md keys, fetches them in parallel, * parses frontmatter, and caches for TTL_MS (5 minutes). */ export class R2ContentSource implements ContentSource { private readonly bucket: R2Bucket private readonly basePath: string private readonly cache: ContentCache private rawCache: Map | null = null private rawCacheExpiresAt: number = 0 private initPromise: Promise> | null = null constructor (config: R2ContentSourceConfig) { this.bucket = config.bucket this.cache = config.cache ?? new MemoryContentCache() // Normalise basePath to end with '/' or be empty const raw = config.basePath ?? '' this.basePath = raw !== '' && !raw.endsWith('/') ? raw + '/' : raw } // ─── Public API ──────────────────────────────────────────────────────────── async getPage (slug: string): Promise { const key = slugToRelKey(slug) // Try cache first for each candidate for (const candidate of [key, key + '/index', key + '/README', key + '/readme']) { const cached = await this.cache.getPage(candidate) if (cached != null) return cached } // Fall through to prefetch const pages = await this.ensurePrefetched() for (const candidate of [key, key + '/index', key + '/README', key + '/readme']) { const file = pages.get(candidate) if (file != null && file.meta.draft !== true) { const page = this.toContentPage(file) await this.cache.setPage(candidate, page) return page } } return null } async getNavTree (): Promise { const cached = await this.cache.getNav() if (cached != null) return cached const pages = await this.ensurePrefetched() const files = Array.from(pages.values()) const nav = buildNavTree(files) await this.cache.setNav(nav) return nav } async listPages (): Promise { const cached = await this.cache.getPageList() if (cached != null) return cached const pages = await this.ensurePrefetched() const result = Array.from(pages.values()) .filter(f => f.meta.draft !== true) .map(f => this.toContentPage(f)) await this.cache.setPageList(result) return result } async refresh (): Promise { this.rawCache = null this.rawCacheExpiresAt = 0 this.initPromise = null await this.cache.clear() } // ─── Internal ────────────────────────────────────────────────────────────── private async ensurePrefetched (): Promise> { if (this.rawCache != null && Date.now() < this.rawCacheExpiresAt) { return this.rawCache } if (this.initPromise != null) return await this.initPromise this.initPromise = this.prefetchAll() const result = await this.initPromise this.initPromise = null return result } private async prefetchAll (): Promise> { const objects = await listAllMdFiles(this.bucket, this.basePath) const fetched = await Promise.all( objects.map(async (obj) => { try { const r2obj = await this.bucket.get(obj.key) if (r2obj == null) return null const raw = await r2obj.text() const { meta, body } = parseFrontmatter(raw) const relPath = keyToRelPath(obj.key, this.basePath) return { path: relPath, meta: meta as Record, body, raw } satisfies ParsedFile } catch { return null } }) ) const pages = new Map() for (const file of fetched) { if (file == null) continue const key = file.path.replace(/\.md$/, '') pages.set(key, file) } this.rawCache = pages this.rawCacheExpiresAt = Date.now() + 5 * 60 * 1000 return pages } private toContentPage (file: ParsedFile): ContentPage { const key = file.path.replace(/\.md$/, '') const isIndex = /(?:^|\/)(?:index|README|readme)$/.test(key) const cleanKey = isIndex ? key.replace(/\/(?:index|README|readme)$/, '') : key const slug = cleanKey === '' || cleanKey === 'index' ? '/' : '/' + cleanKey return { slug, sourcePath: file.path, meta: file.meta, body: file.body, raw: file.raw } } }