@kenjura/ursa 0.96.0 → 0.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +72 -16
  3. package/bin/ursa.js +14 -1
  4. package/meta/templates/default-template/menu.js +18 -1
  5. package/meta/templates/default-template/search.js +11 -0
  6. package/meta/templates/default-template/widgets.js +4 -0
  7. package/package.json +1 -2
  8. package/src/dev.js +13 -23
  9. package/src/helper/__test__/contentHash.test.js +16 -6
  10. package/src/helper/assetBundler.js +93 -19
  11. package/src/helper/automenu.js +36 -11
  12. package/src/helper/build/__test__/autoIndex.test.js +2 -132
  13. package/src/helper/build/__test__/graph.test.js +259 -3
  14. package/src/helper/build/__test__/pass.test.js +553 -0
  15. package/src/helper/build/autoIndex.js +2 -371
  16. package/src/helper/build/excludeFilter.js +1 -2
  17. package/src/helper/build/footer.js +27 -14
  18. package/src/helper/build/graph.js +575 -152
  19. package/src/helper/build/index.js +0 -2
  20. package/src/helper/build/metadata.js +19 -5
  21. package/src/helper/build/pass.js +497 -0
  22. package/src/helper/build/precedence.js +174 -0
  23. package/src/helper/build/site.js +1270 -0
  24. package/src/helper/build/templates.js +1 -2
  25. package/src/helper/build/tracedFs.js +247 -0
  26. package/src/helper/contentHash.js +0 -78
  27. package/src/helper/customMenu.js +1 -1
  28. package/src/helper/fileRenderer.js +119 -111
  29. package/src/helper/findScriptJs.js +1 -1
  30. package/src/helper/findStyleCss.js +1 -1
  31. package/src/helper/folderConfig.js +7 -18
  32. package/src/helper/fullTextIndex.js +41 -29
  33. package/src/helper/imageProcessor.js +45 -0
  34. package/src/helper/linkValidator.js +118 -127
  35. package/src/helper/mdxRenderer.js +27 -5
  36. package/src/helper/menuLabels.js +30 -5
  37. package/src/helper/whitelistFilter.js +1 -2
  38. package/src/jobs/generate.js +67 -1829
  39. package/src/serve.js +317 -697
  40. package/src/helper/__test__/dependencyTracker.test.js +0 -157
  41. package/src/helper/build/cacheBust.js +0 -141
  42. package/src/helper/build/navCache.js +0 -145
  43. package/src/helper/build/watchCache.js +0 -33
  44. package/src/helper/dependencyTracker.js +0 -384
@@ -1,13 +1,6 @@
1
1
  // Auto-index generation helpers for build
2
- import { existsSync, readFileSync } from "fs";
3
- import { readdir, readFile } from "fs/promises";
4
- import { basename, dirname, extname, join } from "path";
5
- import { outputFile } from "fs-extra";
6
- import { findStyleCss, findAllStyleCss } from "../findStyleCss.js";
7
- import { findAllScriptJs } from "../findScriptJs.js";
8
- import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
9
- import { isMetadataOnly, extractMetadata, getAutoIndexConfig } from "../metadataExtractor.js";
10
- import { getCustomMenuForFile } from "./menu.js";
2
+ import { readdir } from "./tracedFs.js";
3
+ import { basename, extname, join } from "path";
11
4
  import { getFolderConfig, isFolderSelfHidden } from "../folderConfig.js";
12
5
  import {
13
6
  toDisplayName,
@@ -17,12 +10,9 @@ import {
17
10
  getFileLabel,
18
11
  findSourceDocument,
19
12
  } from "../menuLabels.js";
20
- import { generateBreadcrumbs } from "../breadcrumbs.js";
21
- import { bundleDocumentCss, bundleDocumentJs } from "../assetBundler.js";
22
13
 
23
14
  // Document extensions for checking if a folder has content
24
15
  const SOURCE_DOC_EXTENSIONS = ['.md', '.mdx', '.txt', '.yml', '.html'];
25
- const OUTPUT_DOC_EXTENSIONS = ['.html'];
26
16
 
27
17
  /**
28
18
  * Recursively check if a directory contains any document files.
@@ -95,87 +85,6 @@ function compareEntries(a, b) {
95
85
  return a.sortKey.toLowerCase().localeCompare(b.sortKey.toLowerCase());
96
86
  }
97
87
 
98
- /**
99
- * Generate auto-index HTML content for a directory from the OUTPUT folder
100
- * (used by fallback auto-index generation after all files are generated)
101
- * @param {string} dir - The directory path to generate index for (in output folder)
102
- * @param {number} depth - How deep to recurse (1 = current level only, 2 = current + children, etc.)
103
- * @param {number} [currentDepth=0] - Current recursion depth (internal use)
104
- * @param {string} [pathPrefix=''] - Path prefix for generating correct hrefs (internal use)
105
- * @param {string|null} [sourceDir=null] - Matching source directory, so `menu-label`
106
- * frontmatter and config.json labels can be honored. Without it, entries fall
107
- * back to their prettified file names.
108
- * @returns {Promise<string>} HTML content for the auto-index
109
- */
110
- export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pathPrefix = '', sourceDir = null) {
111
- try {
112
- const children = await readdir(dir, { withFileTypes: true });
113
-
114
- // Filter to only include relevant files and folders
115
- const filteredChildren = children
116
- .filter(child => {
117
- // Skip hidden files
118
- if (child.name.startsWith('.')) return false;
119
- // Skip index.html - we're generating it or it's the current page
120
- if (child.name === 'index.html') return false;
121
- // Skip img folders (contain images, not content)
122
- if (child.isDirectory() && child.name === 'img') return false;
123
- // Skip folders config.json marks hidden — they are ignored entirely,
124
- // so a stale output directory must not resurrect them in a listing
125
- if (child.isDirectory() && sourceDir && isFolderSelfHidden(join(sourceDir, child.name))) return false;
126
- // Include directories and html files
127
- return child.isDirectory() || child.name.endsWith('.html');
128
- })
129
- .map(child => {
130
- const isDir = child.isDirectory();
131
- const baseName = isDir ? child.name : child.name.replace(/\.html$/, '');
132
- return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
133
- })
134
- .sort(compareEntries);
135
-
136
- if (filteredChildren.length === 0) {
137
- return '';
138
- }
139
-
140
- const items = [];
141
-
142
- for (const { child, isDir, label } of filteredChildren) {
143
- // Skip directories that contain no documents
144
- if (isDir) {
145
- const childDir = join(dir, child.name);
146
- const childSource = sourceDir ? join(sourceDir, child.name) : null;
147
- if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS, childSource)) continue;
148
- }
149
- // Use pathPrefix to ensure hrefs are correct relative to the document root
150
- const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
151
- const href = isDir ? `${childPath}/index.html` : (pathPrefix ? `${pathPrefix}/${child.name}` : child.name);
152
- const icon = isDir ? '📁' : '📄';
153
-
154
- let itemHtml = `<li>${icon} <a href="${href}">${label}</a>`;
155
-
156
- // If this is a directory and we need to go deeper, recurse
157
- if (isDir && currentDepth + 1 < depth) {
158
- const childDir = join(dir, child.name);
159
- const childHtml = await generateAutoIndexHtml(
160
- childDir, depth, currentDepth + 1, childPath,
161
- sourceDir ? join(sourceDir, child.name) : null
162
- );
163
- if (childHtml) {
164
- itemHtml += `\n${childHtml}`;
165
- }
166
- }
167
-
168
- itemHtml += '</li>';
169
- items.push(itemHtml);
170
- }
171
-
172
- return `<ul class="auto-index depth-${currentDepth + 1}">\n${items.join('\n')}\n</ul>`;
173
- } catch (e) {
174
- console.error(`Error generating auto-index HTML for ${dir}: ${e.message}`);
175
- return '';
176
- }
177
- }
178
-
179
88
  /**
180
89
  * Generate auto-index HTML content from the SOURCE folder
181
90
  * (used for inline auto-index generation in index.md files with generate-auto-index: true)
@@ -250,281 +159,3 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
250
159
  return '';
251
160
  }
252
161
  }
253
-
254
- /**
255
- * Generate automatic index.html files for folders that don't have one
256
- * @param {string} output - Output directory path
257
- * @param {string[]} directories - List of source directories
258
- * @param {string} source - Source directory path
259
- * @param {object} templates - Template map
260
- * @param {string} menu - Rendered menu HTML
261
- * @param {string} footer - Footer HTML
262
- * @param {string[]} generatedArticles - List of source article paths that were generated
263
- * @param {Set<string>} copiedCssFiles - Set of CSS files already copied to output
264
- * @param {Set<string>} existingHtmlFiles - Set of existing HTML files in source (relative paths)
265
- * @param {string} cacheBustTimestamp - Cache-busting timestamp
266
- * @param {object} progress - Progress reporter instance
267
- * @param {Map} customMenus - Map of custom menus by directory path
268
- */
269
- export async function generateAutoIndices(output, directories, source, templates, menu, footer, generatedArticles, copiedCssFiles, existingHtmlFiles, cacheBustTimestamp, progress, customMenus) {
270
- // Alternate index file names to look for (in priority order)
271
- const INDEX_ALTERNATES = ['_index.html', 'home.html', '_home.html'];
272
-
273
- // Normalize paths (remove trailing slashes for consistent replacement)
274
- const sourceNorm = source.replace(/\/+$/, '');
275
- const outputNorm = output.replace(/\/+$/, '');
276
-
277
- // Build set of directories that already have an index.html from a source index.md/txt/yml
278
- // Exclude metadata-only index files - they should still get auto-generated indices
279
- const dirsWithSourceIndex = new Set();
280
- for (const articlePath of generatedArticles) {
281
- const base = basename(articlePath, extname(articlePath));
282
- if (base === 'index') {
283
- // Check if this is a metadata-only index file
284
- try {
285
- if (existsSync(articlePath)) {
286
- const content = readFileSync(articlePath, 'utf8');
287
- if (isMetadataOnly(content)) {
288
- // Skip - this folder should still get an auto-index
289
- continue;
290
- }
291
- }
292
- } catch (e) {
293
- // If we can't read it, assume it has content
294
- }
295
- const dir = dirname(articlePath);
296
- const outputDir = dir.replace(sourceNorm, outputNorm);
297
- dirsWithSourceIndex.add(outputDir);
298
- }
299
- }
300
-
301
- // Get all output directories (including root)
302
- const outputDirs = new Set([outputNorm]);
303
- for (const dir of directories) {
304
- // Handle both with and without trailing slash in source
305
- const outputDir = dir.replace(sourceNorm, outputNorm);
306
- outputDirs.add(outputDir);
307
- }
308
-
309
- let generatedCount = 0;
310
- let renamedCount = 0;
311
- let skippedHtmlCount = 0;
312
-
313
- for (const dir of outputDirs) {
314
- // Skip output directories that were never created (the source folder
315
- // produced no output files, e.g. it was empty)
316
- if (!existsSync(dir)) {
317
- continue;
318
- }
319
-
320
- const indexPath = join(dir, 'index.html');
321
-
322
- // Skip if this directory had a source index.md/txt/yml that was already processed
323
- if (dirsWithSourceIndex.has(dir)) {
324
- continue;
325
- }
326
-
327
- // Check if there's an existing index.html in the source directory (don't overwrite it)
328
- const sourceDir = dir.replace(outputNorm, sourceNorm);
329
- const relativeIndexPath = join(sourceDir, 'index.html').replace(sourceNorm + '/', '');
330
- if (existingHtmlFiles && existingHtmlFiles.has(relativeIndexPath)) {
331
- skippedHtmlCount++;
332
- continue; // Don't overwrite existing source HTML
333
- }
334
-
335
- // NOTE: an existing index.html in output is deliberately NOT a reason to
336
- // skip. This listing describes the folder's contents, so it goes stale the
337
- // moment a document or subfolder is added or removed, and warm builds kept
338
- // whatever was written the first time — indefinitely. Everything that has a
339
- // rightful claim on index.html is already excluded above: folders with a
340
- // source index document (dirsWithSourceIndex, built from every source
341
- // article rather than only the regenerated ones) and hand-written source
342
- // HTML (existingHtmlFiles). Alternates are re-promoted below, so those stay
343
- // authoritative too.
344
-
345
- // Get folder name for (foldername).html check
346
- const folderName = basename(dir);
347
- const folderNameAlternate = `${folderName}.html`;
348
-
349
- // Check for alternate index files
350
- let foundAlternate = null;
351
- for (const alt of [...INDEX_ALTERNATES, folderNameAlternate]) {
352
- const altPath = join(dir, alt);
353
- if (existsSync(altPath)) {
354
- foundAlternate = altPath;
355
- break;
356
- }
357
- }
358
-
359
- if (foundAlternate) {
360
- // Rename/copy alternate to index.html
361
- try {
362
- const content = await readFile(foundAlternate, 'utf8');
363
- await outputFile(indexPath, content);
364
- renamedCount++;
365
- progress.status('Auto-index', `Promoted ${basename(foundAlternate)} → index.html in ${dir.replace(outputNorm, '') || '/'}`);
366
- } catch (e) {
367
- progress.log(`Error promoting ${foundAlternate} to index.html: ${e.message}`);
368
- }
369
- } else {
370
- // Generate a simple index listing direct children
371
- try {
372
- const children = await readdir(dir, { withFileTypes: true });
373
-
374
- // Filter to only include relevant files and folders
375
- const filteredItems = children
376
- .filter(child => {
377
- // Skip hidden files and index alternates we just checked
378
- if (child.name.startsWith('.')) return false;
379
- if (child.name === 'index.html') return false;
380
- // Skip folders config.json marks hidden — they produce no output
381
- if (child.isDirectory() && isFolderSelfHidden(join(sourceDir, child.name))) return false;
382
- // Include directories and html files
383
- return child.isDirectory() || child.name.endsWith('.html');
384
- })
385
- .map(child => {
386
- const isDir = child.isDirectory();
387
- const baseName = isDir ? child.name : child.name.replace(/\.html$/, '');
388
- return { child, isDir, baseName, ...resolveEntryNaming(isDir, baseName, sourceDir) };
389
- })
390
- .sort(compareEntries);
391
-
392
- // Build items, skipping directories with no documents
393
- const items = [];
394
- for (const { child, isDir, label } of filteredItems) {
395
- if (isDir) {
396
- const childDir = join(dir, child.name);
397
- if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS, join(sourceDir, child.name))) continue;
398
- }
399
- // For directories, link to /folder/index.html; for files, use the filename directly
400
- const href = isDir ? `${child.name}/index.html` : child.name;
401
- const icon = isDir ? '📁' : '📄';
402
- items.push(`<li>${icon} <a href="${href}">${label}</a></li>`);
403
- }
404
-
405
- if (items.length === 0) {
406
- // Empty folder, skip generating index
407
- continue;
408
- }
409
-
410
- // The page's own heading and <title> follow the same naming rules, so a
411
- // folder labelled "BNW - Brave New World" in the menu is not "Bnw" here.
412
- const folderDisplayName = dir === outputNorm
413
- ? 'Home'
414
- : getFolderLabel(sourceDir, getFolderConfig(sourceDir), folderName);
415
-
416
- // Generate breadcrumbs for auto-index pages
417
- const relDir = dir.replace(outputNorm, '').replace(/^\//, '');
418
- const breadcrumbDir = relDir ? relDir + '/' : '/';
419
- const breadcrumbHtml = generateBreadcrumbs(breadcrumbDir, 'index', null, sourceNorm);
420
-
421
- const indexHtml = `${breadcrumbHtml}<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
422
-
423
- const template = templates["default-template"];
424
- if (!template) {
425
- progress.log(`Warning: No default template for auto-index in ${dir}`);
426
- continue;
427
- }
428
-
429
- // Find all style.css files up the tree and bundle them into a single CSS file
430
- // (Generate mode: one CSS bundle per unique folder path)
431
- let styleLink = "";
432
- try {
433
- const sourceDir = dir.replace(outputNorm, sourceNorm);
434
- const cssPaths = await findAllStyleCss(sourceDir, sourceNorm);
435
- if (cssPaths.length > 0) {
436
- // Copy all CSS files to output
437
- for (const cssPath of cssPaths) {
438
- if (!copiedCssFiles.has(cssPath)) {
439
- const cssOutputPath = cssPath.replace(sourceNorm, outputNorm);
440
- const cssContent = await readFile(cssPath, 'utf8');
441
- await outputFile(cssOutputPath, cssContent);
442
- copiedCssFiles.add(cssPath);
443
- }
444
- }
445
- // Bundle into a single file
446
- const folderRelative = sourceDir.replace(sourceNorm, '').replace(/^\//, '');
447
- const bundleUrl = await bundleDocumentCss(cssPaths, outputNorm, sourceNorm, folderRelative, { minify: true });
448
- styleLink = `<link rel="stylesheet" href="${bundleUrl}" />`;
449
- }
450
- } catch (e) {
451
- // ignore CSS lookup errors
452
- }
453
-
454
- // Find all script.js files from docroot to this directory and bundle them
455
- // (Generate mode: one JS bundle per unique folder path)
456
- let customScript = "";
457
- try {
458
- const sourceDir = dir.replace(outputNorm, sourceNorm);
459
- const scriptPaths = await findAllScriptJs(sourceDir, sourceNorm);
460
- if (scriptPaths.length > 0) {
461
- const folderRelative = sourceDir.replace(sourceNorm, '').replace(/^\//, '');
462
- const bundleUrl = await bundleDocumentJs(scriptPaths, outputNorm, sourceNorm, folderRelative, { minify: true });
463
- customScript = `<script src="${bundleUrl}"></script>`;
464
- }
465
- } catch (e) {
466
- // ignore script lookup errors
467
- }
468
-
469
- // Find custom menu for this directory
470
- let customMenuInfo = null;
471
- if (customMenus) {
472
- // Create a pseudo-file path for this directory to look up custom menu
473
- const sourceDir = dir.replace(outputNorm, sourceNorm);
474
- const pseudoFilePath = join(sourceDir, 'index.md'); // Pretend there's an index.md
475
- customMenuInfo = getCustomMenuForFile(pseudoFilePath, sourceNorm, customMenus);
476
- }
477
-
478
- let finalHtml = template;
479
- const replacements = {
480
- "${menu}": menu,
481
- "${body}": indexHtml,
482
- "${searchIndex}": "[]",
483
- "${title}": folderDisplayName,
484
- "${meta}": "{}",
485
- "${transformedMetadata}": "",
486
- "${styleLink}": styleLink,
487
- "${customScript}": customScript,
488
- "${footer}": footer
489
- };
490
- for (const [key, value] of Object.entries(replacements)) {
491
- finalHtml = finalHtml.replace(key, value);
492
- }
493
-
494
- // Add menu data attributes to body
495
- if (customMenuInfo) {
496
- const menuPosition = customMenuInfo.menuPosition || 'top';
497
- finalHtml = finalHtml.replace(
498
- /<body([^>]*)>/,
499
- `<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
500
- );
501
- } else {
502
- // No custom menu — default to top menu
503
- finalHtml = finalHtml.replace(
504
- /<body([^>]*)>/,
505
- `<body$1 data-menu-position="top">`
506
- );
507
- }
508
-
509
- // Add cache-busting timestamps to static file references
510
- finalHtml = addTimestampToHtmlStaticRefs(finalHtml, cacheBustTimestamp);
511
-
512
- await outputFile(indexPath, finalHtml);
513
- generatedCount++;
514
- progress.status('Auto-index', `Generated index.html for ${dir.replace(outputNorm, '') || '/'}`);
515
- } catch (e) {
516
- progress.log(`Error generating auto-index for ${dir}: ${e.message}`);
517
- }
518
- }
519
- }
520
-
521
- if (generatedCount > 0 || renamedCount > 0 || skippedHtmlCount > 0) {
522
- let summary = `${generatedCount} generated, ${renamedCount} promoted`;
523
- if (skippedHtmlCount > 0) {
524
- summary += `, ${skippedHtmlCount} skipped (existing HTML)`;
525
- }
526
- progress.done('Auto-index', summary);
527
- } else {
528
- progress.log(`Auto-index: All folders already have index.html`);
529
- }
530
- }
@@ -1,6 +1,5 @@
1
1
  // Exclude/filter helpers for build
2
- import { existsSync } from "fs";
3
- import { readFile, stat } from "fs/promises";
2
+ import { existsSync, readFile, stat } from "./tracedFs.js";
4
3
 
5
4
  /**
6
5
  * Parse exclude option - can be comma-separated paths or a file path
@@ -1,18 +1,23 @@
1
1
  // Footer generation helpers for build
2
- import { existsSync } from "fs";
3
- import { readFile } from "fs/promises";
2
+ import { existsSync, readFile } from "./tracedFs.js";
4
3
  import { dirname, join, resolve } from "path";
5
4
  import { URL } from "url";
6
5
  import { renderFile } from "../fileRenderer.js";
6
+ import { execSync } from "child_process";
7
7
 
8
8
  /**
9
9
  * Generate footer HTML from footer.md and package.json
10
10
  * @param {string} source - resolved source path with trailing slash
11
11
  * @param {string} _source - original source path
12
12
  * @param {number} buildId - the current build ID
13
+ * @param {{now?: Date, gitHash?: string|null}} [session] - Build metadata fixed once per
14
+ * serve session (or generate run): the timestamp shown in the footer and the
15
+ * doc repo's short commit hash. Supplying them keeps the footer a pure
16
+ * function of its inputs for the length of the session; when omitted they are
17
+ * taken now.
13
18
  * @returns {Promise<string>} Footer HTML
14
19
  */
15
- export async function getFooter(source, _source, buildId) {
20
+ export async function getFooter(source, _source, buildId, session = {}) {
16
21
  const footerParts = [];
17
22
 
18
23
  // Try to read footer.md from source root
@@ -40,7 +45,6 @@ export async function getFooter(source, _source, buildId) {
40
45
  if (existsSync(packagePath)) {
41
46
  const packageJson = await readFile(packagePath, 'utf8');
42
47
  docPackage = JSON.parse(packageJson);
43
- console.log(`Found doc package.json at ${packagePath}`);
44
48
  break;
45
49
  }
46
50
  } catch (e) {
@@ -61,7 +65,6 @@ export async function getFooter(source, _source, buildId) {
61
65
  const ursaPackageJson = await readFile(ursaPackagePath, 'utf8');
62
66
  const ursaPackage = JSON.parse(ursaPackageJson);
63
67
  ursaVersion = ursaPackage.version;
64
- console.log(`Found ursa package.json at ${ursaPackagePath}, version: ${ursaVersion}`);
65
68
  }
66
69
  } catch (e) {
67
70
  console.error(`Error reading ursa package.json: ${e.message}`);
@@ -75,7 +78,7 @@ export async function getFooter(source, _source, buildId) {
75
78
  metaParts.push(`build ${buildId}`);
76
79
 
77
80
  // Full date/time in a readable format
78
- const now = new Date();
81
+ const now = session.now ?? new Date();
79
82
  const timestamp = now.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
80
83
  metaParts.push(timestamp);
81
84
 
@@ -94,20 +97,30 @@ export async function getFooter(source, _source, buildId) {
94
97
  }
95
98
  }
96
99
 
97
- // Try to get git short hash of doc repo (as HTML comment)
100
+ // Git short hash of the doc repo (as HTML comment)
101
+ const gitHash = session.gitHash !== undefined ? session.gitHash : readGitHash(_source);
102
+ if (gitHash) {
103
+ footerParts.push(`<!-- git: ${gitHash} -->`);
104
+ }
105
+
106
+ return footerParts.join('\n');
107
+ }
108
+
109
+ /**
110
+ * The doc repo's short commit hash, or null outside a git work tree.
111
+ * @param {string} _source - Source directory
112
+ * @returns {string|null}
113
+ */
114
+ export function readGitHash(_source) {
98
115
  try {
99
- const { execSync } = await import('child_process');
100
116
  const gitHash = execSync('git rev-parse --short HEAD', {
101
117
  cwd: resolve(_source),
102
118
  encoding: 'utf8',
103
119
  stdio: ['pipe', 'pipe', 'pipe']
104
120
  }).trim();
105
- if (gitHash) {
106
- footerParts.push(`<!-- git: ${gitHash} -->`);
107
- }
121
+ return gitHash || null;
108
122
  } catch (e) {
109
- // Not a git repo or git not available - silently skip
123
+ // Not a git repo or git not available
124
+ return null;
110
125
  }
111
-
112
- return footerParts.join('\n');
113
126
  }