@kenjura/ursa 0.33.0 → 0.35.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 +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 +40 -13
- package/src/serve.js +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
# 0.35.0
|
|
2
|
+
2025-12-11
|
|
3
|
+
|
|
4
|
+
- Fixed issue where directory indices were empty
|
|
5
|
+
|
|
6
|
+
# 0.34.0
|
|
7
|
+
2025-12-11
|
|
8
|
+
|
|
9
|
+
- Added config.json with folder-specific settings for label, icon, and visibility
|
|
10
|
+
- Root-level config.json can specify open menu items
|
|
11
|
+
|
|
1
12
|
# 0.33
|
|
2
13
|
2025-12-10
|
|
3
14
|
|
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);
|
|
@@ -190,17 +200,42 @@ export async function generate({
|
|
|
190
200
|
allSourceFilenamesThatAreArticles.map(async (file) => {
|
|
191
201
|
try {
|
|
192
202
|
const rawBody = await readFile(file, "utf8");
|
|
203
|
+
const type = parse(file).ext;
|
|
204
|
+
const ext = extname(file);
|
|
205
|
+
const base = basename(file, ext);
|
|
206
|
+
const dir = addTrailingSlash(dirname(file)).replace(source, "");
|
|
207
|
+
|
|
208
|
+
// Calculate output paths for this file
|
|
209
|
+
const outputFilename = file
|
|
210
|
+
.replace(source, output)
|
|
211
|
+
.replace(parse(file).ext, ".html");
|
|
212
|
+
const url = '/' + outputFilename.replace(output, '');
|
|
193
213
|
|
|
194
214
|
// Skip files that haven't changed (unless --clean flag is set)
|
|
195
215
|
if (!_clean && !needsRegeneration(file, rawBody, hashCache)) {
|
|
196
216
|
skippedCount++;
|
|
197
|
-
|
|
217
|
+
// Still need to populate jsonCache for directory indices
|
|
218
|
+
const meta = extractMetadata(rawBody);
|
|
219
|
+
const body = renderFile({
|
|
220
|
+
fileContents: rawBody,
|
|
221
|
+
type,
|
|
222
|
+
dirname: dir,
|
|
223
|
+
basename: base,
|
|
224
|
+
});
|
|
225
|
+
jsonCache.set(file, {
|
|
226
|
+
name: base,
|
|
227
|
+
url,
|
|
228
|
+
contents: rawBody,
|
|
229
|
+
bodyHtml: body,
|
|
230
|
+
metadata: meta,
|
|
231
|
+
transformedMetadata: '',
|
|
232
|
+
});
|
|
233
|
+
return; // Skip regenerating this file
|
|
198
234
|
}
|
|
199
235
|
|
|
200
236
|
console.log(`processing article ${file}`);
|
|
201
237
|
regeneratedCount++;
|
|
202
238
|
|
|
203
|
-
const type = parse(file).ext;
|
|
204
239
|
const meta = extractMetadata(rawBody);
|
|
205
240
|
const rawMeta = extractRawMetadata(rawBody);
|
|
206
241
|
const bodyLessMeta = rawMeta ? rawBody.replace(rawMeta, "") : rawBody;
|
|
@@ -208,9 +243,6 @@ export async function generate({
|
|
|
208
243
|
dirname(file),
|
|
209
244
|
meta
|
|
210
245
|
);
|
|
211
|
-
const ext = extname(file);
|
|
212
|
-
const base = basename(file, ext);
|
|
213
|
-
const dir = addTrailingSlash(dirname(file)).replace(source, "");
|
|
214
246
|
|
|
215
247
|
// Calculate the document's URL path (e.g., "/character/index.html")
|
|
216
248
|
const docUrlPath = '/' + dir + base + '.html';
|
|
@@ -259,10 +291,6 @@ export async function generate({
|
|
|
259
291
|
// Pass docUrlPath so relative links can be resolved correctly
|
|
260
292
|
finalHtml = markInactiveLinks(finalHtml, validPaths, docUrlPath, false);
|
|
261
293
|
|
|
262
|
-
const outputFilename = file
|
|
263
|
-
.replace(source, output)
|
|
264
|
-
.replace(parse(file).ext, ".html");
|
|
265
|
-
|
|
266
294
|
console.log(`writing article to ${outputFilename}`);
|
|
267
295
|
|
|
268
296
|
await outputFile(outputFilename, finalHtml);
|
|
@@ -270,7 +298,6 @@ export async function generate({
|
|
|
270
298
|
// json
|
|
271
299
|
|
|
272
300
|
const jsonOutputFilename = outputFilename.replace(".html", ".json");
|
|
273
|
-
const url = '/' + outputFilename.replace(output, '');
|
|
274
301
|
const jsonObject = {
|
|
275
302
|
name: base,
|
|
276
303
|
url,
|
|
@@ -363,7 +390,7 @@ export async function generate({
|
|
|
363
390
|
);
|
|
364
391
|
|
|
365
392
|
// copy all static files (i.e. images)
|
|
366
|
-
const imageExtensions = /\.(jpg|png|gif|webp)/; //
|
|
393
|
+
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|ico)/; // static asset extensions
|
|
367
394
|
const allSourceFilenamesThatAreImages = allSourceFilenames.filter(
|
|
368
395
|
(filename) => filename.match(imageExtensions)
|
|
369
396
|
);
|
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
|