@kenjura/ursa 0.41.0 → 0.44.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ # 0.44.0
2
+ 2025-12-16
3
+
4
+ - Added 'sections' metadata property with hierarchical section structure
5
+
6
+ # 0.43.0
7
+ 2025-12-14
8
+
9
+ - Added buildId and .ursa.json
10
+ - Added full datetime to footer
11
+ - Added commit hash to footer as comment
12
+
13
+ # 0.42.0
14
+ 2025-12-14
15
+
16
+ - Automenu will now remove dashes from file names
17
+
1
18
  # 0.41.0
2
19
  2025-12-13
3
20
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.41.0",
5
+ "version": "0.44.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -14,6 +14,13 @@ const HOME_ICON = '🏠';
14
14
  // Index file extensions to check for folder links
15
15
  const INDEX_EXTENSIONS = ['.md', '.txt', '.yml', '.yaml'];
16
16
 
17
+ // Convert filename to display name (e.g., "foo-bar" -> "Foo Bar")
18
+ function toDisplayName(filename) {
19
+ return filename
20
+ .replace(/[-_]/g, ' ') // Replace dashes and underscores with spaces
21
+ .replace(/\b\w/g, c => c.toUpperCase()); // Capitalize first letter of each word
22
+ }
23
+
17
24
  function hasIndexFile(dirPath) {
18
25
  for (const ext of INDEX_EXTENSIONS) {
19
26
  const indexPath = join(dirPath, `index${ext}`);
@@ -151,7 +158,7 @@ function buildMenuData(tree, source, validPaths, parentPath = '') {
151
158
 
152
159
  // Get folder config for custom label and icon
153
160
  const folderConfig = hasChildren ? getFolderConfig(item.path) : null;
154
- const label = folderConfig?.label || baseName;
161
+ const label = folderConfig?.label || toDisplayName(baseName);
155
162
 
156
163
  let rawHref = null;
157
164
  if (hasChildren) {
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Extract sections from markdown content based on headings
3
+ * Creates a hierarchical structure of sections
4
+ */
5
+
6
+ /**
7
+ * Extract sections from markdown content
8
+ * @param {string} content - The markdown content
9
+ * @returns {Array} Array of section objects with name and optional children
10
+ */
11
+ export function extractSections(content) {
12
+ if (!content) return [];
13
+
14
+ // Match all markdown headings (# to ######)
15
+ // Handles both "# Heading" and "#Heading" formats
16
+ const headingRegex = /^(#{1,6})\s*(.+?)$/gm;
17
+
18
+ const headings = [];
19
+ let match;
20
+
21
+ while ((match = headingRegex.exec(content)) !== null) {
22
+ const level = match[1].length; // Number of # characters
23
+ const name = match[2].trim();
24
+ headings.push({ level, name });
25
+ }
26
+
27
+ if (headings.length === 0) return [];
28
+
29
+ // Build hierarchical structure
30
+ return buildSectionTree(headings);
31
+ }
32
+
33
+ /**
34
+ * Build a hierarchical tree from flat heading list
35
+ * @param {Array} headings - Array of {level, name} objects
36
+ * @returns {Array} Hierarchical section tree
37
+ */
38
+ function buildSectionTree(headings) {
39
+ const root = { level: 0, children: [] };
40
+ const stack = [root];
41
+
42
+ for (const heading of headings) {
43
+ const section = { name: heading.name, level: heading.level, children: [] };
44
+
45
+ // Pop stack until we find a parent with lower level
46
+ while (stack.length > 1 && stack[stack.length - 1].level >= heading.level) {
47
+ stack.pop();
48
+ }
49
+
50
+ // Add to parent's children
51
+ const parent = stack[stack.length - 1];
52
+ parent.children.push(section);
53
+
54
+ // Push this section onto stack (it might have children)
55
+ stack.push(section);
56
+ }
57
+
58
+ // Clean up: remove level and empty children arrays
59
+ cleanupTree(root.children);
60
+
61
+ return root.children;
62
+ }
63
+
64
+ /**
65
+ * Remove level property and empty children arrays from the tree
66
+ */
67
+ function cleanupTree(sections) {
68
+ for (const section of sections) {
69
+ delete section.level;
70
+ if (section.children && section.children.length > 0) {
71
+ cleanupTree(section.children);
72
+ } else {
73
+ delete section.children;
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ const CONFIG_FILENAME = '.ursa.json';
5
+
6
+ /**
7
+ * Get the path to the .ursa.json config file in the source directory
8
+ * @param {string} sourceDir - The source directory path
9
+ * @returns {string} Path to the config file
10
+ */
11
+ function getConfigPath(sourceDir) {
12
+ return join(sourceDir, CONFIG_FILENAME);
13
+ }
14
+
15
+ /**
16
+ * Load the ursa config from .ursa.json
17
+ * @param {string} sourceDir - The source directory path
18
+ * @returns {object} The config object (empty object if file doesn't exist)
19
+ */
20
+ export function loadUrsaConfig(sourceDir) {
21
+ const configPath = getConfigPath(sourceDir);
22
+ try {
23
+ if (existsSync(configPath)) {
24
+ const content = readFileSync(configPath, 'utf8');
25
+ return JSON.parse(content);
26
+ }
27
+ } catch (e) {
28
+ console.error(`Error reading ${CONFIG_FILENAME}: ${e.message}`);
29
+ }
30
+ return {};
31
+ }
32
+
33
+ /**
34
+ * Save the ursa config to .ursa.json
35
+ * @param {string} sourceDir - The source directory path
36
+ * @param {object} config - The config object to save
37
+ */
38
+ export function saveUrsaConfig(sourceDir, config) {
39
+ const configPath = getConfigPath(sourceDir);
40
+ try {
41
+ const content = JSON.stringify(config, null, 2);
42
+ writeFileSync(configPath, content, 'utf8');
43
+ } catch (e) {
44
+ console.error(`Error writing ${CONFIG_FILENAME}: ${e.message}`);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Get the current build ID and increment it for the next build
50
+ * @param {string} sourceDir - The source directory path
51
+ * @returns {number} The current build ID (starting from 1)
52
+ */
53
+ export function getAndIncrementBuildId(sourceDir) {
54
+ const config = loadUrsaConfig(sourceDir);
55
+ const currentBuildId = config.buildId || 0;
56
+ const newBuildId = currentBuildId + 1;
57
+
58
+ config.buildId = newBuildId;
59
+ saveUrsaConfig(sourceDir, config);
60
+
61
+ return newBuildId;
62
+ }
@@ -20,6 +20,8 @@ import {
20
20
  buildValidPaths,
21
21
  markInactiveLinks,
22
22
  } from "../helper/linkValidator.js";
23
+ import { getAndIncrementBuildId } from "../helper/ursaConfig.js";
24
+ import { extractSections } from "../helper/sectionExtractor.js";
23
25
 
24
26
  // Helper function to build search index from processed files
25
27
  function buildSearchIndex(jsonCache, source, output) {
@@ -128,8 +130,12 @@ export async function generate({
128
130
 
129
131
  const menu = await getMenu(allSourceFilenames, source, validPaths);
130
132
 
133
+ // Get and increment build ID from .ursa.json
134
+ const buildId = getAndIncrementBuildId(resolve(_source));
135
+ console.log(`Build #${buildId}`);
136
+
131
137
  // Generate footer content
132
- const footer = await getFooter(source, _source);
138
+ const footer = await getFooter(source, _source, buildId);
133
139
 
134
140
  // Load content hash cache from .ursa folder in source directory
135
141
  let hashCache = new Map();
@@ -225,12 +231,16 @@ export async function generate({
225
231
  dirname: dir,
226
232
  basename: base,
227
233
  });
234
+ // Extract sections for markdown files
235
+ const sections = type === '.md' ? extractSections(rawBody) : [];
236
+
228
237
  jsonCache.set(file, {
229
238
  name: base,
230
239
  url,
231
240
  contents: rawBody,
232
241
  bodyHtml: body,
233
242
  metadata: meta,
243
+ sections,
234
244
  transformedMetadata: '',
235
245
  });
236
246
  return; // Skip regenerating this file
@@ -302,6 +312,10 @@ export async function generate({
302
312
  // json
303
313
 
304
314
  const jsonOutputFilename = outputFilename.replace(".html", ".json");
315
+
316
+ // Extract sections for markdown files
317
+ const sections = type === '.md' ? extractSections(rawBody) : [];
318
+
305
319
  const jsonObject = {
306
320
  name: base,
307
321
  url,
@@ -309,6 +323,7 @@ export async function generate({
309
323
  // bodyLessMeta: bodyLessMeta,
310
324
  bodyHtml: body,
311
325
  metadata: meta,
326
+ sections,
312
327
  transformedMetadata,
313
328
  // html: finalHtml,
314
329
  };
@@ -551,8 +566,9 @@ function addTrailingSlash(somePath) {
551
566
  * Generate footer HTML from footer.md and package.json
552
567
  * @param {string} source - resolved source path with trailing slash
553
568
  * @param {string} _source - original source path
569
+ * @param {number} buildId - the current build ID
554
570
  */
555
- async function getFooter(source, _source) {
571
+ async function getFooter(source, _source, buildId) {
556
572
  const footerParts = [];
557
573
 
558
574
  // Try to read footer.md from source root
@@ -607,12 +623,18 @@ async function getFooter(source, _source) {
607
623
  console.error(`Error reading ursa package.json: ${e.message}`);
608
624
  }
609
625
 
610
- // Build meta line: version, timestamp, "generated by ursa"
626
+ // Build meta line: version, build id, timestamp, "generated by ursa"
611
627
  const metaParts = [];
612
628
  if (docPackage?.version) {
613
629
  metaParts.push(`v${docPackage.version}`);
614
630
  }
615
- metaParts.push(new Date().toISOString().split('T')[0]); // YYYY-MM-DD
631
+ metaParts.push(`build ${buildId}`);
632
+
633
+ // Full date/time in a readable format
634
+ const now = new Date();
635
+ const timestamp = now.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
636
+ metaParts.push(timestamp);
637
+
616
638
  metaParts.push(`Generated by <a href="https://www.npmjs.com/package/@kenjura/ursa">ursa</a> v${ursaVersion}`);
617
639
 
618
640
  footerParts.push(`<div class="footer-meta">${metaParts.join(' • ')}</div>`);
@@ -628,5 +650,20 @@ async function getFooter(source, _source) {
628
650
  }
629
651
  }
630
652
 
653
+ // Try to get git short hash of doc repo (as HTML comment)
654
+ try {
655
+ const { execSync } = await import('child_process');
656
+ const gitHash = execSync('git rev-parse --short HEAD', {
657
+ cwd: resolve(_source),
658
+ encoding: 'utf8',
659
+ stdio: ['pipe', 'pipe', 'pipe']
660
+ }).trim();
661
+ if (gitHash) {
662
+ footerParts.push(`<!-- git: ${gitHash} -->`);
663
+ }
664
+ } catch (e) {
665
+ // Not a git repo or git not available - silently skip
666
+ }
667
+
631
668
  return footerParts.join('\n');
632
669
  }