@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 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
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.33.0",
5
+ "version": "0.35.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
@@ -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 label = basename(item.path, ext);
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}/${label}` : label;
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: getIcon(item, source),
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
+ }
@@ -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
- return; // Skip this file
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)/; // todo: handle-extensionless images...ugh
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, { recursive: true, filter: /\.(js|json|css|html|md|txt|yml|yaml)$/ }, async (evt, name) => {
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