@kenjura/ursa 0.58.0 → 0.60.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,18 @@
1
+ # 0.60.0
2
+ 2026-01-03
3
+
4
+ - added `menu-label` frontmatter field to override default menu labels
5
+ - added `menu-sort-as` frontmatter field for custom sort ordering
6
+ - metadata-only index.md files now trigger auto-index generation
7
+ - index files sorted to top of menu panes with distinct styling
8
+ - fixed menu column vertical scrolling
9
+ - excluded Ursa-internal fields from frontmatter table display
10
+
11
+ # 0.59.0
12
+ 2026-01-01
13
+
14
+ - added frontmatter rendering
15
+
1
16
  # 0.58.0
2
17
  2025-12-26
3
18
 
package/meta/default.css CHANGED
@@ -263,6 +263,7 @@ nav#nav-main {
263
263
  .menu-columns-wrapper {
264
264
  display: flex;
265
265
  height: 100%;
266
+ min-height: 0; /* Allow flex children to shrink */
266
267
  transition: transform 0.25s ease-out;
267
268
  }
268
269
 
@@ -270,7 +271,9 @@ nav#nav-main {
270
271
  .menu-column {
271
272
  width: 130px;
272
273
  min-width: 130px;
274
+ flex-shrink: 0;
273
275
  height: 100%;
276
+ min-height: 0; /* Allow flex child to shrink and enable scrolling */
274
277
  overflow-y: auto;
275
278
  overflow-x: hidden;
276
279
  border-right: 1px solid rgba(128, 128, 128, 0.15);
@@ -373,6 +376,18 @@ nav#nav-main {
373
376
  opacity: 1;
374
377
  }
375
378
 
379
+ /* Index file styling - appears at top of menu with subtle emphasis */
380
+ .menu-column-item.is-index > .menu-column-item-row {
381
+ border-bottom: 1px solid rgba(128, 128, 128, 0.15);
382
+ margin-bottom: 4px;
383
+ padding-bottom: 8px;
384
+ }
385
+
386
+ .menu-column-item.is-index > .menu-column-item-row .menu-column-label {
387
+ font-weight: 500;
388
+ opacity: 0.95;
389
+ }
390
+
376
391
  /* Scroll buttons */
377
392
  .menu-scroll-btn {
378
393
  position: absolute;
package/meta/menu.js CHANGED
@@ -227,6 +227,11 @@ document.addEventListener('DOMContentLoaded', () => {
227
227
  li.classList.add('has-children');
228
228
  }
229
229
 
230
+ // Check if this is an index file
231
+ if (item.isIndex) {
232
+ li.classList.add('is-index');
233
+ }
234
+
230
235
  // Create the item content
231
236
  const row = document.createElement('div');
232
237
  row.className = 'menu-column-item-row';
@@ -396,8 +401,15 @@ document.addEventListener('DOMContentLoaded', () => {
396
401
  let scrollTimeout = null;
397
402
 
398
403
  container.addEventListener('wheel', (e) => {
399
- // Handle both horizontal and vertical scroll (convert vertical to horizontal for menu)
400
- const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
404
+ // Only handle horizontal scroll - let vertical scroll work normally for column scrolling
405
+ const isHorizontalScroll = Math.abs(e.deltaX) > Math.abs(e.deltaY);
406
+
407
+ if (!isHorizontalScroll) {
408
+ // Allow vertical scrolling within columns
409
+ return;
410
+ }
411
+
412
+ const delta = e.deltaX;
401
413
 
402
414
  if (Math.abs(delta) > 0) {
403
415
  e.preventDefault();
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.60.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -1,7 +1,9 @@
1
1
  import dirTree from "directory-tree";
2
2
  import { extname, basename, join, dirname } from "path";
3
- import { existsSync } from "fs";
3
+ import { existsSync, readFileSync } from "fs";
4
4
  import { getFolderConfig, isFolderHidden, getRootConfig } from "./folderConfig.js";
5
+ import { extractMetadata, isMetadataOnly } from "./metadataExtractor.js";
6
+ import { stripHtml } from "./stripHtml.js";
5
7
 
6
8
  // Icon extensions to check for custom icons
7
9
  const ICON_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico'];
@@ -21,6 +23,92 @@ function toDisplayName(filename) {
21
23
  .replace(/\b\w/g, c => c.toUpperCase()); // Capitalize first letter of each word
22
24
  }
23
25
 
26
+ /**
27
+ * Get the menu label from a file's frontmatter
28
+ * @param {string} filePath - Path to the markdown file
29
+ * @returns {string|null} The menu-label value (with HTML stripped), or null if not found
30
+ */
31
+ function getMenuLabelFromFile(filePath) {
32
+ try {
33
+ if (!existsSync(filePath)) return null;
34
+ const content = readFileSync(filePath, 'utf8');
35
+ const metadata = extractMetadata(content);
36
+ if (metadata && metadata['menu-label']) {
37
+ return stripHtml(String(metadata['menu-label']));
38
+ }
39
+ } catch (e) {
40
+ // Ignore read errors
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Get the menu-sort-as value from a file's frontmatter
47
+ * @param {string} filePath - Path to the markdown file
48
+ * @returns {string|null} The menu-sort-as value (with HTML stripped), or null if not found
49
+ */
50
+ function getMenuSortAsFromFile(filePath) {
51
+ try {
52
+ if (!existsSync(filePath)) return null;
53
+ const content = readFileSync(filePath, 'utf8');
54
+ const metadata = extractMetadata(content);
55
+ if (metadata && metadata['menu-sort-as']) {
56
+ return stripHtml(String(metadata['menu-sort-as']));
57
+ }
58
+ } catch (e) {
59
+ // Ignore read errors
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * Get the menu label for a folder from its index.md frontmatter
66
+ * Falls back to config.json label (deprecated), then display name
67
+ * @param {string} dirPath - Path to the folder
68
+ * @param {object|null} folderConfig - The folder's config.json if any
69
+ * @param {string} baseName - The folder's base name
70
+ * @returns {string} The label to display
71
+ */
72
+ function getFolderLabel(dirPath, folderConfig, baseName) {
73
+ // First, check index.md for menu-label (preferred method)
74
+ for (const ext of INDEX_EXTENSIONS) {
75
+ const indexPath = join(dirPath, `index${ext}`);
76
+ const label = getMenuLabelFromFile(indexPath);
77
+ if (label) return label;
78
+ }
79
+
80
+ // Fall back to config.json label (deprecated)
81
+ if (folderConfig?.label) {
82
+ return folderConfig.label;
83
+ }
84
+
85
+ // Default to display name from folder name
86
+ return toDisplayName(baseName);
87
+ }
88
+
89
+ /**
90
+ * Get the sort key for a folder from its index.md frontmatter
91
+ * @param {string} dirPath - Path to the folder
92
+ * @returns {string|null} The menu-sort-as value, or null if not found
93
+ */
94
+ function getFolderSortKey(dirPath) {
95
+ for (const ext of INDEX_EXTENSIONS) {
96
+ const indexPath = join(dirPath, `index${ext}`);
97
+ const sortKey = getMenuSortAsFromFile(indexPath);
98
+ if (sortKey) return sortKey;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Check if a file is an index file
105
+ * @param {string} baseName - The file's base name (without extension)
106
+ * @returns {boolean} True if this is an index file
107
+ */
108
+ function isIndexFile(baseName) {
109
+ return baseName.toLowerCase() === 'index';
110
+ }
111
+
24
112
  function hasIndexFile(dirPath) {
25
113
  for (const ext of INDEX_EXTENSIONS) {
26
114
  const indexPath = join(dirPath, `index${ext}`);
@@ -165,14 +253,45 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
165
253
  continue;
166
254
  }
167
255
 
256
+ // Skip metadata-only index files (they only provide folder metadata, not actual pages)
257
+ if (!hasChildren && isIndexFile(baseName)) {
258
+ try {
259
+ const content = readFileSync(item.path, 'utf8');
260
+ if (isMetadataOnly(content)) {
261
+ continue; // Skip - this file doesn't produce a page
262
+ }
263
+ } catch (e) {
264
+ // If we can't read it, include it in the menu
265
+ }
266
+ }
267
+
168
268
  // Check if this folder is hidden via config.json
169
269
  if (hasChildren && isFolderHidden(item.path, source)) {
170
270
  continue; // Skip hidden folders
171
271
  }
172
272
 
173
- // Get folder config for custom label and icon
273
+ // Get folder config for custom label and icon (deprecated for labels, still used for icons/hidden)
174
274
  const folderConfig = hasChildren ? getFolderConfig(item.path) : null;
175
- const label = folderConfig?.label || toDisplayName(baseName);
275
+
276
+ // Determine the label - prefer menu-label from frontmatter
277
+ let label;
278
+ let sortKey;
279
+ const isIndex = !hasChildren && isIndexFile(baseName);
280
+
281
+ if (hasChildren) {
282
+ // For folders, get label from index.md frontmatter, then config.json, then folder name
283
+ label = getFolderLabel(item.path, folderConfig, baseName);
284
+ // Get sort key from folder's index.md, fall back to baseName (not transformed label)
285
+ sortKey = getFolderSortKey(item.path) || baseName;
286
+ } else {
287
+ // For files, check frontmatter for menu-label
288
+ const fileLabel = getMenuLabelFromFile(item.path);
289
+ label = fileLabel || toDisplayName(baseName);
290
+ // Get sort key from file's frontmatter, fall back to baseName (not transformed label)
291
+ // This ensures menu-sort-as values can match original filenames consistently
292
+ const fileSortKey = getMenuSortAsFromFile(item.path);
293
+ sortKey = fileSortKey || baseName;
294
+ }
176
295
 
177
296
  let rawHref = null;
178
297
  let href = null;
@@ -204,10 +323,12 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
204
323
 
205
324
  const menuItem = {
206
325
  label,
326
+ sortKey, // Used for sorting (menu-sort-as or label)
207
327
  path: folderPath,
208
328
  href,
209
329
  hasChildren,
210
330
  icon,
331
+ isIndex, // Mark index files for special styling and sorting
211
332
  };
212
333
 
213
334
  // Only include debug and inactive fields if requested (for smaller JSON)
@@ -226,11 +347,17 @@ function buildMenuData(tree, source, validPaths, parentPath = '', includeDebug =
226
347
  items.push(menuItem);
227
348
  }
228
349
 
350
+ // Sort: folders first, then index files, then alphabetically by sortKey
229
351
  return items.sort((a, b) => {
352
+ // Folders always come first
230
353
  if (a.hasChildren && !b.hasChildren) return -1;
231
354
  if (b.hasChildren && !a.hasChildren) return 1;
232
- if (a.label > b.label) return 1;
233
- if (a.label < b.label) return -1;
355
+ // Index files come before other files (after folders)
356
+ if (a.isIndex && !b.isIndex) return -1;
357
+ if (b.isIndex && !a.isIndex) return 1;
358
+ // Alphabetical sort by sortKey (menu-sort-as or label)
359
+ if (a.sortKey > b.sortKey) return 1;
360
+ if (a.sortKey < b.sortKey) return -1;
234
361
  return 0;
235
362
  });
236
363
  }
@@ -280,13 +407,14 @@ function renderMenuLevel(items, level) {
280
407
  const hasChildrenClass = item.hasChildren ? ' has-children' : '';
281
408
  const hasChildrenIndicator = item.hasChildren ? '<span class="menu-more">⋯</span>' : '';
282
409
  const inactiveClass = item.inactive ? ' inactive' : '';
410
+ const isIndexClass = item.isIndex ? ' is-index' : '';
283
411
 
284
412
  const labelHtml = item.href
285
413
  ? `<a href="${item.href}" class="menu-label${inactiveClass}">${item.label}</a>`
286
414
  : `<span class="menu-label">${item.label}</span>`;
287
415
 
288
416
  return `
289
- <li class="menu-item${hasChildrenClass}" data-path="${item.path}">
417
+ <li class="menu-item${hasChildrenClass}${isIndexClass}" data-path="${item.path}">
290
418
  <div class="menu-item-row">
291
419
  ${item.icon}
292
420
  ${labelHtml}
@@ -1,11 +1,12 @@
1
1
  // Auto-index generation helpers for build
2
- import { existsSync } from "fs";
2
+ import { existsSync, readFileSync } from "fs";
3
3
  import { readdir, readFile } from "fs/promises";
4
4
  import { basename, dirname, extname, join } from "path";
5
5
  import { outputFile } from "fs-extra";
6
6
  import { findStyleCss } from "../findStyleCss.js";
7
7
  import { toTitleCase } from "./titleCase.js";
8
8
  import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
9
+ import { isMetadataOnly } from "../metadataExtractor.js";
9
10
 
10
11
  /**
11
12
  * Generate automatic index.html files for folders that don't have one
@@ -30,10 +31,23 @@ export async function generateAutoIndices(output, directories, source, templates
30
31
  const outputNorm = output.replace(/\/+$/, '');
31
32
 
32
33
  // Build set of directories that already have an index.html from a source index.md/txt/yml
34
+ // Exclude metadata-only index files - they should still get auto-generated indices
33
35
  const dirsWithSourceIndex = new Set();
34
36
  for (const articlePath of generatedArticles) {
35
37
  const base = basename(articlePath, extname(articlePath));
36
38
  if (base === 'index') {
39
+ // Check if this is a metadata-only index file
40
+ try {
41
+ if (existsSync(articlePath)) {
42
+ const content = readFileSync(articlePath, 'utf8');
43
+ if (isMetadataOnly(content)) {
44
+ // Skip - this folder should still get an auto-index
45
+ continue;
46
+ }
47
+ }
48
+ } catch (e) {
49
+ // If we can't read it, assume it has content
50
+ }
37
51
  const dir = dirname(articlePath);
38
52
  const outputDir = dir.replace(sourceNorm, outputNorm);
39
53
  dirsWithSourceIndex.add(outputDir);
@@ -0,0 +1,128 @@
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 Ursa-internal keys that shouldn't be displayed in the metadata table
66
+ // These are used by Ursa for rendering/menu behavior, not document metadata
67
+ const excludeKeys = [
68
+ 'template', // Specifies which HTML template to use
69
+ 'layout', // Alternative name for template
70
+ 'draft', // Marks document as draft (not published)
71
+ 'published', // Publication status
72
+ 'menu-label', // Custom label for menu display
73
+ 'menu-sort-as', // Custom sort key for menu ordering
74
+ ];
75
+ const entries = Object.entries(metadata).filter(
76
+ ([key]) => !excludeKeys.includes(key.toLowerCase())
77
+ );
78
+
79
+ if (entries.length === 0) {
80
+ return '';
81
+ }
82
+
83
+ const rows = entries.map(([key, value]) => {
84
+ const formattedValue = formatValue(value);
85
+ // Don't escape HTML in formatted value since it may contain our formatting
86
+ return ` <tr>
87
+ <th>${escapeHtml(formatKey(key))}</th>
88
+ <td>${formattedValue}</td>
89
+ </tr>`;
90
+ }).join('\n');
91
+
92
+ return `<table class="frontmatter-table">
93
+ <tbody>
94
+ ${rows}
95
+ </tbody>
96
+ </table>`;
97
+ }
98
+
99
+ /**
100
+ * Inject the frontmatter table into the body HTML after the first H1
101
+ * If no H1 is present, prepend the table to the body
102
+ * @param {string} bodyHtml - The rendered body HTML
103
+ * @param {Object} metadata - The parsed YAML frontmatter
104
+ * @returns {string} The body HTML with the frontmatter table injected
105
+ */
106
+ export function injectFrontmatterTable(bodyHtml, metadata) {
107
+ const table = metadataToTable(metadata);
108
+
109
+ if (!table) {
110
+ return bodyHtml;
111
+ }
112
+
113
+ // Look for the first closing </h1> tag
114
+ const h1CloseMatch = bodyHtml.match(/<\/h1>/i);
115
+
116
+ if (h1CloseMatch) {
117
+ // Insert the table after the first </h1>
118
+ const insertPosition = h1CloseMatch.index + h1CloseMatch[0].length;
119
+ return (
120
+ bodyHtml.slice(0, insertPosition) +
121
+ '\n' + table + '\n' +
122
+ bodyHtml.slice(insertPosition)
123
+ );
124
+ }
125
+
126
+ // No H1 found, prepend the table
127
+ return table + '\n' + bodyHtml;
128
+ }
@@ -18,6 +18,25 @@ export function extractRawMetadata(rawBody) {
18
18
  return frontMatter;
19
19
  }
20
20
 
21
+ /**
22
+ * Check if a markdown file is "metadata-only" - has frontmatter but no meaningful content
23
+ * Such files are used only to provide metadata (like menu-label) for folders
24
+ * @param {string} rawBody - The raw file content
25
+ * @returns {boolean} True if the file has only frontmatter and no other content
26
+ */
27
+ export function isMetadataOnly(rawBody) {
28
+ if (!rawBody) return false;
29
+
30
+ const frontMatter = matchAllFrontMatter(rawBody);
31
+ if (!frontMatter) return false;
32
+
33
+ // Get content after frontmatter
34
+ const contentAfter = rawBody.slice(frontMatter.length).trim();
35
+
36
+ // Consider the file metadata-only if there's no content after frontmatter
37
+ return contentAfter.length === 0;
38
+ }
39
+
21
40
  function matchFrontMatter(str) {
22
41
  // Only match YAML front matter at the start of the file
23
42
  // Must have --- at line start, content, then closing --- also at line start
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Strip HTML tags from a string
3
+ * @param {string} text - The string that may contain HTML
4
+ * @returns {string} The text with HTML tags removed
5
+ */
6
+ export function stripHtml(text) {
7
+ if (!text || typeof text !== 'string') return text;
8
+
9
+ // Remove HTML tags
10
+ return text
11
+ .replace(/<[^>]*>/g, '') // Remove HTML tags
12
+ .replace(/&lt;/g, '<') // Decode common HTML entities
13
+ .replace(/&gt;/g, '>')
14
+ .replace(/&amp;/g, '&')
15
+ .replace(/&quot;/g, '"')
16
+ .replace(/&#039;/g, "'")
17
+ .replace(/&nbsp;/g, ' ')
18
+ .trim();
19
+ }
@@ -7,7 +7,9 @@ import { isFolderHidden, clearConfigCache } from "../helper/folderConfig.js";
7
7
  import {
8
8
  extractMetadata,
9
9
  extractRawMetadata,
10
+ isMetadataOnly,
10
11
  } from "../helper/metadataExtractor.js";
12
+ import { injectFrontmatterTable } from "../helper/frontmatterTable.js";
11
13
  import {
12
14
  hashContent,
13
15
  loadHashCache,
@@ -279,6 +281,14 @@ export async function generate({
279
281
  return;
280
282
  }
281
283
 
284
+ // Skip metadata-only index files - they exist only to provide folder metadata
285
+ // The auto-index system will generate the actual index.html for these folders
286
+ if (base === 'index' && type === '.md' && isMetadataOnly(rawBody)) {
287
+ progress.log(`ℹ️ Skipping metadata-only ${shortFile} - auto-index will generate listing`);
288
+ skippedCount++;
289
+ return;
290
+ }
291
+
282
292
  // Check if file needs regeneration
283
293
  const needsRegen = _clean || needsRegeneration(file, rawBody, hashCache);
284
294
 
@@ -307,13 +317,18 @@ export async function generate({
307
317
  // Calculate the document's URL path (e.g., "/character/index.html")
308
318
  const docUrlPath = '/' + dir + base + '.html';
309
319
 
310
- const body = renderFile({
320
+ let body = renderFile({
311
321
  fileContents: rawBody,
312
322
  type,
313
323
  dirname: dir,
314
324
  basename: base,
315
325
  });
316
326
 
327
+ // Inject frontmatter table after first H1 (for markdown files with metadata)
328
+ if (type === '.md' && fileMeta) {
329
+ body = injectFrontmatterTable(body, fileMeta);
330
+ }
331
+
317
332
  // Find nearest style.css or _style.css up the tree and copy to output
318
333
  // Use cache to avoid repeated filesystem walks for same directory
319
334
  let styleLink = "";