diff --git a/.changeset/example-catalog-git-dates.md b/.changeset/example-catalog-git-dates.md new file mode 100644 index 0000000..f84ad65 --- /dev/null +++ b/.changeset/example-catalog-git-dates.md @@ -0,0 +1,5 @@ +--- +'@layerstack/docs': patch +--- + +feat(generate-catalog): Add per-example `createdAt` / `updatedAt` from git history diff --git a/packages/docs/src/lib/catalog.ts b/packages/docs/src/lib/catalog.ts index 03f68d2..e7fca9b 100644 --- a/packages/docs/src/lib/catalog.ts +++ b/packages/docs/src/lib/catalog.ts @@ -33,6 +33,10 @@ export interface ExampleInfo { path: string; /** All components used in this example */ components: ComponentUsageInExample[]; + /** ISO timestamp of the commit that first added the example file (following renames) */ + createdAt?: string; + /** ISO timestamp of the last commit that changed the example file */ + updatedAt?: string; } /** @@ -61,6 +65,6 @@ export interface ComponentCatalog { examples: ExampleInfo[]; /** All places where this component is used across all examples */ usage: UsageInfo[]; - /** ISO timestamp when the catalog was last updated */ + /** ISO timestamp when the catalog content last changed (example dates are not counted as content) */ updatedAt: string; } diff --git a/packages/docs/src/lib/node/example-catalog.js b/packages/docs/src/lib/node/example-catalog.js index 573075a..458eb51 100644 --- a/packages/docs/src/lib/node/example-catalog.js +++ b/packages/docs/src/lib/node/example-catalog.js @@ -19,6 +19,7 @@ * // docs/src/examples/catalog/Rect.json * // docs/src/examples/catalog/Area.json */ +import { execFileSync } from 'child_process'; import fs from 'fs'; import path from 'path'; let COMPONENTS_DIR = ''; @@ -99,10 +100,109 @@ function extractModuleExports(filePath) { } return result; } +/** + * Read created/updated dates for every example file from git history. + * + * Runs a single `git log` over the examples directory (newest first) and maps renames + * back to the file's current path. Returns `Map` + * keyed by `/.svelte`, or `null` when git is unavailable or the + * repository is a shallow clone (where history is truncated and dates would be wrong). + */ +export function getExampleGitDates(examplesDir) { + const git = (args) => + execFileSync('git', args, { + cwd: examplesDir, + encoding: 'utf-8', + maxBuffer: 256 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'], + }); + + let output; + try { + if (git(['rev-parse', '--is-shallow-repository']).trim() !== 'false') { + return null; + } + // `-z` avoids path quoting, `--relative` makes paths relative to `examplesDir`, + // and `--date-order` guarantees children are listed before their parents + output = git([ + 'log', + '-z', + '--date-order', + '--format=%x01%ct', + '--name-status', + '-M', + '--relative', + '--', + '.', + ]); + } catch (error) { + return null; + } + + /** @type {Map} */ + const timestamps = new Map(); + // Historical path -> current path (`null` once that path's lineage has ended) + const aliases = new Map(); + const resolve = (p) => (aliases.has(p) ? aliases.get(p) : p); + const record = (p, time) => { + if (p == null) return; + const entry = timestamps.get(p); + if (entry) { + entry.created = Math.min(entry.created, time); + entry.updated = Math.max(entry.updated, time); + } else { + timestamps.set(p, { created: time, updated: time }); + } + }; + + const tokens = output.split('\0'); + let time = 0; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i].replace(/^\n+/, ''); + if (!token) continue; + if (token.startsWith('\x01')) { + time = Number(token.slice(1)) * 1000; + continue; + } + const status = token[0]; + if (status === 'R' || status === 'C') { + const from = tokens[++i]; + const to = tokens[++i]; + const current = resolve(to); + record(current, time); + // Older history at `to` belongs to a different file + aliases.set(to, null); + if (status === 'R') { + aliases.set(from, current); + } + } else { + const file = tokens[++i]; + record(resolve(file), time); + if (status === 'A' || status === 'D') { + // Anything older at this path was a previous file that has since been removed + aliases.set(file, null); + } + } + } + + const dates = new Map(); + for (const [file, { created, updated }] of timestamps) { + dates.set(file, { + createdAt: new Date(created).toISOString(), + updatedAt: new Date(updated).toISOString(), + }); + } + return dates; +} + /** * Get all examples for a specific component + * + * @param gitDates - result of `getExampleGitDates()`, or `null`/`undefined` if unavailable + * @param existingExamples - examples from the previously generated catalog, used to keep + * existing dates when git history is unavailable (or the file has not been committed yet) */ -function getComponentExamples(componentName, allComponents) { +function getComponentExamples(componentName, allComponents, gitDates, existingExamples = []) { const componentExamplesDir = path.join(EXAMPLES_DIR, componentName); const examples = []; // Check if the component examples directory exists @@ -131,6 +231,15 @@ function getComponentExamples(componentName, allComponents) { if (moduleExports.tags?.length) { info.tags = moduleExports.tags; } + const dates = + gitDates?.get(`${componentName}/${entry.name}`) ?? + existingExamples.find((e) => e.name === exampleName); + if (dates?.createdAt) { + info.createdAt = dates.createdAt; + } + if (dates?.updatedAt) { + info.updatedAt = dates.updatedAt; + } examples.push(info); } } @@ -186,36 +295,50 @@ function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } } +/** + * Strip git-derived dates from examples so they don't count as catalog content changes + */ +function withoutDates(examples) { + return Array.isArray(examples) + ? examples.map(({ createdAt, updatedAt, ...rest }) => rest) + : examples; +} /** * Generate catalog for a single component + * + * @param gitDates - result of `getExampleGitDates()`. When `null`/`undefined`, example + * dates are carried over from the existing catalog file. */ -export function generateComponentCatalog(componentName, catalogPath, allComponents) { +export function generateComponentCatalog(componentName, catalogPath, allComponents, gitDates) { console.log(`Processing ${componentName}...`); - const examples = getComponentExamples(componentName, allComponents); - const usage = findComponentUsage(componentName); - // Check if catalog file exists and read it - let existingUpdatedAt; + // Read existing catalog (if any) + let existingCatalog; if (fs.existsSync(catalogPath)) { try { - const existingContent = fs.readFileSync(catalogPath, 'utf-8'); - const existingCatalog = JSON.parse(existingContent); - // Check if content has changed (excluding updatedAt field) - const contentUnchanged = - existingCatalog.component === componentName && - JSON.stringify(existingCatalog.examples) === JSON.stringify(examples) && - JSON.stringify(existingCatalog.usage) === JSON.stringify(usage); - if (contentUnchanged) { - existingUpdatedAt = existingCatalog.updatedAt; - } + existingCatalog = JSON.parse(fs.readFileSync(catalogPath, 'utf-8')); } catch (error) { // If we can't read or parse, treat as new file } } + const examples = getComponentExamples( + componentName, + allComponents, + gitDates, + Array.isArray(existingCatalog?.examples) ? existingCatalog.examples : [] + ); + const usage = findComponentUsage(componentName); + // Check if content has changed (excluding updatedAt field and example dates, which follow + // git history and would otherwise bump `updatedAt` again after every commit) + const contentUnchanged = + existingCatalog?.component === componentName && + JSON.stringify(withoutDates(existingCatalog.examples)) === + JSON.stringify(withoutDates(examples)) && + JSON.stringify(existingCatalog.usage) === JSON.stringify(usage); return { component: componentName, examples, usage, - updatedAt: existingUpdatedAt ?? new Date().toISOString(), + updatedAt: (contentUnchanged && existingCatalog.updatedAt) || new Date().toISOString(), }; } export async function writeExampleCatalogs({ @@ -234,6 +357,12 @@ export async function writeExampleCatalogs({ // Get all components const components = getComponents(componentsDir); logger.log(`Found ${components.length} components\n`); + const gitDates = getExampleGitDates(examplesDir); + if (!gitDates) { + logger.log( + 'Git history unavailable (no git or shallow clone), keeping existing example dates\n' + ); + } let processedCount = 0; let totalExamples = 0; let totalUsages = 0; @@ -241,7 +370,7 @@ export async function writeExampleCatalogs({ for (const componentName of components) { // Write catalog file const catalogPath = path.join(catalogDir, `${componentName}.json`); - const catalog = generateComponentCatalog(componentName, catalogPath, components); + const catalog = generateComponentCatalog(componentName, catalogPath, components, gitDates); fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2)); logger.log(` ✓ Examples: ${catalog.examples.length}`); logger.log(` ✓ Usages: ${catalog.usage.length}`);