@kenjura/ursa 0.32.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 +11 -0
- package/meta/menu.js +42 -0
- package/package.json +1 -1
- package/src/helper/automenu.js +45 -9
- package/src/helper/folderConfig.js +87 -0
- package/src/jobs/generate.js +15 -4
- 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
|
|
@@ -257,6 +268,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
257
268
|
caret.addEventListener('click', toggleExpand);
|
|
258
269
|
}
|
|
259
270
|
|
|
271
|
+
// If the link points to the current page, clicking it should toggle instead of navigate
|
|
272
|
+
if (link) {
|
|
273
|
+
link.addEventListener('click', (e) => {
|
|
274
|
+
const linkHref = link.getAttribute('href');
|
|
275
|
+
const currentHref = window.location.pathname;
|
|
276
|
+
const normalizedLinkHref = linkHref.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
|
277
|
+
const normalizedCurrentHref = currentHref.replace(/\/index\.html$/, '').replace(/\.html$/, '');
|
|
278
|
+
|
|
279
|
+
if (normalizedLinkHref === normalizedCurrentHref) {
|
|
280
|
+
// Already on this page - toggle instead of navigate
|
|
281
|
+
toggleExpand(e);
|
|
282
|
+
}
|
|
283
|
+
// Otherwise, let the default navigation happen
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
260
287
|
row.addEventListener('click', (e) => {
|
|
261
288
|
if (!e.target.closest('a')) {
|
|
262
289
|
toggleExpand(e);
|
|
@@ -341,6 +368,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
341
368
|
const currentHref = window.location.pathname;
|
|
342
369
|
const pathParts = currentHref.split('/').filter(Boolean);
|
|
343
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
|
+
|
|
344
386
|
// Try to find the deepest matching folder
|
|
345
387
|
if (pathParts.length > 1) {
|
|
346
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,25 +128,52 @@ 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) {
|
|
139
158
|
if (hasIndexFile(item.path)) {
|
|
140
|
-
|
|
159
|
+
// Construct proper path - relativePath already starts with /
|
|
160
|
+
const cleanPath = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
|
|
161
|
+
rawHref = `${cleanPath}/index.html`.replace(/\/\//g, '/');
|
|
141
162
|
}
|
|
142
163
|
} else {
|
|
143
|
-
|
|
164
|
+
const cleanPath = relativePath.startsWith('/') ? relativePath : '/' + relativePath;
|
|
165
|
+
rawHref = cleanPath.replace(ext, '');
|
|
144
166
|
}
|
|
145
167
|
|
|
146
168
|
// Resolve the href and check if target exists
|
|
147
169
|
const { href, inactive, debug } = resolveHref(rawHref, validPaths);
|
|
148
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
|
+
|
|
149
177
|
const menuItem = {
|
|
150
178
|
label,
|
|
151
179
|
path: folderPath,
|
|
@@ -153,7 +181,7 @@ function buildMenuData(tree, source, validPaths, parentPath = '') {
|
|
|
153
181
|
inactive,
|
|
154
182
|
debug,
|
|
155
183
|
hasChildren,
|
|
156
|
-
icon
|
|
184
|
+
icon,
|
|
157
185
|
};
|
|
158
186
|
|
|
159
187
|
if (hasChildren) {
|
|
@@ -173,9 +201,15 @@ function buildMenuData(tree, source, validPaths, parentPath = '') {
|
|
|
173
201
|
}
|
|
174
202
|
|
|
175
203
|
export async function getAutomenu(source, validPaths) {
|
|
176
|
-
const tree = dirTree(source
|
|
204
|
+
const tree = dirTree(source, {
|
|
205
|
+
exclude: /[\/\\]\.|node_modules/, // Exclude hidden folders (starting with .) and node_modules
|
|
206
|
+
});
|
|
177
207
|
const menuData = buildMenuData(tree, source, validPaths);
|
|
178
208
|
|
|
209
|
+
// Get root config for openMenuItems setting
|
|
210
|
+
const rootConfig = getRootConfig(source);
|
|
211
|
+
const openMenuItems = rootConfig?.openMenuItems || [];
|
|
212
|
+
|
|
179
213
|
// Add home item with resolved href
|
|
180
214
|
const homeResolved = resolveHref('/', validPaths);
|
|
181
215
|
const fullMenuData = [
|
|
@@ -186,6 +220,9 @@ export async function getAutomenu(source, validPaths) {
|
|
|
186
220
|
// Embed the menu data as JSON for JavaScript to use
|
|
187
221
|
const menuDataScript = `<script type="application/json" id="menu-data">${JSON.stringify(fullMenuData)}</script>`;
|
|
188
222
|
|
|
223
|
+
// Embed the openMenuItems config as separate JSON
|
|
224
|
+
const menuConfigScript = `<script type="application/json" id="menu-config">${JSON.stringify({ openMenuItems })}</script>`;
|
|
225
|
+
|
|
189
226
|
// Render the breadcrumb header (hidden by default, shown when navigating)
|
|
190
227
|
const breadcrumbHtml = `
|
|
191
228
|
<div class="menu-breadcrumb" style="display: none;">
|
|
@@ -197,7 +234,7 @@ export async function getAutomenu(source, validPaths) {
|
|
|
197
234
|
// Render the initial menu (root level)
|
|
198
235
|
const menuHtml = renderMenuLevel(fullMenuData, 0);
|
|
199
236
|
|
|
200
|
-
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>`;
|
|
201
238
|
}
|
|
202
239
|
|
|
203
240
|
function renderMenuLevel(items, level) {
|
|
@@ -205,10 +242,9 @@ function renderMenuLevel(items, level) {
|
|
|
205
242
|
const hasChildrenClass = item.hasChildren ? ' has-children' : '';
|
|
206
243
|
const hasChildrenIndicator = item.hasChildren ? '<span class="menu-more">⋯</span>' : '';
|
|
207
244
|
const inactiveClass = item.inactive ? ' inactive' : '';
|
|
208
|
-
const debugText = item.debug ? ` [DEBUG: ${item.debug}]` : '';
|
|
209
245
|
|
|
210
246
|
const labelHtml = item.href
|
|
211
|
-
? `<a href="${item.href}" class="menu-label${inactiveClass}">${item.label}
|
|
247
|
+
? `<a href="${item.href}" class="menu-label${inactiveClass}">${item.label}</a>`
|
|
212
248
|
: `<span class="menu-label">${item.label}</span>`;
|
|
213
249
|
|
|
214
250
|
return `
|
|
@@ -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,15 +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)/;
|
|
116
|
+
const hiddenOrSystemDirs = /[\/\\]\.(?!\.)|[\/\\]node_modules[\/\\]/; // Matches hidden folders (starting with .) or node_modules
|
|
106
117
|
const allSourceFilenamesThatAreArticles = allSourceFilenames.filter(
|
|
107
|
-
(filename) => filename.match(articleExtensions)
|
|
118
|
+
(filename) => filename.match(articleExtensions) && !filename.match(hiddenOrSystemDirs) && !isInHiddenFolder(filename)
|
|
108
119
|
);
|
|
109
|
-
const allSourceFilenamesThatAreDirectories = await filterAsync(
|
|
120
|
+
const allSourceFilenamesThatAreDirectories = (await filterAsync(
|
|
110
121
|
allSourceFilenames,
|
|
111
122
|
(filename) => isDirectory(filename)
|
|
112
|
-
);
|
|
123
|
+
)).filter((filename) => !filename.match(hiddenOrSystemDirs) && !isFolderHidden(filename, source));
|
|
113
124
|
|
|
114
125
|
// Build set of valid internal paths for link validation (must be before menu)
|
|
115
126
|
const validPaths = buildValidPaths(allSourceFilenamesThatAreArticles, source);
|
|
@@ -362,7 +373,7 @@ export async function generate({
|
|
|
362
373
|
);
|
|
363
374
|
|
|
364
375
|
// copy all static files (i.e. images)
|
|
365
|
-
const imageExtensions = /\.(jpg|png|gif|webp)/; //
|
|
376
|
+
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|ico)/; // static asset extensions
|
|
366
377
|
const allSourceFilenamesThatAreImages = allSourceFilenames.filter(
|
|
367
378
|
(filename) => filename.match(imageExtensions)
|
|
368
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
|