@kenjura/ursa 0.72.0 → 0.75.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 +19 -0
- package/meta/default-template.html +32 -6
- package/meta/default.css +365 -175
- package/meta/menu.js +153 -80
- package/meta/search.js +7 -13
- package/meta/sectionify.js +10 -0
- package/meta/toc-generator.js +12 -6
- package/meta/widgets.js +376 -0
- package/package.json +1 -1
- package/src/dev.js +39 -11
- package/src/helper/automenu.js +102 -12
- package/src/helper/breadcrumbs.js +42 -0
- package/src/helper/build/autoIndex.js +80 -23
- package/src/helper/build/menu.js +4 -4
- package/src/helper/customMenu.js +118 -29
- package/src/helper/imageProcessor.js +38 -8
- package/src/jobs/generate.js +52 -9
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { toTitleCase } from "./build/titleCase.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate breadcrumb navigation HTML from a document's path.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} dir - Directory relative to source root, e.g. "settings/eberron/" or "/"
|
|
7
|
+
* @param {string} base - Filename without extension, e.g. "index" or "places"
|
|
8
|
+
* @param {object} [fileMeta] - Parsed frontmatter (used for current-page label override)
|
|
9
|
+
* @returns {string} Breadcrumb HTML string, or empty string if not applicable
|
|
10
|
+
*/
|
|
11
|
+
export function generateBreadcrumbs(dir, base, fileMeta) {
|
|
12
|
+
const segments = dir.split('/').filter(Boolean);
|
|
13
|
+
const isIndexFile = (base === 'index' || base === 'home');
|
|
14
|
+
|
|
15
|
+
// All path segments leading to the current page
|
|
16
|
+
const allSegments = isIndexFile ? segments : [...segments, base];
|
|
17
|
+
|
|
18
|
+
// No breadcrumbs for root-level pages (need at least 1 segment beyond root)
|
|
19
|
+
if (allSegments.length < 1) return '';
|
|
20
|
+
|
|
21
|
+
const parts = [`<a class="breadcrumb-link" href="/">Home</a>`];
|
|
22
|
+
let href = '/';
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < allSegments.length; i++) {
|
|
25
|
+
const seg = allSegments[i];
|
|
26
|
+
const isLast = i === allSegments.length - 1;
|
|
27
|
+
|
|
28
|
+
// Current page can use frontmatter title; others use title-cased folder name
|
|
29
|
+
const label = isLast && fileMeta?.title
|
|
30
|
+
? fileMeta.title
|
|
31
|
+
: toTitleCase(seg);
|
|
32
|
+
|
|
33
|
+
if (isLast) {
|
|
34
|
+
parts.push(`<span class="breadcrumb-current" aria-current="page">${label}</span>`);
|
|
35
|
+
} else {
|
|
36
|
+
href += seg + '/';
|
|
37
|
+
parts.push(`<a class="breadcrumb-link" href="${href}">${label}</a>`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return `<nav class="breadcrumbs" aria-label="Breadcrumbs">${parts.join('<span class="breadcrumb-sep" aria-hidden="true">/</span>')}</nav>\n`;
|
|
42
|
+
}
|
|
@@ -9,6 +9,35 @@ import { toTitleCase } from "./titleCase.js";
|
|
|
9
9
|
import { addTimestampToHtmlStaticRefs } from "./cacheBust.js";
|
|
10
10
|
import { isMetadataOnly, extractMetadata, getAutoIndexConfig } from "../metadataExtractor.js";
|
|
11
11
|
import { getCustomMenuForFile } from "./menu.js";
|
|
12
|
+
import { generateBreadcrumbs } from "../breadcrumbs.js";
|
|
13
|
+
|
|
14
|
+
// Document extensions for checking if a folder has content
|
|
15
|
+
const SOURCE_DOC_EXTENSIONS = ['.md', '.mdx', '.txt', '.yml', '.html'];
|
|
16
|
+
const OUTPUT_DOC_EXTENSIONS = ['.html'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Recursively check if a directory contains any document files.
|
|
20
|
+
* @param {string} dir - Directory path to check
|
|
21
|
+
* @param {string[]} extensions - File extensions that count as documents
|
|
22
|
+
* @returns {Promise<boolean>} True if the directory (or any subdirectory) contains at least one document
|
|
23
|
+
*/
|
|
24
|
+
async function directoryHasDocuments(dir, extensions) {
|
|
25
|
+
try {
|
|
26
|
+
const children = await readdir(dir, { withFileTypes: true });
|
|
27
|
+
for (const child of children) {
|
|
28
|
+
if (child.name.startsWith('.')) continue;
|
|
29
|
+
const fullPath = join(dir, child.name);
|
|
30
|
+
if (child.isDirectory()) {
|
|
31
|
+
if (child.name === 'img') continue;
|
|
32
|
+
if (await directoryHasDocuments(fullPath, extensions)) return true;
|
|
33
|
+
} else {
|
|
34
|
+
const ext = extname(child.name).toLowerCase();
|
|
35
|
+
if (extensions.includes(ext)) return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch (e) { /* ignore */ }
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
12
41
|
|
|
13
42
|
/**
|
|
14
43
|
* Generate auto-index HTML content for a directory from the OUTPUT folder
|
|
@@ -50,6 +79,11 @@ export async function generateAutoIndexHtml(dir, depth = 1, currentDepth = 0, pa
|
|
|
50
79
|
|
|
51
80
|
for (const child of filteredChildren) {
|
|
52
81
|
const isDir = child.isDirectory();
|
|
82
|
+
// Skip directories that contain no documents
|
|
83
|
+
if (isDir) {
|
|
84
|
+
const childDir = join(dir, child.name);
|
|
85
|
+
if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
|
|
86
|
+
}
|
|
53
87
|
const name = isDir ? child.name : child.name.replace('.html', '');
|
|
54
88
|
// Use pathPrefix to ensure hrefs are correct relative to the document root
|
|
55
89
|
const childPath = pathPrefix ? `${pathPrefix}/${child.name}` : child.name;
|
|
@@ -99,11 +133,11 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
|
|
|
99
133
|
// Skip hidden files
|
|
100
134
|
if (child.name.startsWith('.')) return false;
|
|
101
135
|
// Skip index files (we're generating into the index)
|
|
102
|
-
if (child.name.match(/^index\.(md|txt|yml|html)$/i)) return false;
|
|
136
|
+
if (child.name.match(/^index\.(md|mdx|txt|yml|html)$/i)) return false;
|
|
103
137
|
// Skip img folders (contain images, not content)
|
|
104
138
|
if (child.isDirectory() && child.name === 'img') return false;
|
|
105
|
-
// Include directories and article files (md, txt, yml, html)
|
|
106
|
-
return child.isDirectory() || child.name.match(/\.(md|txt|yml|html)$/i);
|
|
139
|
+
// Include directories and article files (md, mdx, txt, yml, html)
|
|
140
|
+
return child.isDirectory() || child.name.match(/\.(md|mdx|txt|yml|html)$/i);
|
|
107
141
|
})
|
|
108
142
|
.sort((a, b) => {
|
|
109
143
|
// Directories first, then files, alphabetically within each group
|
|
@@ -120,6 +154,11 @@ export async function generateAutoIndexHtmlFromSource(sourceDir, depth = 1, curr
|
|
|
120
154
|
|
|
121
155
|
for (const child of filteredChildren) {
|
|
122
156
|
const isDir = child.isDirectory();
|
|
157
|
+
// Skip directories that contain no documents
|
|
158
|
+
if (isDir) {
|
|
159
|
+
const childDir = join(sourceDir, child.name);
|
|
160
|
+
if (!await directoryHasDocuments(childDir, SOURCE_DOC_EXTENSIONS)) continue;
|
|
161
|
+
}
|
|
123
162
|
// Get name without extension for display
|
|
124
163
|
const ext = isDir ? '' : extname(child.name);
|
|
125
164
|
const nameWithoutExt = isDir ? child.name : basename(child.name, ext);
|
|
@@ -262,23 +301,29 @@ export async function generateAutoIndices(output, directories, source, templates
|
|
|
262
301
|
const children = await readdir(dir, { withFileTypes: true });
|
|
263
302
|
|
|
264
303
|
// Filter to only include relevant files and folders
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
304
|
+
const filteredItems = children.filter(child => {
|
|
305
|
+
// Skip hidden files and index alternates we just checked
|
|
306
|
+
if (child.name.startsWith('.')) return false;
|
|
307
|
+
if (child.name === 'index.html') return false;
|
|
308
|
+
// Include directories and html files
|
|
309
|
+
return child.isDirectory() || child.name.endsWith('.html');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Build items, skipping directories with no documents
|
|
313
|
+
const items = [];
|
|
314
|
+
for (const child of filteredItems) {
|
|
315
|
+
const isDir = child.isDirectory();
|
|
316
|
+
if (isDir) {
|
|
317
|
+
const childDir = join(dir, child.name);
|
|
318
|
+
if (!await directoryHasDocuments(childDir, OUTPUT_DOC_EXTENSIONS)) continue;
|
|
319
|
+
}
|
|
320
|
+
const name = isDir ? child.name : child.name.replace('.html', '');
|
|
321
|
+
// For directories, link to /folder/index.html; for files, use the filename directly
|
|
322
|
+
const href = isDir ? `${child.name}/index.html` : child.name;
|
|
323
|
+
const displayName = toTitleCase(name);
|
|
324
|
+
const icon = isDir ? '📁' : '📄';
|
|
325
|
+
items.push(`<li>${icon} <a href="${href}">${displayName}</a></li>`);
|
|
326
|
+
}
|
|
282
327
|
|
|
283
328
|
if (items.length === 0) {
|
|
284
329
|
// Empty folder, skip generating index
|
|
@@ -286,7 +331,13 @@ export async function generateAutoIndices(output, directories, source, templates
|
|
|
286
331
|
}
|
|
287
332
|
|
|
288
333
|
const folderDisplayName = dir === outputNorm ? 'Home' : toTitleCase(folderName);
|
|
289
|
-
|
|
334
|
+
|
|
335
|
+
// Generate breadcrumbs for auto-index pages
|
|
336
|
+
const relDir = dir.replace(outputNorm, '').replace(/^\//, '');
|
|
337
|
+
const breadcrumbDir = relDir ? relDir + '/' : '/';
|
|
338
|
+
const breadcrumbHtml = generateBreadcrumbs(breadcrumbDir, 'index', null);
|
|
339
|
+
|
|
340
|
+
const indexHtml = `${breadcrumbHtml}<h1>${folderDisplayName}</h1>\n<ul class="auto-index">\n${items.join('\n')}\n</ul>`;
|
|
290
341
|
|
|
291
342
|
const template = templates["default-template"];
|
|
292
343
|
if (!template) {
|
|
@@ -357,13 +408,19 @@ export async function generateAutoIndices(output, directories, source, templates
|
|
|
357
408
|
finalHtml = finalHtml.replace(key, value);
|
|
358
409
|
}
|
|
359
410
|
|
|
360
|
-
// Add
|
|
411
|
+
// Add menu data attributes to body
|
|
361
412
|
if (customMenuInfo) {
|
|
362
|
-
const menuPosition = customMenuInfo.menuPosition || '
|
|
413
|
+
const menuPosition = customMenuInfo.menuPosition || 'top';
|
|
363
414
|
finalHtml = finalHtml.replace(
|
|
364
415
|
/<body([^>]*)>/,
|
|
365
416
|
`<body$1 data-custom-menu="${customMenuInfo.menuJsonPath}" data-menu-position="${menuPosition}">`
|
|
366
417
|
);
|
|
418
|
+
} else {
|
|
419
|
+
// No custom menu — default to top menu
|
|
420
|
+
finalHtml = finalHtml.replace(
|
|
421
|
+
/<body([^>]*)>/,
|
|
422
|
+
`<body$1 data-menu-position="top">`
|
|
423
|
+
);
|
|
367
424
|
}
|
|
368
425
|
|
|
369
426
|
// Add cache-busting timestamps to static file references
|
package/src/helper/build/menu.js
CHANGED
|
@@ -48,7 +48,7 @@ export function findAllCustomMenus(allSourceFilenames, source) {
|
|
|
48
48
|
menuPath: menuInfo.path,
|
|
49
49
|
menuDir: menuInfo.menuDir,
|
|
50
50
|
menuData: parsedMenu.menuData,
|
|
51
|
-
menuPosition: parsedMenu.menuPosition || '
|
|
51
|
+
menuPosition: parsedMenu.menuPosition || 'top',
|
|
52
52
|
// The URL path for the menu JSON file
|
|
53
53
|
menuJsonPath: '/public/custom-menu-' + getMenuId(menuInfo.menuDir, source) + '.json',
|
|
54
54
|
});
|
|
@@ -60,7 +60,7 @@ export function findAllCustomMenus(allSourceFilenames, source) {
|
|
|
60
60
|
let menuData;
|
|
61
61
|
if (autoGenerate) {
|
|
62
62
|
// Auto-generate menu and combine with manual content
|
|
63
|
-
const depth = parseInt(frontmatter['menu-depth'], 10) ||
|
|
63
|
+
const depth = parseInt(frontmatter['menu-depth'], 10) || 10;
|
|
64
64
|
menuData = combineAutoAndManualMenu(body, menuInfo.menuDir, source, depth);
|
|
65
65
|
} else {
|
|
66
66
|
menuData = parseCustomMenu(body, menuInfo.menuDir, source);
|
|
@@ -70,7 +70,7 @@ export function findAllCustomMenus(allSourceFilenames, source) {
|
|
|
70
70
|
menuPath: menuInfo.path,
|
|
71
71
|
menuDir: menuInfo.menuDir,
|
|
72
72
|
menuData,
|
|
73
|
-
menuPosition: frontmatter['menu-position'] || '
|
|
73
|
+
menuPosition: frontmatter['menu-position'] || 'top',
|
|
74
74
|
// The URL path for the menu JSON file
|
|
75
75
|
menuJsonPath: '/public/custom-menu-' + getMenuId(menuInfo.menuDir, source) + '.json',
|
|
76
76
|
});
|
|
@@ -112,7 +112,7 @@ export function getCustomMenuForFile(filePath, source, customMenus) {
|
|
|
112
112
|
return {
|
|
113
113
|
menuJsonPath: menuInfo.menuJsonPath,
|
|
114
114
|
menuDir: relative(source, menuInfo.menuDir) || '',
|
|
115
|
-
menuPosition: menuInfo.menuPosition || '
|
|
115
|
+
menuPosition: menuInfo.menuPosition || 'top',
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
118
|
const parentDir = dirname(currentDir);
|
package/src/helper/customMenu.js
CHANGED
|
@@ -18,6 +18,64 @@ const INDEX_NAMES = ['index', 'home'];
|
|
|
18
18
|
// Default icons
|
|
19
19
|
const FOLDER_ICON = '📁';
|
|
20
20
|
const DOCUMENT_ICON = '📄';
|
|
21
|
+
const HOME_ICON = '🏠';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Collapse single-document folders into direct links.
|
|
25
|
+
* If a folder's only children are index-like files (no sub-folders),
|
|
26
|
+
* collapse it to a direct link using the folder's label and the first child's href.
|
|
27
|
+
* @param {Array} items - Menu items
|
|
28
|
+
* @returns {Array} - Processed menu items
|
|
29
|
+
*/
|
|
30
|
+
function collapseSingleDocFolders(items) {
|
|
31
|
+
return items.map(item => {
|
|
32
|
+
if (!item.hasChildren || !item.children || item.children.length === 0) return item;
|
|
33
|
+
|
|
34
|
+
// Recurse first so nested single-doc folders are collapsed bottom-up
|
|
35
|
+
item.children = collapseSingleDocFolders(item.children);
|
|
36
|
+
|
|
37
|
+
// Check if all children are non-folder items (i.e., no child has children)
|
|
38
|
+
const hasSubfolders = item.children.some(c => c.hasChildren);
|
|
39
|
+
if (!hasSubfolders && item.children.length > 0) {
|
|
40
|
+
// All children are leaf files — if there's only one, collapse to a direct link
|
|
41
|
+
// For index-like content (single child), collapse the folder entirely
|
|
42
|
+
if (item.children.length === 1) {
|
|
43
|
+
const onlyChild = item.children[0];
|
|
44
|
+
return {
|
|
45
|
+
...item,
|
|
46
|
+
hasChildren: false,
|
|
47
|
+
children: [],
|
|
48
|
+
href: onlyChild.href || item.href,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return item;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Recursively check if a folder contains any document files.
|
|
59
|
+
* @param {string} dirPath - Directory to check
|
|
60
|
+
* @returns {boolean} True if the folder (or any subfolder) contains at least one document
|
|
61
|
+
*/
|
|
62
|
+
function folderHasDocuments(dirPath) {
|
|
63
|
+
try {
|
|
64
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (entry.name.startsWith('.') || entry.name.startsWith('_')) continue;
|
|
67
|
+
const fullPath = join(dirPath, entry.name);
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
if (entry.name === 'img') continue;
|
|
70
|
+
if (folderHasDocuments(fullPath)) return true;
|
|
71
|
+
} else {
|
|
72
|
+
const ext = extname(entry.name);
|
|
73
|
+
if (SOURCE_EXTENSIONS.includes(ext)) return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch (e) { /* ignore */ }
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
21
79
|
|
|
22
80
|
/**
|
|
23
81
|
* Extract frontmatter from menu file content
|
|
@@ -113,30 +171,18 @@ function getMenuLabelFromFile(filePath) {
|
|
|
113
171
|
* Similar to the main automenu but for custom menu contexts
|
|
114
172
|
* @param {string} folderPath - Absolute path to folder
|
|
115
173
|
* @param {string} sourceRoot - Root source directory
|
|
116
|
-
* @param {number} depth - How deep to recurse (default:
|
|
174
|
+
* @param {number} depth - How deep to recurse (default: 10)
|
|
117
175
|
* @param {boolean} isRoot - Whether this is the root level (adds Home item)
|
|
118
176
|
* @returns {Array} - Menu items array
|
|
119
177
|
*/
|
|
120
|
-
export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth =
|
|
178
|
+
export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 10, isRoot = true) {
|
|
121
179
|
const items = [];
|
|
122
180
|
|
|
123
181
|
if (depth <= 0 || !existsSync(folderPath)) {
|
|
124
182
|
return items;
|
|
125
183
|
}
|
|
126
184
|
|
|
127
|
-
// Home item
|
|
128
|
-
let homeItem = null;
|
|
129
|
-
if (isRoot) {
|
|
130
|
-
const relativePath = '/' + relative(sourceRoot, folderPath).replace(/\\/g, '/');
|
|
131
|
-
homeItem = {
|
|
132
|
-
label: 'Home',
|
|
133
|
-
path: 'home',
|
|
134
|
-
href: relativePath + '/index.html',
|
|
135
|
-
hasChildren: false,
|
|
136
|
-
icon: `<span class="menu-icon">${DOCUMENT_ICON}</span>`,
|
|
137
|
-
children: [],
|
|
138
|
-
};
|
|
139
|
-
}
|
|
185
|
+
// (Home item is built at the end for root level, after partitioning)
|
|
140
186
|
|
|
141
187
|
try {
|
|
142
188
|
const entries = readdirSync(folderPath, { withFileTypes: true });
|
|
@@ -155,6 +201,9 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 2, is
|
|
|
155
201
|
const relativePath = '/' + relative(sourceRoot, fullPath).replace(/\\/g, '/');
|
|
156
202
|
|
|
157
203
|
if (entry.isDirectory()) {
|
|
204
|
+
// Skip folders that contain no documents (recursively)
|
|
205
|
+
if (!folderHasDocuments(fullPath)) continue;
|
|
206
|
+
|
|
158
207
|
// Check for index file to get label
|
|
159
208
|
let label = null;
|
|
160
209
|
for (const ext of SOURCE_EXTENSIONS) {
|
|
@@ -182,6 +231,8 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 2, is
|
|
|
182
231
|
const baseName = basename(entry.name, ext);
|
|
183
232
|
// Skip index files (they're represented by the folder)
|
|
184
233
|
if (INDEX_NAMES.includes(baseName.toLowerCase())) continue;
|
|
234
|
+
// Skip foldername-matching files (e.g. Arcanist/Arcanist.md — represented by the folder)
|
|
235
|
+
if (baseName.toLowerCase() === basename(folderPath).toLowerCase()) continue;
|
|
185
236
|
|
|
186
237
|
// Get label from frontmatter or filename
|
|
187
238
|
const label = getMenuLabelFromFile(fullPath) || toDisplayName(baseName);
|
|
@@ -197,23 +248,39 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 2, is
|
|
|
197
248
|
}
|
|
198
249
|
}
|
|
199
250
|
|
|
200
|
-
// Sort: folders first, then
|
|
251
|
+
// Sort: folders first (a-z), then files (a-z), case-insensitive
|
|
201
252
|
items.sort((a, b) => {
|
|
202
253
|
if (a.hasChildren && !b.hasChildren) return -1;
|
|
203
254
|
if (!a.hasChildren && b.hasChildren) return 1;
|
|
204
|
-
return a.label.localeCompare(b.label);
|
|
255
|
+
return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' });
|
|
205
256
|
});
|
|
206
257
|
|
|
207
258
|
} catch (e) {
|
|
208
259
|
console.error(`Error reading folder ${folderPath}:`, e);
|
|
209
260
|
}
|
|
210
261
|
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
262
|
+
// Post-process: collapse single-doc folders into direct links
|
|
263
|
+
const processedItems = collapseSingleDocFolders(items);
|
|
264
|
+
|
|
265
|
+
if (isRoot) {
|
|
266
|
+
// Partition top-level items: files go under Home, folders stay at top level
|
|
267
|
+
const topLevelFolders = processedItems.filter(item => item.hasChildren);
|
|
268
|
+
const topLevelFiles = processedItems.filter(item => !item.hasChildren);
|
|
269
|
+
|
|
270
|
+
const relativePath = '/' + relative(sourceRoot, folderPath).replace(/\\/g, '/');
|
|
271
|
+
const homeItem = {
|
|
272
|
+
label: 'Home',
|
|
273
|
+
path: 'home',
|
|
274
|
+
href: relativePath + '/index.html',
|
|
275
|
+
hasChildren: topLevelFiles.length > 0,
|
|
276
|
+
icon: `<span class="menu-icon">${HOME_ICON}</span>`,
|
|
277
|
+
children: topLevelFiles,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
return [homeItem, ...topLevelFolders];
|
|
214
281
|
}
|
|
215
282
|
|
|
216
|
-
return
|
|
283
|
+
return processedItems;
|
|
217
284
|
}
|
|
218
285
|
|
|
219
286
|
/**
|
|
@@ -241,7 +308,7 @@ export function autoGenerateMenuFromFolder(folderPath, sourceRoot, depth = 2, is
|
|
|
241
308
|
* @param {number} depth - How many levels deep to auto-generate
|
|
242
309
|
* @returns {Array} - Combined menu data array
|
|
243
310
|
*/
|
|
244
|
-
export function combineAutoAndManualMenu(manualContent, menuDir, sourceRoot, depth =
|
|
311
|
+
export function combineAutoAndManualMenu(manualContent, menuDir, sourceRoot, depth = 10) {
|
|
245
312
|
// Generate the auto menu
|
|
246
313
|
const autoMenuItems = autoGenerateMenuFromFolder(menuDir, sourceRoot, depth, true);
|
|
247
314
|
|
|
@@ -422,15 +489,31 @@ export function parseCustomMenu(content, menuDir, sourceRoot) {
|
|
|
422
489
|
// It's a relative path - resolve it relative to the menu directory
|
|
423
490
|
absoluteSourcePath = resolve(menuDir, href);
|
|
424
491
|
|
|
425
|
-
// Check if the path already has a source extension (.md, .txt)
|
|
492
|
+
// Check if the path already has a source extension (.md, .txt, .mdx)
|
|
426
493
|
const hrefExt = extname(href).toLowerCase();
|
|
427
494
|
const hasSourceExt = SOURCE_EXTENSIONS.includes(hrefExt);
|
|
495
|
+
const hasOutputExt = hrefExt === '.html';
|
|
428
496
|
|
|
429
497
|
// If it has a source extension, check if that exact file exists
|
|
498
|
+
// If it has .html extension, strip it and look for corresponding source file
|
|
430
499
|
// Otherwise use sourceFileExists which tries multiple extensions
|
|
431
500
|
let fileExists = false;
|
|
432
501
|
if (hasSourceExt) {
|
|
433
502
|
fileExists = existsSync(absoluteSourcePath);
|
|
503
|
+
} else if (hasOutputExt) {
|
|
504
|
+
// Strip .html and look for source files (the user linked to the output path)
|
|
505
|
+
const withoutHtml = absoluteSourcePath.replace(/\.html$/, '');
|
|
506
|
+
for (const ext of SOURCE_EXTENSIONS) {
|
|
507
|
+
if (existsSync(withoutHtml + ext)) {
|
|
508
|
+
absoluteSourcePath = withoutHtml + ext;
|
|
509
|
+
fileExists = true;
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Also check if it's a directory with an index file (e.g., index.html → index.md)
|
|
514
|
+
if (!fileExists) {
|
|
515
|
+
fileExists = sourceFileExists(withoutHtml);
|
|
516
|
+
}
|
|
434
517
|
} else {
|
|
435
518
|
fileExists = sourceFileExists(absoluteSourcePath);
|
|
436
519
|
}
|
|
@@ -441,8 +524,8 @@ export function parseCustomMenu(content, menuDir, sourceRoot) {
|
|
|
441
524
|
// Normalize path separators for web
|
|
442
525
|
href = href.replace(/\\/g, '/');
|
|
443
526
|
// Convert source extensions to .html
|
|
444
|
-
if (hasSourceExt) {
|
|
445
|
-
href = href.replace(/\.(md|txt)$/i, '.html');
|
|
527
|
+
if (hasSourceExt || existsSync(absoluteSourcePath) && SOURCE_EXTENSIONS.includes(extname(absoluteSourcePath).toLowerCase())) {
|
|
528
|
+
href = href.replace(/\.(md|mdx|txt)$/i, '.html');
|
|
446
529
|
} else if (!href.match(/\.[a-z]+$/i)) {
|
|
447
530
|
// Ensure it ends with .html if it doesn't have an extension
|
|
448
531
|
// Check if it's likely a folder (ends with /) or file
|
|
@@ -454,8 +537,14 @@ export function parseCustomMenu(content, menuDir, sourceRoot) {
|
|
|
454
537
|
}
|
|
455
538
|
}
|
|
456
539
|
} else {
|
|
457
|
-
// Source file doesn't exist -
|
|
458
|
-
|
|
540
|
+
// Source file doesn't exist - preserve href as-is so it still renders
|
|
541
|
+
// as a clickable (though possibly broken) link rather than plain text.
|
|
542
|
+
// If the href was relative, resolve it to an absolute web path.
|
|
543
|
+
const resolvedAbsolute = resolve(menuDir, href.startsWith('./') || href.startsWith('../') ? href : './' + href);
|
|
544
|
+
const resolvedRelative = relative(sourceRoot, resolvedAbsolute);
|
|
545
|
+
href = '/' + resolvedRelative.replace(/\\/g, '/');
|
|
546
|
+
// Convert source extensions to .html for web paths
|
|
547
|
+
href = href.replace(/\.(md|mdx|txt)$/i, '.html');
|
|
459
548
|
}
|
|
460
549
|
}
|
|
461
550
|
|
|
@@ -507,8 +596,8 @@ export function getCustomMenuForFile(filePath, sourceRoot) {
|
|
|
507
596
|
// Extract frontmatter options
|
|
508
597
|
const { frontmatter, body } = extractMenuFrontmatter(customMenuInfo.content);
|
|
509
598
|
const autoGenerate = frontmatter['auto-generate-menu'] === true || frontmatter['auto-generate-menu'] === 'true';
|
|
510
|
-
const menuPosition = frontmatter['menu-position'] || '
|
|
511
|
-
const depth = parseInt(frontmatter['menu-depth'], 10) ||
|
|
599
|
+
const menuPosition = frontmatter['menu-position'] || 'top';
|
|
600
|
+
const depth = parseInt(frontmatter['menu-depth'], 10) || 10;
|
|
512
601
|
|
|
513
602
|
let menuData;
|
|
514
603
|
|
|
@@ -464,8 +464,32 @@ export function transformImageTags(html, imageMap, docUrlPath = '/') {
|
|
|
464
464
|
const lastAClose = precedingHtml.lastIndexOf('</a>');
|
|
465
465
|
const isInsideAnchor = lastAOpen > lastAClose;
|
|
466
466
|
|
|
467
|
+
// Detect per-image no-preview flag:
|
|
468
|
+
// Markdown:  → ?no-preview in src
|
|
469
|
+
// HTML: <img data-no-preview src="..."> → data-no-preview attribute
|
|
470
|
+
const noPreviewQuery = /[?&]no-preview(?:&|$)/.test(src);
|
|
471
|
+
const noPreviewAttr = /data-no-preview/.test(before + after);
|
|
472
|
+
const noPreview = noPreviewQuery || noPreviewAttr;
|
|
473
|
+
|
|
474
|
+
// Strip ?no-preview from src so it doesn't appear in output
|
|
475
|
+
let cleanSrc = src;
|
|
476
|
+
if (noPreviewQuery) {
|
|
477
|
+
cleanSrc = cleanSrc
|
|
478
|
+
.replace(/[?&]no-preview(?=&|$)/, (m) => m[0] === '?' ? '?' : '')
|
|
479
|
+
.replace(/\?&/, '?')
|
|
480
|
+
.replace(/\?$/, '');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Strip data-no-preview attribute from output
|
|
484
|
+
let cleanBefore = before;
|
|
485
|
+
let cleanAfter = after;
|
|
486
|
+
if (noPreviewAttr) {
|
|
487
|
+
cleanBefore = cleanBefore.replace(/\s*data-no-preview(?:="[^"]*")?\s*/g, ' ');
|
|
488
|
+
cleanAfter = cleanAfter.replace(/\s*data-no-preview(?:="[^"]*")?\s*/g, ' ');
|
|
489
|
+
}
|
|
490
|
+
|
|
467
491
|
// Normalize src path for lookup
|
|
468
|
-
let lookupPath =
|
|
492
|
+
let lookupPath = cleanSrc;
|
|
469
493
|
// Remove query strings for lookup
|
|
470
494
|
const queryIndex = lookupPath.indexOf('?');
|
|
471
495
|
if (queryIndex !== -1) {
|
|
@@ -490,25 +514,31 @@ export function transformImageTags(html, imageMap, docUrlPath = '/') {
|
|
|
490
514
|
|
|
491
515
|
const imageInfo = imageMap.get(lookupPath);
|
|
492
516
|
|
|
493
|
-
// Determine full-size URL (use original from imageInfo, or fallback to src)
|
|
494
|
-
const fullSizeUrl = imageInfo ? imageInfo.original :
|
|
517
|
+
// Determine full-size URL (use original from imageInfo, or fallback to cleaned src)
|
|
518
|
+
const fullSizeUrl = imageInfo ? imageInfo.original : cleanSrc;
|
|
495
519
|
|
|
496
|
-
// Determine the src to use (preview if available
|
|
497
|
-
let newSrc =
|
|
498
|
-
if (imageInfo && imageInfo.preview !== imageInfo.original) {
|
|
520
|
+
// Determine the src to use (preview if available and not suppressed)
|
|
521
|
+
let newSrc = cleanSrc;
|
|
522
|
+
if (!noPreview && imageInfo && imageInfo.preview !== imageInfo.original) {
|
|
499
523
|
// Preserve any existing query string (like cache busting) on preview
|
|
500
|
-
const querySuffix = queryIndex !== -1 ?
|
|
524
|
+
const querySuffix = queryIndex !== -1 ? cleanSrc.substring(queryIndex) : '';
|
|
501
525
|
newSrc = imageInfo.preview + querySuffix;
|
|
502
526
|
}
|
|
503
527
|
|
|
504
528
|
// Build the new img tag
|
|
505
|
-
const imgTag = `<img${
|
|
529
|
+
const imgTag = `<img${cleanBefore}src="${newSrc}"${cleanAfter}>`;
|
|
506
530
|
|
|
507
531
|
// If already inside an anchor tag, just return the updated img tag
|
|
508
532
|
if (isInsideAnchor) {
|
|
509
533
|
return imgTag;
|
|
510
534
|
}
|
|
511
535
|
|
|
536
|
+
// If no-preview is set, the image is intentionally full-size — skip the
|
|
537
|
+
// click-to-view-original wrapper since it would be redundant.
|
|
538
|
+
if (noPreview) {
|
|
539
|
+
return imgTag;
|
|
540
|
+
}
|
|
541
|
+
|
|
512
542
|
// Wrap in anchor tag linking to full-size image
|
|
513
543
|
return `<a href="${fullSizeUrl}" target="_blank" class="image-link">${imgTag}</a>`;
|
|
514
544
|
}
|