@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 CHANGED
@@ -1,3 +1,14 @@
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
+
7
+ # 0.33
8
+ 2025-12-10
9
+
10
+ - Fixed the broke-ass menu
11
+
1
12
  # 0.32
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
@@ -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
@@ -2,7 +2,7 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.32.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,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 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) {
139
158
  if (hasIndexFile(item.path)) {
140
- rawHref = `/${relativePath}/index.html`;
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
- rawHref = `/${relativePath.replace(ext, '')}`;
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: getIcon(item, source),
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}${debugText}</a>`
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
+ }
@@ -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)/; // todo: handle-extensionless images...ugh
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, { 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