@kenjura/ursa 0.58.0 → 0.59.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,8 @@
1
+ # 0.59.0
2
+ 2026-01-01
3
+
4
+ - added frontmatter rendering
5
+
1
6
  # 0.58.0
2
7
  2025-12-26
3
8
 
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.58.0",
5
+ "version": "0.59.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Convert YAML frontmatter metadata to an HTML table
3
+ * and inject it into the document body after the first H1
4
+ */
5
+
6
+ /**
7
+ * Convert a metadata value to a displayable string
8
+ * @param {any} value - The value to convert
9
+ * @returns {string} The displayable string
10
+ */
11
+ function formatValue(value) {
12
+ if (value === null || value === undefined) {
13
+ return '';
14
+ }
15
+ if (Array.isArray(value)) {
16
+ return value.map(formatValue).join(', ');
17
+ }
18
+ if (typeof value === 'object') {
19
+ // For nested objects, render as a mini definition list
20
+ return Object.entries(value)
21
+ .map(([k, v]) => `<strong>${escapeHtml(k)}:</strong> ${escapeHtml(formatValue(v))}`)
22
+ .join('<br>');
23
+ }
24
+ return String(value);
25
+ }
26
+
27
+ /**
28
+ * Escape HTML special characters
29
+ * @param {string} str - String to escape
30
+ * @returns {string} Escaped string
31
+ */
32
+ function escapeHtml(str) {
33
+ if (typeof str !== 'string') return str;
34
+ return str
35
+ .replace(/&/g, '&amp;')
36
+ .replace(/</g, '&lt;')
37
+ .replace(/>/g, '&gt;')
38
+ .replace(/"/g, '&quot;')
39
+ .replace(/'/g, '&#039;');
40
+ }
41
+
42
+ /**
43
+ * Convert a key to a human-readable label
44
+ * @param {string} key - The metadata key
45
+ * @returns {string} Human-readable label
46
+ */
47
+ function formatKey(key) {
48
+ // Convert camelCase or snake_case to Title Case with spaces
49
+ return key
50
+ .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase
51
+ .replace(/_/g, ' ') // snake_case
52
+ .replace(/\b\w/g, c => c.toUpperCase()); // Title Case
53
+ }
54
+
55
+ /**
56
+ * Generate an HTML table from metadata object
57
+ * @param {Object} metadata - The parsed YAML frontmatter
58
+ * @returns {string} HTML table string
59
+ */
60
+ export function metadataToTable(metadata) {
61
+ if (!metadata || typeof metadata !== 'object' || Object.keys(metadata).length === 0) {
62
+ return '';
63
+ }
64
+
65
+ // Filter out internal/template-related keys that shouldn't be displayed
66
+ const excludeKeys = ['template', 'layout', 'draft', 'published'];
67
+ const entries = Object.entries(metadata).filter(
68
+ ([key]) => !excludeKeys.includes(key.toLowerCase())
69
+ );
70
+
71
+ if (entries.length === 0) {
72
+ return '';
73
+ }
74
+
75
+ const rows = entries.map(([key, value]) => {
76
+ const formattedValue = formatValue(value);
77
+ // Don't escape HTML in formatted value since it may contain our formatting
78
+ return ` <tr>
79
+ <th>${escapeHtml(formatKey(key))}</th>
80
+ <td>${formattedValue}</td>
81
+ </tr>`;
82
+ }).join('\n');
83
+
84
+ return `<table class="frontmatter-table">
85
+ <tbody>
86
+ ${rows}
87
+ </tbody>
88
+ </table>`;
89
+ }
90
+
91
+ /**
92
+ * Inject the frontmatter table into the body HTML after the first H1
93
+ * If no H1 is present, prepend the table to the body
94
+ * @param {string} bodyHtml - The rendered body HTML
95
+ * @param {Object} metadata - The parsed YAML frontmatter
96
+ * @returns {string} The body HTML with the frontmatter table injected
97
+ */
98
+ export function injectFrontmatterTable(bodyHtml, metadata) {
99
+ const table = metadataToTable(metadata);
100
+
101
+ if (!table) {
102
+ return bodyHtml;
103
+ }
104
+
105
+ // Look for the first closing </h1> tag
106
+ const h1CloseMatch = bodyHtml.match(/<\/h1>/i);
107
+
108
+ if (h1CloseMatch) {
109
+ // Insert the table after the first </h1>
110
+ const insertPosition = h1CloseMatch.index + h1CloseMatch[0].length;
111
+ return (
112
+ bodyHtml.slice(0, insertPosition) +
113
+ '\n' + table + '\n' +
114
+ bodyHtml.slice(insertPosition)
115
+ );
116
+ }
117
+
118
+ // No H1 found, prepend the table
119
+ return table + '\n' + bodyHtml;
120
+ }
@@ -8,6 +8,7 @@ import {
8
8
  extractMetadata,
9
9
  extractRawMetadata,
10
10
  } from "../helper/metadataExtractor.js";
11
+ import { injectFrontmatterTable } from "../helper/frontmatterTable.js";
11
12
  import {
12
13
  hashContent,
13
14
  loadHashCache,
@@ -307,13 +308,18 @@ export async function generate({
307
308
  // Calculate the document's URL path (e.g., "/character/index.html")
308
309
  const docUrlPath = '/' + dir + base + '.html';
309
310
 
310
- const body = renderFile({
311
+ let body = renderFile({
311
312
  fileContents: rawBody,
312
313
  type,
313
314
  dirname: dir,
314
315
  basename: base,
315
316
  });
316
317
 
318
+ // Inject frontmatter table after first H1 (for markdown files with metadata)
319
+ if (type === '.md' && fileMeta) {
320
+ body = injectFrontmatterTable(body, fileMeta);
321
+ }
322
+
317
323
  // Find nearest style.css or _style.css up the tree and copy to output
318
324
  // Use cache to avoid repeated filesystem walks for same directory
319
325
  let styleLink = "";