import { readdir, readFile, stat } from 'node:fs/promises' import { join, relative, extname, basename, dirname } from 'node:path' import picomatch from 'picomatch' import { parseFrontmatter } from './frontmatter.ts' import type { ContentSource, ContentPage, NavNode } from './types.ts' export interface FilesystemSourceOptions { /** Glob patterns to include. Only matching files will be served. Mutually exclusive with exclude. */ include?: string[] /** Glob patterns to exclude. Matching files will not be served. Mutually exclusive with include. */ exclude?: string[] } export class FilesystemSource implements ContentSource { private readonly rootDir: string private readonly cache = new Map() private navCache: NavNode | null = null private allPagesCache: ContentPage[] | null = null private readonly includeMatcher: picomatch.Matcher | null private readonly excludeMatcher: picomatch.Matcher | null constructor (rootDir: string, opts: FilesystemSourceOptions = {}) { this.rootDir = rootDir this.includeMatcher = opts.include != null && opts.include.length > 0 ? picomatch(opts.include) : null this.excludeMatcher = opts.exclude != null && opts.exclude.length > 0 ? picomatch(opts.exclude) : null } /** Returns true if a relative path should be served, based on include/exclude patterns. */ private shouldInclude (relPath: string): boolean { // Normalise to forward slashes for consistent glob matching const p = relPath.replace(/\\/g, '/') if (this.includeMatcher != null) return this.includeMatcher(p) if (this.excludeMatcher != null) return !this.excludeMatcher(p) return true } async getPage (slug: string): Promise { const stripped = slug.replace(/^\/+|\/+$/g, '') const normalized = stripped !== '' ? stripped : 'index' if (this.cache.has(normalized)) { return this.cache.get(normalized) ?? null } const candidates = [ join(this.rootDir, `${normalized}.md`), join(this.rootDir, normalized, 'index.md'), join(this.rootDir, normalized, 'README.md'), join(this.rootDir, normalized, 'readme.md') ] if (normalized === 'index') { candidates.unshift( join(this.rootDir, 'index.md'), join(this.rootDir, 'README.md'), join(this.rootDir, 'readme.md') ) } for (const filePath of candidates) { try { const raw = await readFile(filePath, 'utf-8') const parsed = parseFrontmatter(raw) const fileStat = await stat(filePath) // Check include/exclude patterns against relative path const relPath = relative(this.rootDir, filePath) if (!this.shouldInclude(relPath)) return null const page: ContentPage = { slug: `/${normalized === 'index' ? '' : normalized}`, sourcePath: filePath, meta: parsed.meta, body: parsed.body, raw: parsed.raw, modifiedAt: fileStat.mtime } this.cache.set(normalized, page) return page } catch { continue } } return null } async getNavTree (): Promise { if (this.navCache != null) return this.navCache this.navCache = await this.buildNavTree(this.rootDir, '/') return this.navCache } async listPages (): Promise { if (this.allPagesCache != null) return this.allPagesCache const pages: ContentPage[] = [] await this.walkDir(this.rootDir, pages) pages.sort((a, b) => a.slug.localeCompare(b.slug)) this.allPagesCache = pages return pages } async refresh (): Promise { this.cache.clear() this.navCache = null this.allPagesCache = null } private async walkDir (dir: string, pages: ContentPage[]): Promise { let entries try { entries = await readdir(dir, { withFileTypes: true }) } catch { return } for (const entry of entries) { const fullPath = join(dir, entry.name) if (entry.isDirectory()) { if (entry.name.startsWith('.') || entry.name === 'node_modules') { continue } await this.walkDir(fullPath, pages) } else if (entry.isFile() && extname(entry.name) === '.md') { const relPath = relative(this.rootDir, fullPath) if (!this.shouldInclude(relPath)) continue const slug = this.filePathToSlug(relPath) try { const raw = await readFile(fullPath, 'utf-8') const parsed = parseFrontmatter(raw) const fileStat = await stat(fullPath) if (parsed.meta.draft === true) continue pages.push({ slug, sourcePath: fullPath, meta: parsed.meta, body: parsed.body, raw: parsed.raw, modifiedAt: fileStat.mtime }) } catch { continue } } } } private async dirHasIndex (dir: string): Promise { for (const name of ['index.md', 'README.md', 'readme.md']) { try { await stat(join(dir, name)) return true } catch { continue } } return false } private filePathToSlug (relPath: string): string { let slug = relPath.replace(/\.md$/, '') const base = basename(slug) if (base === 'index' || base === 'README' || base === 'readme') { slug = dirname(slug) if (slug === '.') slug = '' } slug = slug.replace(/\\/g, '/') return `/${slug}` } private async buildNavTree (dir: string, slugPrefix: string): Promise { const entries = await readdir(dir, { withFileTypes: true }) const children: NavNode[] = [] let dirTitle = basename(dir) let dirOrder = 0 for (const name of ['index.md', 'README.md', 'readme.md']) { try { const raw = await readFile(join(dir, name), 'utf-8') const parsed = parseFrontmatter(raw) if (parsed.meta.title != null) dirTitle = String(parsed.meta.title) if (parsed.meta.order != null) dirOrder = parsed.meta.order break } catch { continue } } for (const entry of entries) { if (entry.name.startsWith('.') || entry.name === 'node_modules') continue const fullPath = join(dir, entry.name) const childSlug = `${slugPrefix}${slugPrefix.endsWith('/') ? '' : '/'}${entry.name}` if (entry.isDirectory()) { const subtree = await this.buildNavTree(fullPath, childSlug) // Only include directories that have navigable content: // either at least one child page/section, or their own index.md if (subtree.children.length > 0 || await this.dirHasIndex(fullPath)) { children.push(subtree) } } else if ( entry.isFile() && extname(entry.name) === '.md' && entry.name !== 'index.md' && entry.name !== 'README.md' && entry.name !== 'readme.md' ) { const relPath = relative(this.rootDir, fullPath) if (!this.shouldInclude(relPath)) continue const raw = await readFile(fullPath, 'utf-8') const parsed = parseFrontmatter(raw) if (parsed.meta.draft === true) continue const name = basename(entry.name, '.md') const slug = `${slugPrefix}${slugPrefix.endsWith('/') ? '' : '/'}${name}` children.push({ title: parsed.meta.title != null ? String(parsed.meta.title) : titleCase(name), slug, order: parsed.meta.order ?? 999, children: [], isSection: false }) } } children.sort((a, b) => (a.order !== 0 || b.order !== 0) ? a.order - b.order : a.title.localeCompare(b.title)) return { title: titleCase(dirTitle), slug: slugPrefix, order: dirOrder, children, isSection: true } } } function titleCase (str: string): string { return str .replace(/[-_]/g, ' ') .replace(/\b\w/g, c => c.toUpperCase()) }