@kenjura/ursa 0.53.0 → 0.55.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.
@@ -0,0 +1,303 @@
1
+ // Custom menu support - allows defining custom menus in menu.md, menu.txt, _menu.md, or _menu.txt
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join, dirname, relative, resolve, basename } from "path";
4
+
5
+ // Menu file names to look for (in order of priority)
6
+ const MENU_FILE_NAMES = ['menu.md', 'menu.txt', '_menu.md', '_menu.txt'];
7
+
8
+ // Source file extensions to check
9
+ const SOURCE_EXTENSIONS = ['.md', '.txt'];
10
+
11
+ // Default icons
12
+ const FOLDER_ICON = '📁';
13
+ const DOCUMENT_ICON = '📄';
14
+
15
+ /**
16
+ * Check if a source file exists for a given path
17
+ * Checks for: ./Foo.md, ./Foo.txt, ./Foo/index.md, ./Foo/index.txt, ./Foo/home.md, ./Foo/home.txt, ./Foo/Foo.md, ./Foo/Foo.txt
18
+ * @param {string} basePath - The base path without extension (absolute path in source)
19
+ * @returns {boolean} - True if a source file exists
20
+ */
21
+ function sourceFileExists(basePath) {
22
+ const name = basename(basePath);
23
+
24
+ // Check direct file (./Foo.md, ./Foo.txt)
25
+ for (const ext of SOURCE_EXTENSIONS) {
26
+ if (existsSync(basePath + ext)) {
27
+ return true;
28
+ }
29
+ }
30
+
31
+ // Check as folder with index files (./Foo/index.md, ./Foo/index.txt, ./Foo/home.md, ./Foo/home.txt, ./Foo/Foo.md, ./Foo/Foo.txt)
32
+ const indexNames = ['index', 'home', name];
33
+ for (const indexName of indexNames) {
34
+ for (const ext of SOURCE_EXTENSIONS) {
35
+ if (existsSync(join(basePath, indexName + ext))) {
36
+ return true;
37
+ }
38
+ }
39
+ }
40
+
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * Find a custom menu file in the given directory or any parent directory
46
+ * @param {string} dirPath - The directory to start searching from
47
+ * @param {string} sourceRoot - The root source directory (stop searching here)
48
+ * @returns {{path: string, content: string, menuDir: string} | null} - Menu file info or null if not found
49
+ */
50
+ export function findCustomMenu(dirPath, sourceRoot) {
51
+ // Normalize paths
52
+ const normalizedDir = resolve(dirPath);
53
+ const normalizedRoot = resolve(sourceRoot);
54
+
55
+ let currentDir = normalizedDir;
56
+
57
+ // Walk up the directory tree until we reach or pass the source root
58
+ while (currentDir.startsWith(normalizedRoot)) {
59
+ for (const menuFileName of MENU_FILE_NAMES) {
60
+ const menuPath = join(currentDir, menuFileName);
61
+ if (existsSync(menuPath)) {
62
+ try {
63
+ const content = readFileSync(menuPath, 'utf8');
64
+ return {
65
+ path: menuPath,
66
+ content,
67
+ menuDir: currentDir, // The directory where the menu was found
68
+ };
69
+ } catch (e) {
70
+ console.error(`Error reading menu file ${menuPath}:`, e);
71
+ }
72
+ }
73
+ }
74
+
75
+ // Move up one directory
76
+ const parentDir = dirname(currentDir);
77
+ if (parentDir === currentDir) {
78
+ // Reached filesystem root
79
+ break;
80
+ }
81
+ currentDir = parentDir;
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Parse a custom menu file and return menu data structure
89
+ * Supports two formats:
90
+ *
91
+ * Markdown format:
92
+ * - [Label](./relative/path)
93
+ * - [Child Label](./relative/child/path)
94
+ *
95
+ * Wikitext format:
96
+ * * [[path|Label]]
97
+ * ** [[child/path|Child Label]]
98
+ * or
99
+ * * [[Label]] (path derived from label)
100
+ *
101
+ * @param {string} content - The menu file content
102
+ * @param {string} menuDir - The directory where the menu file was found
103
+ * @param {string} sourceRoot - The root source directory
104
+ * @returns {Array} - Menu data array compatible with the existing menu system
105
+ */
106
+ export function parseCustomMenu(content, menuDir, sourceRoot) {
107
+ const lines = content.split('\n');
108
+ const menuItems = [];
109
+ const stack = [{ children: menuItems, indent: -1 }]; // Stack for tracking nesting
110
+
111
+ for (const line of lines) {
112
+ // Skip empty lines
113
+ const trimmedLine = line.trim();
114
+ if (!trimmedLine) {
115
+ continue;
116
+ }
117
+
118
+ let label = null;
119
+ let href = null;
120
+ let indent = 0;
121
+
122
+ // Try wikitext format first: * [[path|Label]] or * [[Label]]
123
+ if (trimmedLine.match(/^\*+\s*\[\[/)) {
124
+ // Count asterisks for indent level
125
+ const asteriskMatch = trimmedLine.match(/^(\*+)/);
126
+ indent = asteriskMatch ? (asteriskMatch[1].length - 1) * 2 : 0; // Convert to space-equivalent
127
+
128
+ // Parse wikitext link: [[path|label]] or [[label]]
129
+ const wikiMatch = trimmedLine.match(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/);
130
+ if (wikiMatch) {
131
+ if (wikiMatch[2]) {
132
+ // [[path|label]] format - first part is path, second is label
133
+ // Special case: _home means index
134
+ const pathPart = wikiMatch[1] === '_home' ? 'index' : wikiMatch[1];
135
+ label = wikiMatch[2];
136
+ href = './' + pathPart;
137
+ } else {
138
+ // [[label]] format - path derived from label
139
+ label = wikiMatch[1];
140
+ // Special case: _home means index
141
+ href = './' + (wikiMatch[1] === '_home' ? 'index' : wikiMatch[1]);
142
+ }
143
+ }
144
+ }
145
+ // Try markdown format: - [Label](path)
146
+ else if (trimmedLine.startsWith('-')) {
147
+ // Calculate indentation level (count leading spaces/tabs before the dash)
148
+ const leadingWhitespace = line.match(/^(\s*)/)[1];
149
+ indent = leadingWhitespace.length;
150
+
151
+ // Parse the markdown link: - [Label](path)
152
+ const linkMatch = trimmedLine.match(/^-\s*\[([^\]]+)\]\(([^)]+)\)/);
153
+ if (linkMatch) {
154
+ label = linkMatch[1];
155
+ href = linkMatch[2];
156
+ }
157
+ }
158
+
159
+ // Skip if we couldn't parse
160
+ if (!label || !href) {
161
+ continue;
162
+ }
163
+
164
+ // Resolve relative paths based on where the menu file was found
165
+ // Resolve relative paths and check if source file exists
166
+ let absoluteSourcePath = null;
167
+ if (href.startsWith('./') || href.startsWith('../') || !href.startsWith('/')) {
168
+ // It's a relative path - resolve it relative to the menu directory
169
+ absoluteSourcePath = resolve(menuDir, href);
170
+
171
+ // Check if the source file exists
172
+ const fileExists = sourceFileExists(absoluteSourcePath);
173
+
174
+ if (fileExists) {
175
+ // Convert to web-accessible path (relative to source root)
176
+ href = '/' + relative(sourceRoot, absoluteSourcePath);
177
+ // Normalize path separators for web
178
+ href = href.replace(/\\/g, '/');
179
+ // Ensure it ends with .html if it doesn't have an extension
180
+ if (!href.match(/\.[a-z]+$/i)) {
181
+ // Check if it's likely a folder (ends with /) or file
182
+ if (href.endsWith('/')) {
183
+ href = href + 'index.html';
184
+ } else {
185
+ // Assume it's a file - add .html
186
+ href = href + '.html';
187
+ }
188
+ }
189
+ } else {
190
+ // Source file doesn't exist - this is a non-navigable menu item
191
+ href = null;
192
+ }
193
+ }
194
+
195
+ const menuItem = {
196
+ label,
197
+ path: label.toLowerCase().replace(/\s+/g, '-'), // Generate path from label
198
+ href,
199
+ hasChildren: false, // Will be updated if children are added
200
+ icon: `<span class="menu-icon">${href ? DOCUMENT_ICON : FOLDER_ICON}</span>`,
201
+ children: [],
202
+ };
203
+
204
+ // Find the correct parent based on indentation
205
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
206
+ stack.pop();
207
+ }
208
+
209
+ // Add this item to the current parent
210
+ const parent = stack[stack.length - 1];
211
+ parent.children.push(menuItem);
212
+
213
+ // If this is the first child, mark parent as having children
214
+ if (parent.menuItem) {
215
+ parent.menuItem.hasChildren = true;
216
+ parent.menuItem.icon = `<span class="menu-icon">${FOLDER_ICON}</span>`;
217
+ }
218
+
219
+ // Push this item onto the stack as a potential parent
220
+ stack.push({ children: menuItem.children, indent, menuItem });
221
+ }
222
+
223
+ return menuItems;
224
+ }
225
+
226
+ /**
227
+ * Get custom menu data for a given file path
228
+ * @param {string} filePath - The source file path
229
+ * @param {string} sourceRoot - The root source directory
230
+ * @returns {{menuData: Array, menuPath: string} | null} - Menu data and path, or null if no custom menu
231
+ */
232
+ export function getCustomMenuForFile(filePath, sourceRoot) {
233
+ const fileDir = dirname(filePath);
234
+ const customMenuInfo = findCustomMenu(fileDir, sourceRoot);
235
+
236
+ if (!customMenuInfo) {
237
+ return null;
238
+ }
239
+
240
+ const menuData = parseCustomMenu(customMenuInfo.content, customMenuInfo.menuDir, sourceRoot);
241
+
242
+ return {
243
+ menuData,
244
+ menuPath: customMenuInfo.path,
245
+ menuDir: customMenuInfo.menuDir,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Build menu HTML structure from custom menu data
251
+ * This matches the format expected by the existing menu.js client-side code
252
+ * @param {Array} menuData - The parsed menu data
253
+ * @returns {string} - HTML string for the menu
254
+ */
255
+ export function buildCustomMenuHtml(menuData) {
256
+ const menuConfigScript = `<script type="application/json" id="menu-config">${JSON.stringify({ openMenuItems: [], customMenu: true })}</script>`;
257
+
258
+ const breadcrumbHtml = `
259
+ <div class="menu-breadcrumb" style="display: none;">
260
+ <button class="menu-back" title="Go back">←</button>
261
+ <button class="menu-home" title="Go to root">🏠</button>
262
+ <span class="menu-current-path"></span>
263
+ </div>`;
264
+
265
+ const menuHtml = renderCustomMenuLevel(menuData);
266
+
267
+ return `${menuConfigScript}${breadcrumbHtml}<ul class="menu-level" data-level="0">${menuHtml}</ul>`;
268
+ }
269
+
270
+ /**
271
+ * Render a level of the custom menu
272
+ * @param {Array} items - Menu items at this level
273
+ * @returns {string} - HTML string
274
+ */
275
+ function renderCustomMenuLevel(items) {
276
+ return items.map(item => {
277
+ const hasChildrenClass = item.hasChildren ? ' has-children' : '';
278
+ const hasChildrenIndicator = item.hasChildren ? '<span class="menu-more">⋯</span>' : '';
279
+
280
+ const labelHtml = item.href
281
+ ? `<a href="${item.href}" class="menu-label">${item.label}</a>`
282
+ : `<span class="menu-label">${item.label}</span>`;
283
+
284
+ return `
285
+ <li class="menu-item${hasChildrenClass}" data-path="${item.path}">
286
+ <div class="menu-item-row">
287
+ ${item.icon}
288
+ ${labelHtml}
289
+ ${hasChildrenIndicator}
290
+ </div>
291
+ </li>`;
292
+ }).join('');
293
+ }
294
+
295
+ /**
296
+ * Check if a directory (or any parent) has a custom menu
297
+ * @param {string} dirPath - The directory to check
298
+ * @param {string} sourceRoot - The root source directory
299
+ * @returns {boolean} - True if a custom menu exists
300
+ */
301
+ export function hasCustomMenu(dirPath, sourceRoot) {
302
+ return findCustomMenu(dirPath, sourceRoot) !== null;
303
+ }