diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 836b76e..c8164f0 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -10,6 +10,7 @@ import apiRoutes from '../api/index.json'; import m3u8Grammar from './m3u8-grammar.json' with { type: 'json' }; import fs from 'node:fs/promises'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; const DESCRIPTION = 'A JavaScript library for reading, writing, and converting media files. Directly in the browser,' + ' and faster than anybunny else.'; @@ -27,8 +28,14 @@ export default withMermaid({ for (const entry of entries) { const isDirectory = await fs.stat(path.join('./examples', entry)).then(stat => stat.isDirectory()); if (isDirectory) { + const gitDate = execFileSync( + 'git', + ['log', '-1', '--format=%cI', '--', path.join('./examples', entry)], + ).toString().trim(); + items.push({ url: `/examples/${entry}/`, // With trailing slash + lastmod: new Date(gitDate).toISOString(), }); } } @@ -36,7 +43,7 @@ export default withMermaid({ return items; }, }, - // lastUpdated: true, + lastUpdated: true, head: [ ['link', { rel: 'icon', type: 'image/png', href: '/mediabunny-logo.png' }], ['link', { rel: 'icon', type: 'image/svg+xml', href: '/mediabunny-logo.svg' }], @@ -201,7 +208,7 @@ export default withMermaid({ tailwindcss() as any, llmstxt({ ignoreFiles: [ - 'api/*', + 'api/!(index).md', 'examples.md', 'llms.md', ], @@ -305,5 +312,10 @@ export default withMermaid({ for (const file of files) { await fs.copyFile('./docs/api/' + file, './dist-docs/api/' + file); } + + // The llms.txt generation leaves behind runs of empty lines, collapse them into one + const llmsTxtPath = './dist-docs/llms.txt'; + const llmsTxt = await fs.readFile(llmsTxtPath, 'utf-8'); + await fs.writeFile(llmsTxtPath, llmsTxt.replace(/\n{3,}/g, '\n\n')); }, }); diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts index 67a127a..68b20ba 100644 --- a/scripts/generate-api-docs.ts +++ b/scripts/generate-api-docs.ts @@ -15,6 +15,7 @@ import * as ts from 'typescript'; import * as fs from 'fs'; import * as path from 'path'; +import { execFileSync } from 'child_process'; const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) => { const program = ts.createProgram(entryFiles, { @@ -629,6 +630,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) // Data structures for "Used by" feature const usedByReferences = new Map>(); const generatedDocs = new Map(); + const symbolSourceFiles = new Map(); const addUsage = ( used: string, @@ -678,6 +680,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) } } + // Remember where the symbol is actually defined (reexports don't count), for lastModified dates + symbolSourceFiles.set(symbolName, targetDeclaration.getSourceFile().fileName); + const groupTag = ts.getJSDocTags(targetDeclaration).find(tag => tag.tagName.text === 'group'); if (!groupTag || typeof groupTag.comment !== 'string') { throw new Error(`Symbol '${symbolName}' is missing @group JSDoc tag`); @@ -1750,6 +1755,8 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) }); // Phase 3: Assemble final docs with "Used by" sections and write files + let newestLastModified: string | null = null; + generatedDocs.forEach((markdown, symbolName) => { const usages = usedByReferences.get(symbolName); let usedByMarkdown = ''; @@ -1821,8 +1828,16 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) } } - const finalMarkdown = markdown.replace('', usedByMarkdown); if (!dry) { + const lastModified = getLastModifiedDate(symbolSourceFiles.get(symbolName)!); + if (!newestLastModified || new Date(lastModified) > new Date(newestLastModified)) { + newestLastModified = lastModified; + } + + const finalMarkdown = addLastUpdatedToFrontmatter( + markdown.replace('', usedByMarkdown), + lastModified, + ); const outputPath = path.join(outputDir, `${symbolName}.md`); fs.writeFileSync(outputPath, finalMarkdown); console.log(`Generated: ${outputPath}`); @@ -1874,7 +1889,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) if (!dry) { const indexPath = path.join(outputDir, 'index.md'); - fs.writeFileSync(indexPath, indexMarkdown); + fs.writeFileSync(indexPath, addLastUpdatedToFrontmatter(indexMarkdown, newestLastModified!)); console.log(`Generated: ${indexPath}`); } @@ -1901,6 +1916,30 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) } }; +// Last-commit date of a source file, so generated pages can carry an honest lastModified date. +// Falls back to the filesystem mtime for files git doesn't know about yet. +const lastModifiedCache = new Map(); +const getLastModifiedDate = (filePath: string) => { + let date = lastModifiedCache.get(filePath); + if (date === undefined) { + const gitDate = execFileSync('git', ['log', '-1', '--format=%cI', '--', filePath]).toString().trim(); + date = gitDate + ? new Date(gitDate).toISOString() + : fs.statSync(filePath).mtime.toISOString(); + lastModifiedCache.set(filePath, date); + } + return date; +}; + +// Unquoted ISO value so YAML parses it as a date -- VitePress only honors the lastUpdated +// frontmatter key (for both the page footer and the sitemap) when it's an actual Date +const addLastUpdatedToFrontmatter = (markdown: string, lastModified: string) => { + if (markdown.startsWith('---\n')) { + return markdown.replace('---\n', `---\nlastUpdated: ${lastModified}\n`); + } + return `---\nlastUpdated: ${lastModified}\n---\n\n${markdown}`; +}; + // Shared helper for extracting a JSDoc description, handling inline tags via raw-source fallback. // - `tagHandling: 'stopAtFirst'` matches the behavior used by property-level descriptions: if any // @-tag line is encountered, all subsequent lines are dropped (including any trailing description).