@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 CHANGED
@@ -1,3 +1,9 @@
1
+ # 0.34.0
2
+ 2025-12-11
3
+
4
+ - Added config.json with folder-specific settings for label, icon, and visibility
5
+ - Root-level config.json can specify open menu items
6
+
1
7
  # 0.33
2
8
  2025-12-10
3
9
 
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.34.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);
@@ -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)/; // todo: handle-extensionless images...ugh
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, { 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