@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 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)',
@@ -73,6 +73,7 @@
73
73
  });
74
74
  }
75
75
  </script>
76
+ ${customScript}
76
77
  </body>
77
78
 
78
79
  </html>
@@ -12,9 +12,20 @@
12
12
  <nav id="nav-global">
13
13
  <button class="menu-button" aria-label="Menu">☰</button>
14
14
 
15
- <input id="global-search" type="text" placeholder="Search..." />
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
- <span class="avatar" aria-hidden="true">👤</span>
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
- .search-wrapper {
78
- position: relative;
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
+ }