@kenjura/ursa 0.66.0 → 0.73.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 +57 -0
- package/bin/ursa.js +49 -0
- package/meta/character-sheet-template.html +1 -0
- package/meta/default-template.html +14 -2
- package/meta/default.css +259 -2
- package/meta/menu.js +230 -0
- package/meta/search.js +20 -5
- package/package.json +8 -1
- package/src/dev.js +944 -0
- package/src/helper/automenu.js +2 -2
- package/src/helper/build/autoIndex.js +38 -2
- package/src/helper/build/menu.js +44 -13
- package/src/helper/build/navCache.js +141 -0
- package/src/helper/build/profiler.js +211 -0
- package/src/helper/build/progress.js +61 -2
- package/src/helper/customMenu.js +357 -12
- package/src/helper/fileRenderer.js +85 -1
- package/src/helper/findScriptJs.js +26 -0
- package/src/helper/imageExtractor.js +99 -0
- package/src/helper/imageProcessor.js +215 -28
- package/src/helper/linkValidator.js +12 -12
- package/src/helper/mdxRenderer.js +175 -0
- package/src/helper/metadataExtractor.js +14 -4
- package/src/helper/parseWorker.cjs +42 -0
- package/src/helper/parserPool.js +260 -0
- package/src/helper/streamingReader.js +104 -0
- package/src/jobs/generate.js +402 -95
- package/src/serve.js +34 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
+
# 0.73.0
|
|
2
|
+
2026-02-07
|
|
3
|
+
|
|
4
|
+
- fixed build pipeline blocker
|
|
5
|
+
|
|
6
|
+
# 0.72.0
|
|
7
|
+
2026-02-07
|
|
8
|
+
|
|
9
|
+
- MDX support: Ursa can now process .mdx files with embedded JSX components
|
|
10
|
+
- MDX files are parsed and rendered to HTML with React components
|
|
11
|
+
- Custom components can be imported and used within MDX content
|
|
12
|
+
- MDX documents are fully integrated with Ursa's build and serve processes, including hot reloading in dev mode
|
|
13
|
+
- This allows for rich interactive content while still benefiting from Ursa's static site generation features
|
|
14
|
+
|
|
15
|
+
# 0.71.0
|
|
16
|
+
2026-02-05
|
|
17
|
+
|
|
18
|
+
- 'Dev mode': new mode similar to serve, but only generates documents on-demand to save time.
|
|
19
|
+
- When running `ursa dev`, the server starts immediately without a full build
|
|
20
|
+
- Documents are generated on-the-fly when requested, with caching for subsequent requests
|
|
21
|
+
- Ideal for development with large sites where full builds are slow
|
|
22
|
+
- Still supports hot reloading and file watching for dynamic updates
|
|
23
|
+
- Custom menus can now include auto-generated menus in addition to custom content
|
|
24
|
+
- Custom menus are displayed on the top bar instead of the side
|
|
25
|
+
|
|
26
|
+
# 0.70.0
|
|
27
|
+
2026-02-04
|
|
28
|
+
|
|
29
|
+
- **Navigation Cache**: Dramatically improved navigation build time
|
|
30
|
+
- Navigation structure cached in `.ursa/nav-cache.json`
|
|
31
|
+
- Cache validated by file list hash + metadata file stats (index.md, config.json)
|
|
32
|
+
- Parallel stat operations for faster cache validation
|
|
33
|
+
- Result: Navigation build drops from ~9s to ~50ms on cached runs (99% improvement)
|
|
34
|
+
|
|
35
|
+
# 0.69.0
|
|
36
|
+
2026-02-04
|
|
37
|
+
|
|
38
|
+
- **Image Processing Performance**: Dramatically improved image processing speed
|
|
39
|
+
- Persistent image cache: images are only re-processed when source file changes (mtime/size check)
|
|
40
|
+
- Parallel processing: 8 images processed concurrently instead of sequentially
|
|
41
|
+
- Smart preview skipping: images smaller than 800x800 skip preview generation (already small enough)
|
|
42
|
+
- Result: Image processing drops from ~23s to ~16ms on cached runs (99.9% improvement)
|
|
43
|
+
|
|
44
|
+
# 0.68.0
|
|
45
|
+
2026-02-04
|
|
46
|
+
|
|
47
|
+
- **Build Performance Profiling**: Added comprehensive profiling to identify performance bottlenecks
|
|
48
|
+
- Each build phase is now timed with millisecond precision
|
|
49
|
+
- Visual bar chart report shows percentage of total build time per phase
|
|
50
|
+
- Phases tracked: Scan source files, Filter & classify, Build navigation, Load cache, Copy meta files, Process images, Process articles, Write search index, Write menu data, Process directories, Process static files, Auto-index generation, Finalization
|
|
51
|
+
- Report displayed at end of each build for performance analysis
|
|
52
|
+
|
|
53
|
+
# 0.67.0
|
|
54
|
+
2026-02-04
|
|
55
|
+
|
|
56
|
+
- All images referenced in whitelisted documents should be processed and copied, even if the images themselves are not in the whitelist
|
|
57
|
+
|
|
1
58
|
# 0.66.0
|
|
2
59
|
2026-02-04
|
|
3
60
|
|
package/bin/ursa.js
CHANGED
|
@@ -168,6 +168,55 @@ yargs(hideBin(process.argv))
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
)
|
|
171
|
+
.command(
|
|
172
|
+
'dev <source>',
|
|
173
|
+
'Start dev mode - serves documents on-demand without pre-processing (fast startup)',
|
|
174
|
+
(yargs) => {
|
|
175
|
+
return yargs
|
|
176
|
+
.positional('source', {
|
|
177
|
+
describe: 'Source directory containing markdown/wikitext files',
|
|
178
|
+
type: 'string',
|
|
179
|
+
demandOption: true
|
|
180
|
+
})
|
|
181
|
+
.option('meta', {
|
|
182
|
+
alias: 'm',
|
|
183
|
+
describe: 'Meta directory containing templates and styles (defaults to ursa package meta)',
|
|
184
|
+
type: 'string'
|
|
185
|
+
})
|
|
186
|
+
.option('output', {
|
|
187
|
+
alias: 'o',
|
|
188
|
+
default: 'output',
|
|
189
|
+
describe: 'Output directory for generated files',
|
|
190
|
+
type: 'string'
|
|
191
|
+
})
|
|
192
|
+
.option('port', {
|
|
193
|
+
alias: 'p',
|
|
194
|
+
default: 8080,
|
|
195
|
+
describe: 'Port to serve on',
|
|
196
|
+
type: 'number'
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
async (argv) => {
|
|
200
|
+
const source = resolve(argv.source);
|
|
201
|
+
const meta = argv.meta ? resolve(argv.meta) : PACKAGE_META;
|
|
202
|
+
const output = resolve(argv.output);
|
|
203
|
+
const port = argv.port;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const { dev } = await import('../src/dev.js');
|
|
207
|
+
await dev({
|
|
208
|
+
_source: source,
|
|
209
|
+
_meta: meta,
|
|
210
|
+
_output: output,
|
|
211
|
+
port: port
|
|
212
|
+
});
|
|
213
|
+
} catch (error) {
|
|
214
|
+
console.error('Error starting dev mode:', error.message);
|
|
215
|
+
console.error(error);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
)
|
|
171
220
|
.command(
|
|
172
221
|
'$0 <source>',
|
|
173
222
|
'Generate a static site from source files (default command)',
|
|
@@ -12,9 +12,20 @@
|
|
|
12
12
|
<nav id="nav-global">
|
|
13
13
|
<button class="menu-button" aria-label="Menu">☰</button>
|
|
14
14
|
|
|
15
|
-
<
|
|
15
|
+
<div class="nav-center">
|
|
16
|
+
<nav id="nav-main-top">
|
|
17
|
+
<!-- Top menu will be populated by JavaScript when data-menu-position="top" -->
|
|
18
|
+
</nav>
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
<div class="search-wrapper">
|
|
21
|
+
<input id="global-search" type="text" placeholder="Search..." />
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="nav-right-controls">
|
|
26
|
+
<button class="search-button" aria-label="Search">🔍</button>
|
|
27
|
+
<span class="avatar" aria-hidden="true">👤</span>
|
|
28
|
+
</div>
|
|
18
29
|
</nav>
|
|
19
30
|
<nav id="nav-main">
|
|
20
31
|
${menu}
|
|
@@ -37,6 +48,7 @@
|
|
|
37
48
|
<script src="/public/search.js"></script>
|
|
38
49
|
<script src="/public/sectionify.js"></script>
|
|
39
50
|
<script src="/public/sticky.js"></script>
|
|
51
|
+
${customScript}
|
|
40
52
|
</body>
|
|
41
53
|
|
|
42
54
|
</html>
|
package/meta/default.css
CHANGED
|
@@ -74,11 +74,17 @@ nav#nav-global {
|
|
|
74
74
|
opacity: 0.7;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
/* Center container for search and top menu */
|
|
78
|
+
.nav-center {
|
|
79
79
|
width: var(--article-width);
|
|
80
80
|
max-width: calc(100% - 16px);
|
|
81
81
|
justify-self: center;
|
|
82
|
+
position: relative;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.search-wrapper {
|
|
86
|
+
position: relative;
|
|
87
|
+
width: 100%;
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
input#global-search {
|
|
@@ -120,6 +126,10 @@ nav#nav-global {
|
|
|
120
126
|
font-size: 1.5rem;
|
|
121
127
|
line-height: 1;
|
|
122
128
|
padding: 8px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* When avatar is standalone (not in container), align to end */
|
|
132
|
+
> .avatar {
|
|
123
133
|
justify-self: end;
|
|
124
134
|
}
|
|
125
135
|
}
|
|
@@ -219,6 +229,253 @@ nav#nav-global {
|
|
|
219
229
|
text-decoration: underline;
|
|
220
230
|
}
|
|
221
231
|
|
|
232
|
+
/* ==========================================
|
|
233
|
+
TOP NAVIGATION MENU STYLES
|
|
234
|
+
When body[data-menu-position="top"] is set
|
|
235
|
+
========================================== */
|
|
236
|
+
|
|
237
|
+
/* Top menu container - positioning handled by parent .nav-center */
|
|
238
|
+
nav#nav-main-top {
|
|
239
|
+
display: none;
|
|
240
|
+
width: 100%;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* Show top menu when position is top */
|
|
244
|
+
body[data-menu-position="top"] nav#nav-main-top {
|
|
245
|
+
display: flex;
|
|
246
|
+
align-items: center;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* Hide search input when top menu is present - it moves to a button */
|
|
250
|
+
body[data-menu-position="top"] nav#nav-global input#global-search {
|
|
251
|
+
display: none;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* Show search button instead when top menu is active */
|
|
255
|
+
body[data-menu-position="top"] nav#nav-global .search-button {
|
|
256
|
+
display: inline-flex;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/* Hide side nav when top menu is active */
|
|
260
|
+
body[data-menu-position="top"] nav#nav-main {
|
|
261
|
+
display: none;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* Full-width article when side nav is hidden */
|
|
265
|
+
body[data-menu-position="top"] article#main-content {
|
|
266
|
+
margin-left: auto;
|
|
267
|
+
margin-right: auto;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* Update nav-global grid to accommodate top menu - keep search+avatar together on right */
|
|
271
|
+
body[data-menu-position="top"] nav#nav-global {
|
|
272
|
+
grid-template-columns: auto 1fr auto;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* Top-level menu list */
|
|
276
|
+
nav#nav-main-top .top-menu-level {
|
|
277
|
+
display: flex;
|
|
278
|
+
list-style: none;
|
|
279
|
+
margin: 0;
|
|
280
|
+
padding: 0;
|
|
281
|
+
gap: 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* Top-level menu items */
|
|
285
|
+
nav#nav-main-top .top-menu-item {
|
|
286
|
+
position: relative;
|
|
287
|
+
padding: 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
nav#nav-main-top .top-menu-label {
|
|
291
|
+
display: block;
|
|
292
|
+
padding: 8px 16px;
|
|
293
|
+
color: var(--text-color);
|
|
294
|
+
text-decoration: none;
|
|
295
|
+
white-space: nowrap;
|
|
296
|
+
cursor: pointer;
|
|
297
|
+
transition: background-color 0.15s ease;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
nav#nav-main-top .top-menu-label:hover {
|
|
301
|
+
background-color: rgba(255, 255, 255, 0.1);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
nav#nav-main-top a.top-menu-label:hover {
|
|
305
|
+
text-decoration: none;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* Dropdown indicator for items with children */
|
|
309
|
+
nav#nav-main-top .top-menu-item.has-dropdown > .top-menu-label::after {
|
|
310
|
+
content: '▼';
|
|
311
|
+
font-size: 0.4em;
|
|
312
|
+
margin-left: 6px;
|
|
313
|
+
opacity: 0.6;
|
|
314
|
+
position: relative;
|
|
315
|
+
top: -1px;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/* Dropdown menu (first level of children) */
|
|
319
|
+
nav#nav-main-top .top-menu-dropdown {
|
|
320
|
+
display: none;
|
|
321
|
+
position: absolute;
|
|
322
|
+
top: 100%;
|
|
323
|
+
left: 0;
|
|
324
|
+
min-width: 200px;
|
|
325
|
+
background-color: var(--bg-color);
|
|
326
|
+
border: 1px solid var(--nav-top-bg);
|
|
327
|
+
border-radius: 4px;
|
|
328
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
329
|
+
list-style: none;
|
|
330
|
+
margin: 0;
|
|
331
|
+
padding: 4px 0;
|
|
332
|
+
z-index: 1005;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/* Show dropdown on hover */
|
|
336
|
+
nav#nav-main-top .top-menu-item.has-dropdown:hover > .top-menu-dropdown {
|
|
337
|
+
display: block;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* Dropdown items */
|
|
341
|
+
nav#nav-main-top .dropdown-item {
|
|
342
|
+
position: relative;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
nav#nav-main-top .dropdown-label {
|
|
346
|
+
display: flex;
|
|
347
|
+
align-items: center;
|
|
348
|
+
justify-content: space-between;
|
|
349
|
+
padding: 8px 16px;
|
|
350
|
+
color: var(--text-color);
|
|
351
|
+
text-decoration: none;
|
|
352
|
+
white-space: nowrap;
|
|
353
|
+
cursor: pointer;
|
|
354
|
+
transition: background-color 0.15s ease;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
nav#nav-main-top .dropdown-label:hover {
|
|
358
|
+
background-color: var(--nav-top-bg);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
nav#nav-main-top a.dropdown-label:hover {
|
|
362
|
+
text-decoration: none;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/* Flyout indicator */
|
|
366
|
+
nav#nav-main-top .flyout-indicator {
|
|
367
|
+
font-size: 0.7em;
|
|
368
|
+
opacity: 0.6;
|
|
369
|
+
margin-left: 8px;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* Flyout menu (nested children) */
|
|
373
|
+
nav#nav-main-top .top-menu-flyout {
|
|
374
|
+
display: none;
|
|
375
|
+
position: absolute;
|
|
376
|
+
left: 100%;
|
|
377
|
+
top: 0;
|
|
378
|
+
min-width: 200px;
|
|
379
|
+
background-color: var(--bg-color);
|
|
380
|
+
border: 1px solid var(--nav-top-bg);
|
|
381
|
+
border-radius: 4px;
|
|
382
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
383
|
+
list-style: none;
|
|
384
|
+
margin: 0;
|
|
385
|
+
padding: 4px 0;
|
|
386
|
+
z-index: 1006;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/* Show flyout on hover */
|
|
390
|
+
nav#nav-main-top .dropdown-item.has-flyout:hover > .top-menu-flyout {
|
|
391
|
+
display: block;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/* Search button and avatar container (shown when top menu is active) */
|
|
395
|
+
nav#nav-global .nav-right-controls {
|
|
396
|
+
display: flex;
|
|
397
|
+
align-items: center;
|
|
398
|
+
gap: 4px;
|
|
399
|
+
justify-self: end;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
nav#nav-global .search-button {
|
|
403
|
+
display: none;
|
|
404
|
+
align-items: center;
|
|
405
|
+
justify-content: center;
|
|
406
|
+
background: none;
|
|
407
|
+
border: none;
|
|
408
|
+
color: var(--text-color);
|
|
409
|
+
font-size: 1.25rem;
|
|
410
|
+
padding: 8px 12px;
|
|
411
|
+
cursor: pointer;
|
|
412
|
+
opacity: 0.8;
|
|
413
|
+
transition: opacity 0.2s ease;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
nav#nav-global .search-button:hover {
|
|
417
|
+
opacity: 1;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* Search backdrop - just the dark overlay */
|
|
421
|
+
body[data-menu-position="top"] .search-backdrop {
|
|
422
|
+
display: none;
|
|
423
|
+
position: fixed;
|
|
424
|
+
top: 0;
|
|
425
|
+
left: 0;
|
|
426
|
+
right: 0;
|
|
427
|
+
bottom: 0;
|
|
428
|
+
background: rgba(0, 0, 0, 0.7);
|
|
429
|
+
z-index: 1010;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
body[data-menu-position="top"] .search-backdrop.active {
|
|
433
|
+
display: block;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* Floating search container - positioned above nav */
|
|
437
|
+
body[data-menu-position="top"] .search-floating {
|
|
438
|
+
display: none;
|
|
439
|
+
position: fixed;
|
|
440
|
+
top: 80px;
|
|
441
|
+
left: 50%;
|
|
442
|
+
transform: translateX(-50%);
|
|
443
|
+
width: min(600px, calc(100vw - 32px));
|
|
444
|
+
z-index: 1020;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
body[data-menu-position="top"] .search-floating.active {
|
|
448
|
+
display: block;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/* Style the search wrapper when inside floating container */
|
|
452
|
+
body[data-menu-position="top"] .search-floating .search-wrapper {
|
|
453
|
+
width: 100%;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
body[data-menu-position="top"] .search-floating #global-search {
|
|
457
|
+
width: 100%;
|
|
458
|
+
height: 48px;
|
|
459
|
+
font-size: 1.25rem;
|
|
460
|
+
padding: 0 16px;
|
|
461
|
+
border: 2px solid var(--nav-top-bg);
|
|
462
|
+
border-radius: 8px;
|
|
463
|
+
background: var(--bg-color);
|
|
464
|
+
color: var(--text-color);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/* Search results inside floating container */
|
|
468
|
+
body[data-menu-position="top"] .search-floating .search-results {
|
|
469
|
+
position: relative;
|
|
470
|
+
top: 4px;
|
|
471
|
+
max-height: 60vh;
|
|
472
|
+
overflow-y: auto;
|
|
473
|
+
background: var(--bg-color);
|
|
474
|
+
border: 1px solid var(--border-color);
|
|
475
|
+
border-radius: 8px;
|
|
476
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
477
|
+
}
|
|
478
|
+
|
|
222
479
|
nav#nav-main {
|
|
223
480
|
position: fixed;
|
|
224
481
|
top: calc(var(--global-nav-height));
|
package/meta/menu.js
CHANGED
|
@@ -3,9 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Displays 2 columns at a time, with horizontal scrolling to navigate deeper levels.
|
|
5
5
|
* Each column represents one level of the folder hierarchy.
|
|
6
|
+
*
|
|
7
|
+
* Also supports top menu position for horizontal navigation with dropdowns.
|
|
6
8
|
*/
|
|
7
9
|
document.addEventListener('DOMContentLoaded', () => {
|
|
8
10
|
const navMain = document.querySelector('nav#nav-main');
|
|
11
|
+
const navMainTop = document.querySelector('nav#nav-main-top');
|
|
12
|
+
const menuPosition = document.body.dataset.menuPosition || 'side';
|
|
13
|
+
|
|
14
|
+
// If menu position is top, handle differently
|
|
15
|
+
if (menuPosition === 'top') {
|
|
16
|
+
initTopMenu();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
if (!navMain) return;
|
|
10
21
|
|
|
11
22
|
// Check for custom menu
|
|
@@ -527,3 +538,222 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
527
538
|
// Initial render (shows loading, then loadMenuData will call initializeFromCurrentPage)
|
|
528
539
|
renderMenu();
|
|
529
540
|
});
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Initialize top menu (horizontal navigation with dropdowns)
|
|
544
|
+
*/
|
|
545
|
+
function initTopMenu() {
|
|
546
|
+
const navMainTop = document.querySelector('nav#nav-main-top');
|
|
547
|
+
const customMenuPath = document.body.dataset.customMenu;
|
|
548
|
+
|
|
549
|
+
if (!navMainTop || !customMenuPath) return;
|
|
550
|
+
|
|
551
|
+
// Load menu data and render top menu
|
|
552
|
+
fetch(customMenuPath)
|
|
553
|
+
.then(response => response.json())
|
|
554
|
+
.then(data => {
|
|
555
|
+
// Handle new JSON format with menuData and menuPosition
|
|
556
|
+
const menuData = data.menuData || data;
|
|
557
|
+
renderTopMenu(navMainTop, menuData);
|
|
558
|
+
})
|
|
559
|
+
.catch(error => {
|
|
560
|
+
console.error('Failed to load top menu data:', error);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Set up search button toggle
|
|
564
|
+
initTopMenuSearch();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Render the top navigation menu
|
|
569
|
+
*/
|
|
570
|
+
function renderTopMenu(container, menuData) {
|
|
571
|
+
const ul = document.createElement('ul');
|
|
572
|
+
ul.className = 'top-menu-level';
|
|
573
|
+
|
|
574
|
+
menuData.forEach(item => {
|
|
575
|
+
const li = document.createElement('li');
|
|
576
|
+
li.className = 'top-menu-item';
|
|
577
|
+
if (item.hasChildren || (item.children && item.children.length > 0)) {
|
|
578
|
+
li.classList.add('has-dropdown');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Create label (link or span)
|
|
582
|
+
const label = item.href
|
|
583
|
+
? document.createElement('a')
|
|
584
|
+
: document.createElement('span');
|
|
585
|
+
label.className = 'top-menu-label';
|
|
586
|
+
label.textContent = item.label;
|
|
587
|
+
if (item.href) {
|
|
588
|
+
label.href = item.href;
|
|
589
|
+
}
|
|
590
|
+
li.appendChild(label);
|
|
591
|
+
|
|
592
|
+
// Create dropdown if has children
|
|
593
|
+
if (item.children && item.children.length > 0) {
|
|
594
|
+
const dropdown = createTopMenuDropdown(item.children);
|
|
595
|
+
li.appendChild(dropdown);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
ul.appendChild(li);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
container.appendChild(ul);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Create a dropdown menu for top navigation
|
|
606
|
+
*/
|
|
607
|
+
function createTopMenuDropdown(items) {
|
|
608
|
+
const ul = document.createElement('ul');
|
|
609
|
+
ul.className = 'top-menu-dropdown';
|
|
610
|
+
|
|
611
|
+
items.forEach(item => {
|
|
612
|
+
const li = document.createElement('li');
|
|
613
|
+
li.className = 'dropdown-item';
|
|
614
|
+
if (item.hasChildren || (item.children && item.children.length > 0)) {
|
|
615
|
+
li.classList.add('has-flyout');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Create label
|
|
619
|
+
const label = item.href
|
|
620
|
+
? document.createElement('a')
|
|
621
|
+
: document.createElement('span');
|
|
622
|
+
label.className = 'dropdown-label';
|
|
623
|
+
label.textContent = item.label;
|
|
624
|
+
if (item.href) {
|
|
625
|
+
label.href = item.href;
|
|
626
|
+
}
|
|
627
|
+
li.appendChild(label);
|
|
628
|
+
|
|
629
|
+
// Add flyout indicator if has children
|
|
630
|
+
if (item.children && item.children.length > 0) {
|
|
631
|
+
const indicator = document.createElement('span');
|
|
632
|
+
indicator.className = 'flyout-indicator';
|
|
633
|
+
indicator.textContent = '▶';
|
|
634
|
+
label.appendChild(indicator);
|
|
635
|
+
|
|
636
|
+
// Create flyout submenu
|
|
637
|
+
const flyout = createTopMenuFlyout(item.children);
|
|
638
|
+
li.appendChild(flyout);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
ul.appendChild(li);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
return ul;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Create a flyout submenu for nested items
|
|
649
|
+
*/
|
|
650
|
+
function createTopMenuFlyout(items) {
|
|
651
|
+
const ul = document.createElement('ul');
|
|
652
|
+
ul.className = 'top-menu-flyout';
|
|
653
|
+
|
|
654
|
+
items.forEach(item => {
|
|
655
|
+
const li = document.createElement('li');
|
|
656
|
+
li.className = 'dropdown-item';
|
|
657
|
+
if (item.hasChildren || (item.children && item.children.length > 0)) {
|
|
658
|
+
li.classList.add('has-flyout');
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const label = item.href
|
|
662
|
+
? document.createElement('a')
|
|
663
|
+
: document.createElement('span');
|
|
664
|
+
label.className = 'dropdown-label';
|
|
665
|
+
label.textContent = item.label;
|
|
666
|
+
if (item.href) {
|
|
667
|
+
label.href = item.href;
|
|
668
|
+
}
|
|
669
|
+
li.appendChild(label);
|
|
670
|
+
|
|
671
|
+
// Recursive flyout for deeper levels
|
|
672
|
+
if (item.children && item.children.length > 0) {
|
|
673
|
+
const indicator = document.createElement('span');
|
|
674
|
+
indicator.className = 'flyout-indicator';
|
|
675
|
+
indicator.textContent = '▶';
|
|
676
|
+
label.appendChild(indicator);
|
|
677
|
+
|
|
678
|
+
const flyout = createTopMenuFlyout(item.children);
|
|
679
|
+
li.appendChild(flyout);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
ul.appendChild(li);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
return ul;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Initialize search functionality for top menu mode
|
|
690
|
+
*/
|
|
691
|
+
function initTopMenuSearch() {
|
|
692
|
+
const searchButton = document.querySelector('nav#nav-global .search-button');
|
|
693
|
+
const searchInput = document.querySelector('#global-search');
|
|
694
|
+
const searchWrapper = searchInput?.closest('.search-wrapper');
|
|
695
|
+
const searchResults = document.querySelector('#search-results');
|
|
696
|
+
|
|
697
|
+
if (!searchButton || !searchInput || !searchWrapper) return;
|
|
698
|
+
|
|
699
|
+
// Create search overlay backdrop (just the dark background)
|
|
700
|
+
const backdrop = document.createElement('div');
|
|
701
|
+
backdrop.className = 'search-backdrop';
|
|
702
|
+
document.body.appendChild(backdrop);
|
|
703
|
+
|
|
704
|
+
// Create container for search that floats above the nav
|
|
705
|
+
const floatingSearch = document.createElement('div');
|
|
706
|
+
floatingSearch.className = 'search-floating';
|
|
707
|
+
document.body.appendChild(floatingSearch);
|
|
708
|
+
|
|
709
|
+
// Toggle search on button click
|
|
710
|
+
searchButton.addEventListener('click', () => {
|
|
711
|
+
backdrop.classList.add('active');
|
|
712
|
+
floatingSearch.classList.add('active');
|
|
713
|
+
|
|
714
|
+
// Move the search wrapper into the floating container
|
|
715
|
+
floatingSearch.appendChild(searchWrapper);
|
|
716
|
+
|
|
717
|
+
// Move search results into floating container too (if exists)
|
|
718
|
+
if (searchResults) {
|
|
719
|
+
floatingSearch.appendChild(searchResults);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
searchInput.value = '';
|
|
723
|
+
searchInput.focus();
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
function closeSearch() {
|
|
727
|
+
backdrop.classList.remove('active');
|
|
728
|
+
floatingSearch.classList.remove('active');
|
|
729
|
+
|
|
730
|
+
// Move search wrapper back to nav-center
|
|
731
|
+
const navCenter = document.querySelector('.nav-center');
|
|
732
|
+
if (navCenter && searchWrapper) {
|
|
733
|
+
navCenter.appendChild(searchWrapper);
|
|
734
|
+
}
|
|
735
|
+
// Move search results back too
|
|
736
|
+
if (searchResults && searchWrapper) {
|
|
737
|
+
searchWrapper.parentNode.appendChild(searchResults);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Close on backdrop click
|
|
742
|
+
backdrop.addEventListener('click', closeSearch);
|
|
743
|
+
|
|
744
|
+
// Close on escape
|
|
745
|
+
document.addEventListener('keydown', (e) => {
|
|
746
|
+
if (e.key === 'Escape' && backdrop.classList.contains('active')) {
|
|
747
|
+
closeSearch();
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// Close when a search result is clicked (navigation will happen)
|
|
752
|
+
if (searchResults) {
|
|
753
|
+
searchResults.addEventListener('click', (e) => {
|
|
754
|
+
if (e.target.closest('.search-result-item')) {
|
|
755
|
+
closeSearch();
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|