@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 +15 -0
- package/meta/default.css +15 -0
- package/meta/menu.js +14 -2
- package/package.json +1 -1
- package/src/helper/automenu.js +134 -6
- package/src/helper/build/autoIndex.js +15 -1
- package/src/helper/frontmatterTable.js +128 -0
- package/src/helper/metadataExtractor.js +19 -0
- package/src/helper/stripHtml.js +19 -0
- package/src/jobs/generate.js +16 -1
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
|
-
//
|
|
400
|
-
const
|
|
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
package/src/helper/automenu.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
233
|
-
if (a.
|
|
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, '&')
|
|
36
|
+
.replace(/</g, '<')
|
|
37
|
+
.replace(/>/g, '>')
|
|
38
|
+
.replace(/"/g, '"')
|
|
39
|
+
.replace(/'/g, ''');
|
|
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(/</g, '<') // Decode common HTML entities
|
|
13
|
+
.replace(/>/g, '>')
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
.replace(/'/g, "'")
|
|
17
|
+
.replace(/ /g, ' ')
|
|
18
|
+
.trim();
|
|
19
|
+
}
|
package/src/jobs/generate.js
CHANGED
|
@@ -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
|
-
|
|
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 = "";
|