@kenjura/ursa 0.33.0 → 0.34.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 +6 -0
- package/meta/menu.js +26 -0
- package/package.json +1 -1
- package/src/helper/automenu.js +36 -4
- package/src/helper/folderConfig.js +87 -0
- package/src/jobs/generate.js +13 -3
- package/src/serve.js +9 -1
package/CHANGELOG.md
CHANGED
package/meta/menu.js
CHANGED
|
@@ -14,6 +14,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
14
14
|
return;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Load menu config from embedded JSON (contains openMenuItems)
|
|
18
|
+
const menuConfigScript = document.getElementById('menu-config');
|
|
19
|
+
let menuConfig = { openMenuItems: [] };
|
|
20
|
+
if (menuConfigScript) {
|
|
21
|
+
try {
|
|
22
|
+
menuConfig = JSON.parse(menuConfigScript.textContent);
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.error('Failed to parse menu config:', e);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
// State
|
|
18
29
|
let currentPath = []; // Array of path segments representing current directory
|
|
19
30
|
let expandedLevel1 = new Set(); // Track which level-1 items are expanded
|
|
@@ -357,6 +368,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
357
368
|
const currentHref = window.location.pathname;
|
|
358
369
|
const pathParts = currentHref.split('/').filter(Boolean);
|
|
359
370
|
|
|
371
|
+
// Check if we're on the home/root page
|
|
372
|
+
const isHomePage = currentHref === '/' ||
|
|
373
|
+
currentHref === '/index' ||
|
|
374
|
+
currentHref === '/index.html' ||
|
|
375
|
+
pathParts.length === 0 ||
|
|
376
|
+
(pathParts.length === 1 && pathParts[0].match(/^index(\.html)?$/));
|
|
377
|
+
|
|
378
|
+
// If on home page and we have openMenuItems config, expand those items
|
|
379
|
+
if (isHomePage && menuConfig.openMenuItems && menuConfig.openMenuItems.length > 0) {
|
|
380
|
+
for (const itemPath of menuConfig.openMenuItems) {
|
|
381
|
+
// Add to expanded set - itemPath should be the folder name like "character"
|
|
382
|
+
expandedLevel1.add(itemPath);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
360
386
|
// Try to find the deepest matching folder
|
|
361
387
|
if (pathParts.length > 1) {
|
|
362
388
|
// Navigate to parent folder of current page
|
package/package.json
CHANGED
package/src/helper/automenu.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import dirTree from "directory-tree";
|
|
2
2
|
import { extname, basename, join, dirname } from "path";
|
|
3
3
|
import { existsSync } from "fs";
|
|
4
|
+
import { getFolderConfig, isFolderHidden, getRootConfig } from "./folderConfig.js";
|
|
4
5
|
|
|
5
6
|
// Icon extensions to check for custom icons
|
|
6
7
|
const ICON_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico'];
|
|
@@ -127,12 +128,30 @@ function resolveHref(rawHref, validPaths) {
|
|
|
127
128
|
function buildMenuData(tree, source, validPaths, parentPath = '') {
|
|
128
129
|
const items = [];
|
|
129
130
|
|
|
131
|
+
// Files to hide from menu by default
|
|
132
|
+
const hiddenFiles = ['config.json', 'style.css'];
|
|
133
|
+
|
|
130
134
|
for (const item of tree.children || []) {
|
|
131
135
|
const ext = extname(item.path);
|
|
132
|
-
const
|
|
136
|
+
const baseName = basename(item.path, ext);
|
|
137
|
+
const fileName = basename(item.path);
|
|
133
138
|
const hasChildren = !!item.children;
|
|
134
139
|
const relativePath = item.path.replace(source, '');
|
|
135
|
-
const folderPath = parentPath ? `${parentPath}/${
|
|
140
|
+
const folderPath = parentPath ? `${parentPath}/${baseName}` : baseName;
|
|
141
|
+
|
|
142
|
+
// Skip hidden files (config.json, style.css, etc.)
|
|
143
|
+
if (!hasChildren && hiddenFiles.includes(fileName)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check if this folder is hidden via config.json
|
|
148
|
+
if (hasChildren && isFolderHidden(item.path, source)) {
|
|
149
|
+
continue; // Skip hidden folders
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Get folder config for custom label and icon
|
|
153
|
+
const folderConfig = hasChildren ? getFolderConfig(item.path) : null;
|
|
154
|
+
const label = folderConfig?.label || baseName;
|
|
136
155
|
|
|
137
156
|
let rawHref = null;
|
|
138
157
|
if (hasChildren) {
|
|
@@ -149,6 +168,12 @@ function buildMenuData(tree, source, validPaths, parentPath = '') {
|
|
|
149
168
|
// Resolve the href and check if target exists
|
|
150
169
|
const { href, inactive, debug } = resolveHref(rawHref, validPaths);
|
|
151
170
|
|
|
171
|
+
// Determine icon - custom from config, or custom icon file, or default
|
|
172
|
+
let icon = getIcon(item, source);
|
|
173
|
+
if (folderConfig?.icon) {
|
|
174
|
+
icon = `<span class="menu-icon"><img src="${folderConfig.icon}" alt="${label}" /></span>`;
|
|
175
|
+
}
|
|
176
|
+
|
|
152
177
|
const menuItem = {
|
|
153
178
|
label,
|
|
154
179
|
path: folderPath,
|
|
@@ -156,7 +181,7 @@ function buildMenuData(tree, source, validPaths, parentPath = '') {
|
|
|
156
181
|
inactive,
|
|
157
182
|
debug,
|
|
158
183
|
hasChildren,
|
|
159
|
-
icon
|
|
184
|
+
icon,
|
|
160
185
|
};
|
|
161
186
|
|
|
162
187
|
if (hasChildren) {
|
|
@@ -181,6 +206,10 @@ export async function getAutomenu(source, validPaths) {
|
|
|
181
206
|
});
|
|
182
207
|
const menuData = buildMenuData(tree, source, validPaths);
|
|
183
208
|
|
|
209
|
+
// Get root config for openMenuItems setting
|
|
210
|
+
const rootConfig = getRootConfig(source);
|
|
211
|
+
const openMenuItems = rootConfig?.openMenuItems || [];
|
|
212
|
+
|
|
184
213
|
// Add home item with resolved href
|
|
185
214
|
const homeResolved = resolveHref('/', validPaths);
|
|
186
215
|
const fullMenuData = [
|
|
@@ -191,6 +220,9 @@ export async function getAutomenu(source, validPaths) {
|
|
|
191
220
|
// Embed the menu data as JSON for JavaScript to use
|
|
192
221
|
const menuDataScript = `<script type="application/json" id="menu-data">${JSON.stringify(fullMenuData)}</script>`;
|
|
193
222
|
|
|
223
|
+
// Embed the openMenuItems config as separate JSON
|
|
224
|
+
const menuConfigScript = `<script type="application/json" id="menu-config">${JSON.stringify({ openMenuItems })}</script>`;
|
|
225
|
+
|
|
194
226
|
// Render the breadcrumb header (hidden by default, shown when navigating)
|
|
195
227
|
const breadcrumbHtml = `
|
|
196
228
|
<div class="menu-breadcrumb" style="display: none;">
|
|
@@ -202,7 +234,7 @@ export async function getAutomenu(source, validPaths) {
|
|
|
202
234
|
// Render the initial menu (root level)
|
|
203
235
|
const menuHtml = renderMenuLevel(fullMenuData, 0);
|
|
204
236
|
|
|
205
|
-
return `${menuDataScript}${breadcrumbHtml}<ul class="menu-level" data-level="0">${menuHtml}</ul>`;
|
|
237
|
+
return `${menuDataScript}${menuConfigScript}${breadcrumbHtml}<ul class="menu-level" data-level="0">${menuHtml}</ul>`;
|
|
206
238
|
}
|
|
207
239
|
|
|
208
240
|
function renderMenuLevel(items, level) {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILENAME = 'config.json';
|
|
5
|
+
|
|
6
|
+
// Cache for folder configs to avoid repeated file reads
|
|
7
|
+
const configCache = new Map();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Folder configuration schema:
|
|
11
|
+
* {
|
|
12
|
+
* label?: string, // Custom label for menu display
|
|
13
|
+
* icon?: string, // URL to icon image for menu
|
|
14
|
+
* hidden?: boolean, // If true, hide from menu and don't generate files
|
|
15
|
+
* openMenuItems?: string[] // (root only) Array of folder names to expand by default
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Clear the config cache (useful between generation runs)
|
|
21
|
+
*/
|
|
22
|
+
export function clearConfigCache() {
|
|
23
|
+
configCache.clear();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read and parse a folder's config.json if it exists (synchronous)
|
|
28
|
+
* @param {string} folderPath - Absolute path to the folder
|
|
29
|
+
* @returns {object|null} Parsed config object or null if not found
|
|
30
|
+
*/
|
|
31
|
+
export function getFolderConfig(folderPath) {
|
|
32
|
+
// Check cache first
|
|
33
|
+
if (configCache.has(folderPath)) {
|
|
34
|
+
return configCache.get(folderPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const configPath = join(folderPath, CONFIG_FILENAME);
|
|
38
|
+
try {
|
|
39
|
+
if (existsSync(configPath)) {
|
|
40
|
+
const content = readFileSync(configPath, 'utf8');
|
|
41
|
+
const config = JSON.parse(content);
|
|
42
|
+
configCache.set(folderPath, config);
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.warn(`Could not read folder config at ${configPath}:`, e.message);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
configCache.set(folderPath, null);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the root folder's config
|
|
55
|
+
* @param {string} sourceRoot - The source root directory
|
|
56
|
+
* @returns {object|null} Config object or null
|
|
57
|
+
*/
|
|
58
|
+
export function getRootConfig(sourceRoot) {
|
|
59
|
+
return getFolderConfig(sourceRoot.replace(/\/$/, ''));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if a folder or any of its ancestors is hidden via config.json
|
|
64
|
+
* @param {string} folderPath - Absolute path to check
|
|
65
|
+
* @param {string} sourceRoot - The source root directory (stop checking at this level)
|
|
66
|
+
* @returns {boolean} True if this folder should be hidden
|
|
67
|
+
*/
|
|
68
|
+
export function isFolderHidden(folderPath, sourceRoot) {
|
|
69
|
+
let currentPath = folderPath.replace(/\/$/, '');
|
|
70
|
+
|
|
71
|
+
// Normalize source root for comparison
|
|
72
|
+
const normalizedRoot = sourceRoot.replace(/\/$/, '');
|
|
73
|
+
|
|
74
|
+
while (currentPath.length >= normalizedRoot.length) {
|
|
75
|
+
const config = getFolderConfig(currentPath);
|
|
76
|
+
if (config?.hidden === true) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Move to parent directory
|
|
81
|
+
const parentPath = dirname(currentPath);
|
|
82
|
+
if (parentPath === currentPath) break; // Reached filesystem root
|
|
83
|
+
currentPath = parentPath;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return false;
|
|
87
|
+
}
|
package/src/jobs/generate.js
CHANGED
|
@@ -4,6 +4,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "fs/promises";
|
|
|
4
4
|
import { getAutomenu } from "../helper/automenu.js";
|
|
5
5
|
import { filterAsync } from "../helper/filterAsync.js";
|
|
6
6
|
import { isDirectory } from "../helper/isDirectory.js";
|
|
7
|
+
import { isFolderHidden, clearConfigCache } from "../helper/folderConfig.js";
|
|
7
8
|
import {
|
|
8
9
|
extractMetadata,
|
|
9
10
|
extractRawMetadata,
|
|
@@ -101,16 +102,25 @@ export async function generate({
|
|
|
101
102
|
const templates = await getTemplates(meta); // todo: error if no default template
|
|
102
103
|
// console.log({ templates });
|
|
103
104
|
|
|
105
|
+
// Clear config cache at start of generation to pick up any changes
|
|
106
|
+
clearConfigCache();
|
|
107
|
+
|
|
108
|
+
// Helper to check if a path is inside a config-hidden folder
|
|
109
|
+
const isInHiddenFolder = (filePath) => {
|
|
110
|
+
const dir = dirname(filePath);
|
|
111
|
+
return isFolderHidden(dir, source);
|
|
112
|
+
};
|
|
113
|
+
|
|
104
114
|
// read all articles, process them, copy them to build
|
|
105
115
|
const articleExtensions = /\.(md|txt|yml)/;
|
|
106
116
|
const hiddenOrSystemDirs = /[\/\\]\.(?!\.)|[\/\\]node_modules[\/\\]/; // Matches hidden folders (starting with .) or node_modules
|
|
107
117
|
const allSourceFilenamesThatAreArticles = allSourceFilenames.filter(
|
|
108
|
-
(filename) => filename.match(articleExtensions) && !filename.match(hiddenOrSystemDirs)
|
|
118
|
+
(filename) => filename.match(articleExtensions) && !filename.match(hiddenOrSystemDirs) && !isInHiddenFolder(filename)
|
|
109
119
|
);
|
|
110
120
|
const allSourceFilenamesThatAreDirectories = (await filterAsync(
|
|
111
121
|
allSourceFilenames,
|
|
112
122
|
(filename) => isDirectory(filename)
|
|
113
|
-
)).filter((filename) => !filename.match(hiddenOrSystemDirs));
|
|
123
|
+
)).filter((filename) => !filename.match(hiddenOrSystemDirs) && !isFolderHidden(filename, source));
|
|
114
124
|
|
|
115
125
|
// Build set of valid internal paths for link validation (must be before menu)
|
|
116
126
|
const validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source);
|
|
@@ -363,7 +373,7 @@ export async function generate({
|
|
|
363
373
|
);
|
|
364
374
|
|
|
365
375
|
// copy all static files (i.e. images)
|
|
366
|
-
const imageExtensions = /\.(jpg|png|gif|webp)/; //
|
|
376
|
+
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|ico)/; // static asset extensions
|
|
367
377
|
const allSourceFilenamesThatAreImages = allSourceFilenames.filter(
|
|
368
378
|
(filename) => filename.match(imageExtensions)
|
|
369
379
|
);
|
package/src/serve.js
CHANGED
|
@@ -48,7 +48,15 @@ export async function serve({
|
|
|
48
48
|
|
|
49
49
|
// Source changes use incremental mode (only regenerate changed files)
|
|
50
50
|
// Exception: CSS changes require full rebuild since they're embedded in all pages
|
|
51
|
-
watch(sourceDir, {
|
|
51
|
+
watch(sourceDir, {
|
|
52
|
+
recursive: true,
|
|
53
|
+
filter: (f, skip) => {
|
|
54
|
+
// Skip .ursa folder (contains hash cache that gets updated during generation)
|
|
55
|
+
if (/[\/\\]\.ursa[\/\\]?/.test(f)) return skip;
|
|
56
|
+
// Only watch relevant file types
|
|
57
|
+
return /\.(js|json|css|html|md|txt|yml|yaml)$/.test(f);
|
|
58
|
+
}
|
|
59
|
+
}, async (evt, name) => {
|
|
52
60
|
console.log(`Source files changed! Event: ${evt}, File: ${name}`);
|
|
53
61
|
|
|
54
62
|
// CSS files affect all pages (embedded styles), so trigger full rebuild
|