@kenjura/ursa 0.76.0 → 0.77.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +35 -17
  2. package/meta/default.css +33 -0
  3. package/meta/templates/default-template/default.css +1268 -0
  4. package/meta/{default-template.html → templates/default-template/index.html} +15 -0
  5. package/meta/{menu.js → templates/default-template/menu.js} +1 -1
  6. package/meta/templates/default-template/sectionify.js +46 -0
  7. package/meta/{widgets.js → templates/default-template/widgets.js} +126 -0
  8. package/package.json +1 -1
  9. package/src/dev.js +73 -28
  10. package/src/helper/assetBundler.js +471 -0
  11. package/src/helper/build/autoIndex.js +24 -23
  12. package/src/helper/build/cacheBust.js +79 -0
  13. package/src/helper/build/navCache.js +4 -0
  14. package/src/helper/build/templates.js +176 -19
  15. package/src/helper/build/watchCache.js +7 -0
  16. package/src/helper/customMenu.js +4 -2
  17. package/src/helper/dependencyTracker.js +269 -0
  18. package/src/helper/findStyleCss.js +29 -0
  19. package/src/helper/portUtils.js +132 -0
  20. package/src/jobs/generate.js +228 -60
  21. package/src/serve.js +446 -162
  22. package/meta/character-sheet.css +0 -50
  23. /package/meta/{goudy_bookletter_1911-webfont.woff → shared/goudy_bookletter_1911-webfont.woff} +0 -0
  24. /package/meta/{character-sheet/css → templates/character-sheet-template}/character-sheet.css +0 -0
  25. /package/meta/{character-sheet/js → templates/character-sheet-template}/components.js +0 -0
  26. /package/meta/{cssui.bundle.min.css → templates/character-sheet-template/cssui.bundle.min.css} +0 -0
  27. /package/meta/{character-sheet-template.html → templates/character-sheet-template/index.html} +0 -0
  28. /package/meta/{character-sheet/js → templates/character-sheet-template}/main.js +0 -0
  29. /package/meta/{character-sheet/js → templates/character-sheet-template}/model.js +0 -0
  30. /package/meta/{search.js → templates/default-template/search.js} +0 -0
  31. /package/meta/{sticky.js → templates/default-template/sticky.js} +0 -0
  32. /package/meta/{toc-generator.js → templates/default-template/toc-generator.js} +0 -0
  33. /package/meta/{toc.js → templates/default-template/toc.js} +0 -0
  34. /package/meta/{template2.html → templates/template2/index.html} +0 -0
@@ -16,6 +16,12 @@
16
16
  <button class="widget-button" data-widget="recent-activity" data-widget-side="left" aria-label="Recent Activity" title="Recent Activity">
17
17
  <span class="widget-icon">🕒</span>
18
18
  </button>
19
+ <button class="widget-button" data-widget="suggested" data-widget-side="left" aria-label="Suggested Content" title="Suggested Content">
20
+ <span class="widget-icon">⭐</span>
21
+ </button>
22
+ </div>
23
+ <div id="ursa-update-indicator" class="ursa-update-indicator" style="display:none;" title="Updating...">
24
+ <div class="ursa-spinner"></div>
19
25
  </div>
20
26
  </div>
21
27
 
@@ -55,6 +61,15 @@
55
61
  <!-- Populated by JavaScript -->
56
62
  </div>
57
63
  </div>
64
+ <div id="widget-content-suggested" class="widget-content" data-widget="suggested">
65
+ <div class="widget-header">
66
+ <span class="widget-header-title">Suggested</span>
67
+ <button class="widget-close-btn" aria-label="Close">✕</button>
68
+ </div>
69
+ <div class="suggested-content-list">
70
+ <!-- Populated by JavaScript -->
71
+ </div>
72
+ </div>
58
73
  </div>
59
74
 
60
75
  <!-- Widget dropdown panel for RIGHT-side widgets -->
@@ -11,7 +11,7 @@ document.addEventListener('DOMContentLoaded', () => {
11
11
  const navMain = document.querySelector('nav#nav-main');
12
12
  const navMainTop = document.querySelector('nav#nav-main-top');
13
13
  const menuPosition = document.body.dataset.menuPosition || 'top';
14
-
14
+
15
15
  // If menu position is top (default), handle differently
16
16
  if (menuPosition === 'top') {
17
17
  initTopMenu();
@@ -0,0 +1,46 @@
1
+ document.addEventListener('DOMContentLoaded', () => {
2
+ const article = document.querySelector('article#main-content');
3
+ if (!article) return;
4
+
5
+ const children = Array.from(article.children);
6
+ let sections = [];
7
+ let currentSection = document.createElement('section');
8
+ currentSection.classList.add('sectionOuter');
9
+
10
+ for (let i = 0; i < children.length; i++) {
11
+ const el = children[i];
12
+ // Skip breadcrumb nav — it stays outside sections
13
+ if (el.classList && el.classList.contains('breadcrumbs')) continue;
14
+ if (el.tagName === 'H1' && currentSection.childNodes.length > 0) {
15
+ sections.push(currentSection);
16
+ currentSection = document.createElement('section');
17
+ currentSection.classList.add('sectionOuter');
18
+ }
19
+ currentSection.appendChild(el);
20
+ }
21
+ if (currentSection.childNodes.length > 0) {
22
+ sections.push(currentSection);
23
+ }
24
+
25
+ // Preserve breadcrumb nav before clearing
26
+ const breadcrumbs = article.querySelector('.breadcrumbs');
27
+
28
+ // Remove all existing children
29
+ while (article.firstChild) {
30
+ article.removeChild(article.firstChild);
31
+ }
32
+
33
+ // Re-insert breadcrumbs at the top, outside any section
34
+ if (breadcrumbs) {
35
+ article.appendChild(breadcrumbs);
36
+ }
37
+
38
+ // Append new sections
39
+ sections.forEach(section => article.appendChild(section));
40
+
41
+
42
+ // Optional: Add section numbers or other decorations
43
+ Array.from(article.querySelectorAll('section.sectionOuter')).forEach((section, index) => {
44
+ section.style.setProperty('--section-index', index + 1);
45
+ });
46
+ });
@@ -111,6 +111,10 @@ class WidgetManager {
111
111
 
112
112
  // Initialize recent activity widget
113
113
  this.initRecentActivityWidget();
114
+
115
+ // Track current page view and initialize suggested content widget
116
+ this.trackPageView();
117
+ this.initSuggestedWidget();
114
118
 
115
119
  // Restore saved widget states from localStorage
116
120
  this.restoreState();
@@ -567,6 +571,128 @@ class WidgetManager {
567
571
  if (months < 12) return `${months}mo ago`;
568
572
  return `${years}y ago`;
569
573
  }
574
+
575
+ /**
576
+ * Track current page view in localStorage.
577
+ * Stores a map of URL → { count, lastVisit, title }
578
+ */
579
+ trackPageView() {
580
+ const url = window.location.pathname;
581
+ // Skip tracking for index/home pages to keep suggestions more focused
582
+ if (url === '/' || url === '/index.html') return;
583
+
584
+ const STORAGE_KEY = 'ursa-page-views';
585
+ const MAX_TRACKED_PAGES = 100; // Limit storage size
586
+
587
+ try {
588
+ let pageViews = {};
589
+ const stored = localStorage.getItem(STORAGE_KEY);
590
+ if (stored) {
591
+ pageViews = JSON.parse(stored);
592
+ }
593
+
594
+ // Get page title from the document
595
+ const title = document.title || url;
596
+
597
+ // Update or create entry for this page
598
+ if (pageViews[url]) {
599
+ pageViews[url].count += 1;
600
+ pageViews[url].lastVisit = Date.now();
601
+ pageViews[url].title = title;
602
+ } else {
603
+ pageViews[url] = {
604
+ count: 1,
605
+ lastVisit: Date.now(),
606
+ title: title
607
+ };
608
+ }
609
+
610
+ // Prune oldest entries if we exceed the limit
611
+ const entries = Object.entries(pageViews);
612
+ if (entries.length > MAX_TRACKED_PAGES) {
613
+ // Sort by lastVisit and keep only the most recent
614
+ entries.sort((a, b) => b[1].lastVisit - a[1].lastVisit);
615
+ pageViews = Object.fromEntries(entries.slice(0, MAX_TRACKED_PAGES));
616
+ }
617
+
618
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(pageViews));
619
+ } catch (e) {
620
+ // localStorage not available or quota exceeded
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Initialize the Suggested Content widget.
626
+ * Shows frequently viewed pages based on localStorage tracking.
627
+ */
628
+ initSuggestedWidget() {
629
+ const container = document.querySelector('.suggested-content-list');
630
+ if (!container) return;
631
+
632
+ const STORAGE_KEY = 'ursa-page-views';
633
+ const MAX_SUGGESTIONS = 10;
634
+ const currentUrl = window.location.pathname;
635
+
636
+ try {
637
+ const stored = localStorage.getItem(STORAGE_KEY);
638
+ if (!stored) {
639
+ container.innerHTML = '<div class="suggested-empty">Visit more pages to see suggestions</div>';
640
+ return;
641
+ }
642
+
643
+ const pageViews = JSON.parse(stored);
644
+ const entries = Object.entries(pageViews);
645
+
646
+ if (entries.length === 0) {
647
+ container.innerHTML = '<div class="suggested-empty">Visit more pages to see suggestions</div>';
648
+ return;
649
+ }
650
+
651
+ // Filter out current page and sort by view count (descending)
652
+ const sorted = entries
653
+ .filter(([url]) => url !== currentUrl)
654
+ .sort((a, b) => {
655
+ // Primary sort: view count (descending)
656
+ const countDiff = b[1].count - a[1].count;
657
+ if (countDiff !== 0) return countDiff;
658
+ // Secondary sort: last visit (descending)
659
+ return b[1].lastVisit - a[1].lastVisit;
660
+ })
661
+ .slice(0, MAX_SUGGESTIONS);
662
+
663
+ if (sorted.length === 0) {
664
+ container.innerHTML = '<div class="suggested-empty">Visit more pages to see suggestions</div>';
665
+ return;
666
+ }
667
+
668
+ container.innerHTML = '';
669
+ const ul = document.createElement('ul');
670
+ ul.className = 'suggested-items';
671
+
672
+ for (const [url, data] of sorted) {
673
+ const li = document.createElement('li');
674
+ li.className = 'suggested-item';
675
+
676
+ const a = document.createElement('a');
677
+ a.href = url;
678
+ a.className = 'suggested-link';
679
+ a.textContent = data.title || url;
680
+
681
+ const meta = document.createElement('span');
682
+ meta.className = 'suggested-meta';
683
+ meta.textContent = `${data.count} view${data.count !== 1 ? 's' : ''}`;
684
+ meta.title = `Last visited: ${new Date(data.lastVisit).toLocaleString()}`;
685
+
686
+ li.appendChild(a);
687
+ li.appendChild(meta);
688
+ ul.appendChild(li);
689
+ }
690
+
691
+ container.appendChild(ul);
692
+ } catch (e) {
693
+ container.innerHTML = '<div class="suggested-empty">Unable to load suggestions</div>';
694
+ }
695
+ }
570
696
  }
571
697
 
572
698
  // Initialize widgets when DOM is ready
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.76.0",
5
+ "version": "0.77.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
8
  "bin": {
package/src/dev.js CHANGED
@@ -33,9 +33,12 @@ import { extractImageReferences } from "./helper/imageExtractor.js";
33
33
  import { recurse } from "./helper/recursive-readdir.js";
34
34
  import { isFolderHidden, clearConfigCache } from "./helper/folderConfig.js";
35
35
  import { extractSections } from "./helper/sectionExtractor.js";
36
- import { getTemplates, getMenu, findAllCustomMenus, getCustomMenuForFile, getTransformedMetadata, getFooter, toTitleCase, addTrailingSlash, generateAutoIndexHtmlFromSource } from "./helper/build/index.js";
36
+ import { getTemplates, getMenu, findAllCustomMenus, getCustomMenuForFile, getTransformedMetadata, getFooter, toTitleCase, addTrailingSlash, generateAutoIndexHtmlFromSource, copyMetaAssets } from "./helper/build/index.js";
37
37
  import { findCustomMenu, extractMenuFrontmatter, parseCustomMenu, combineAutoAndManualMenu } from "./helper/customMenu.js";
38
38
  import { getAndIncrementBuildId } from "./helper/ursaConfig.js";
39
+ import { resolvePort } from "./helper/portUtils.js";
40
+ import { findAllStyleCss } from "./helper/findStyleCss.js";
41
+ import { bundleMetaTemplateAssets, generateSeparateCssTags, generateSeparateJsTags, clearMetaBundleCache } from "./helper/assetBundler.js";
39
42
 
40
43
  // Dev mode state
41
44
  const devState = {
@@ -266,6 +269,23 @@ function findNearestMenu(dirPath) {
266
269
  return null;
267
270
  }
268
271
 
272
+ /**
273
+ * Find all style.css files from docroot to dirPath (all levels)
274
+ */
275
+ async function findAllStyles(dirPath) {
276
+ const { stylePathMap, source } = devState;
277
+
278
+ // Use a separate cache key to avoid collision with findNearestStyle
279
+ const cacheKey = `all:${dirPath}`;
280
+ if (stylePathMap.has(cacheKey)) {
281
+ return stylePathMap.get(cacheKey);
282
+ }
283
+
284
+ const stylePaths = await findAllStyleCss(dirPath, source);
285
+ stylePathMap.set(cacheKey, stylePaths);
286
+ return stylePaths;
287
+ }
288
+
269
289
  /**
270
290
  * Find the nearest style.css
271
291
  */
@@ -440,35 +460,40 @@ async function wrapInTemplate(body, title, fileMeta, urlPath, sourcePath) {
440
460
  throw new Error(`Template not found: ${requestedTemplateName || 'default-template'}`);
441
461
  }
442
462
 
443
- // Find nearest style.css
463
+ // Find all style.css files from docroot to current dir and serve as separate tags
464
+ // (Serve/dev mode: separate tags per level for individual invalidation)
444
465
  let styleLink = "";
445
466
  try {
446
467
  const styleDir = sourcePath ? dirname(sourcePath) : source;
447
- const cssPath = await findNearestStyle(styleDir);
448
- if (cssPath) {
449
- const cssUrlPath = '/' + cssPath.replace(source, '');
450
- styleLink = `<link rel="stylesheet" href="${cssUrlPath}" />`;
451
-
452
- // Copy CSS file to output
453
- const cssOutputPath = cssPath.replace(source, output);
454
- const cssContent = await readFile(cssPath, 'utf8');
455
- await outputFile(cssOutputPath, cssContent);
468
+ const cssPaths = await findAllStyles(styleDir);
469
+ if (cssPaths.length > 0) {
470
+ // Copy all CSS files to output
471
+ for (const cssPath of cssPaths) {
472
+ const cssOutputPath = cssPath.replace(source, output);
473
+ const cssContent = await readFile(cssPath, 'utf8');
474
+ await outputFile(cssOutputPath, cssContent);
475
+ }
476
+ styleLink = generateSeparateCssTags(cssPaths, source);
456
477
  }
457
478
  } catch (e) {
458
479
  // Ignore CSS errors
459
480
  }
460
481
 
461
- // Find all script.js files from docroot to current dir and inline their contents
482
+ // Find all script.js files from docroot to current dir and serve as separate external tags
483
+ // (Serve/dev mode: separate tags per level for individual invalidation)
462
484
  let customScript = "";
463
485
  try {
464
486
  const scriptDir = sourcePath ? dirname(sourcePath) : source;
465
487
  const scriptPaths = await findAllScripts(scriptDir);
466
- const scriptTags = [];
467
- for (const scriptPath of scriptPaths) {
468
- const scriptContent = await readFile(scriptPath, 'utf8');
469
- scriptTags.push(`<script>\n${scriptContent}\n</script>`);
488
+ if (scriptPaths.length > 0) {
489
+ // Copy all script files to output so they can be served
490
+ for (const scriptPath of scriptPaths) {
491
+ const scriptOutputPath = scriptPath.replace(source, output);
492
+ const scriptContent = await readFile(scriptPath, 'utf8');
493
+ await outputFile(scriptOutputPath, scriptContent);
494
+ }
495
+ customScript = generateSeparateJsTags(scriptPaths, source);
470
496
  }
471
- customScript = scriptTags.join('\n');
472
497
  } catch (e) {
473
498
  // Ignore script errors
474
499
  }
@@ -617,9 +642,10 @@ async function buildBackgroundCaches() {
617
642
  await findNearestStyle(dir);
618
643
  }
619
644
 
620
- // 7. Pre-load templates
645
+ // 7. Pre-load templates and re-bundle meta assets
621
646
  console.log(' 📄 Pre-loading templates...');
622
- devState.templates = await getTemplates(meta);
647
+ const rawTemplatesBg = await getTemplates(meta);
648
+ devState.templates = await bundleMetaTemplateAssets(rawTemplatesBg, meta, resolve(output, 'public'), { minify: true, sourcemap: false });
623
649
 
624
650
  // 8. Build footer
625
651
  console.log(' 📝 Building footer...');
@@ -724,15 +750,20 @@ export async function dev({
724
750
  console.log(`🎨 Meta: ${metaDir}`);
725
751
  console.log(`📤 Output: ${outputDir}`);
726
752
  console.log('━'.repeat(50));
727
-
753
+
754
+ // Resolve port (prompt user if occupied)
755
+ port = await resolvePort(port);
756
+
728
757
  // Create output directory and copy meta files
729
758
  await mkdir(outputDir, { recursive: true });
730
759
  const publicDir = join(outputDir, 'public');
731
760
  await mkdir(publicDir, { recursive: true });
732
- await copyDir(metaDir, publicDir);
761
+ await copyMetaAssets(metaDir, publicDir);
733
762
 
734
- // Pre-load templates for immediate use
735
- devState.templates = await getTemplates(metaDir);
763
+ // Pre-load templates and bundle meta assets (minify without obfuscation for debuggability)
764
+ let rawTemplates = await getTemplates(metaDir);
765
+ devState.templates = await bundleMetaTemplateAssets(rawTemplates, metaDir, publicDir, { minify: true, sourcemap: false });
766
+ console.log('📦 Meta template assets bundled');
736
767
 
737
768
  // Start server immediately
738
769
  const app = express();
@@ -950,20 +981,29 @@ export async function dev({
950
981
  devState.menuReady = false;
951
982
  await buildBackgroundCaches();
952
983
  } else if (isCssChange) {
953
- // Copy CSS to output
984
+ // Copy CSS to output and clear style cache so next render picks up changes
985
+ devState.stylePathMap.clear();
954
986
  try {
955
987
  const relativePath = name.replace(sourceDir, '');
956
988
  const outputPath = join(outputDir, relativePath);
957
989
  const content = await readFile(name, 'utf8');
958
990
  await outputFile(outputPath, content);
959
- console.log(`✅ Copied ${relativePath}`);
991
+ console.log(`✅ Copied ${relativePath} (style cache cleared)`);
960
992
  } catch (e) {
961
993
  console.error(`Error copying CSS: ${e.message}`);
962
994
  }
963
995
  } else if (isScriptChange) {
964
- // Clear script cache so next render picks up changes
996
+ // Copy script to output and clear script cache so next render picks up changes
965
997
  devState.scriptPathMap.clear();
966
- console.log(`✅ Script cache cleared for ${name}`);
998
+ try {
999
+ const relativePath = name.replace(sourceDir, '');
1000
+ const outputPath = join(outputDir, relativePath);
1001
+ const content = await readFile(name, 'utf8');
1002
+ await outputFile(outputPath, content);
1003
+ console.log(`✅ Copied ${relativePath} (script cache cleared)`);
1004
+ } catch (e) {
1005
+ console.error(`Error copying script: ${e.message}`);
1006
+ }
967
1007
  } else if (isComponentChange) {
968
1008
  // Component files (.tsx, .jsx, .ts) may be imported by MDX files
969
1009
  // Clear all MDX document caches so they re-compile with the updated component
@@ -1009,7 +1049,12 @@ export async function dev({
1009
1049
  // Watch meta directory for template changes
1010
1050
  watch(metaDir, { recursive: true }, async (evt, name) => {
1011
1051
  console.log(`🎨 Meta changed: ${name}`);
1012
- devState.templates = await getTemplates(metaDir);
1052
+ clearMetaBundleCache();
1053
+ const rawMetaTemplates = await getTemplates(metaDir);
1054
+ // Re-copy meta files and re-bundle
1055
+ const pubDir = join(outputDir, 'public');
1056
+ await copyMetaAssets(metaDir, pubDir);
1057
+ devState.templates = await bundleMetaTemplateAssets(rawMetaTemplates, metaDir, pubDir, { minify: true, sourcemap: false });
1013
1058
  broadcast('reload', { file: name });
1014
1059
  });
1015
1060