@kenjura/ursa 0.10.0 → 0.33.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/meta/sticky.js ADDED
@@ -0,0 +1,73 @@
1
+ document.addEventListener('DOMContentLoaded', () => {
2
+ const article = document.querySelector('article#main-content');
3
+ if (!article) return;
4
+
5
+ const headings = article.querySelectorAll('h1, h2, h3');
6
+
7
+ function updateStuckState() {
8
+ let currentStuckHeading = null;
9
+
10
+ headings.forEach(el => {
11
+ const wasStuck = el.classList.contains('stuck');
12
+ const style = window.getComputedStyle(el);
13
+ if (style.position === 'sticky' && style.top !== 'auto') {
14
+ const rect = el.getBoundingClientRect();
15
+ const top = parseInt(style.top, 10) || 0;
16
+ if (rect.top <= top && rect.bottom > top) {
17
+ el.classList.add('stuck');
18
+ currentStuckHeading = el;
19
+ } else {
20
+ el.classList.remove('stuck');
21
+ }
22
+ } else {
23
+ el.classList.remove('stuck');
24
+ }
25
+
26
+ // Dispatch event if stuck state changed
27
+ if (wasStuck !== el.classList.contains('stuck')) {
28
+ const event = new CustomEvent('headingStuckStateChanged', {
29
+ detail: {
30
+ heading: el,
31
+ stuck: el.classList.contains('stuck'),
32
+ currentStuckHeading: currentStuckHeading
33
+ }
34
+ });
35
+ document.dispatchEvent(event);
36
+ }
37
+
38
+ // Handle text updates for H1 elements
39
+ if (el.tagName === 'H1') {
40
+ // Store original text if not already stored
41
+ if (!el.dataset.originalText) {
42
+ el.dataset.originalText = el.textContent;
43
+ }
44
+
45
+ // Only update text if this H1 is stuck
46
+ if (el.classList.contains('stuck')) {
47
+ // Find the last stuck h2 and h3 (i.e., the "current" ones)
48
+ const stuckH2s = Array.from(headings).filter(h => h.tagName === 'H2' && h.classList.contains('stuck'));
49
+ const stuckH3s = Array.from(headings).filter(h => h.tagName === 'H3' && h.classList.contains('stuck'));
50
+ const stuckH2 = stuckH2s.length ? stuckH2s[stuckH2s.length - 1] : null;
51
+ const stuckH3 = stuckH3s.length ? stuckH3s[stuckH3s.length - 1] : null;
52
+
53
+ let newText = el.dataset.originalText;
54
+
55
+ if (stuckH3 && stuckH2) {
56
+ newText += ' > ' + (stuckH2.dataset.originalText || stuckH2.textContent) + ' > ' + (stuckH3.dataset.originalText || stuckH3.textContent);
57
+ } else if (stuckH2) {
58
+ newText += ' > ' + (stuckH2.dataset.originalText || stuckH2.textContent);
59
+ }
60
+
61
+ el.textContent = newText;
62
+ } else {
63
+ // Restore original text if not stuck
64
+ el.textContent = el.dataset.originalText;
65
+ }
66
+ }
67
+ });
68
+ }
69
+
70
+ updateStuckState();
71
+ window.addEventListener('scroll', updateStuckState, { passive: true });
72
+ window.addEventListener('resize', updateStuckState);
73
+ });
@@ -0,0 +1,124 @@
1
+ // Table of Contents Generator
2
+ document.addEventListener('DOMContentLoaded', () => {
3
+ const tocNav = document.getElementById('nav-toc');
4
+ const article = document.querySelector('article#main-content');
5
+
6
+ if (!tocNav || !article) return;
7
+
8
+ // Find all headings in the article
9
+ const headings = article.querySelectorAll('h1, h2, h3');
10
+
11
+ if (headings.length === 0) {
12
+ tocNav.style.display = 'none';
13
+ return;
14
+ }
15
+
16
+ // Generate TOC HTML
17
+ const tocList = document.createElement('ul');
18
+
19
+ headings.forEach((heading, index) => {
20
+ // Create unique ID for the heading if it doesn't have one
21
+ if (!heading.id) {
22
+ const text = heading.textContent.trim()
23
+ .toLowerCase()
24
+ .replace(/[^\w\s-]/g, '') // Remove special characters
25
+ .replace(/\s+/g, '-'); // Replace spaces with hyphens
26
+ heading.id = `heading-${index}-${text}`;
27
+ }
28
+
29
+ // Create TOC item
30
+ const listItem = document.createElement('li');
31
+ listItem.className = `toc-${heading.tagName.toLowerCase()}`;
32
+
33
+ const link = document.createElement('a');
34
+ link.href = `#${heading.id}`;
35
+ link.textContent = heading.textContent;
36
+ link.addEventListener('click', handleTocClick);
37
+
38
+ listItem.appendChild(link);
39
+ tocList.appendChild(listItem);
40
+ });
41
+
42
+ tocNav.appendChild(tocList);
43
+
44
+ // Handle TOC link clicks for smooth scrolling
45
+ function handleTocClick(e) {
46
+ e.preventDefault();
47
+ const targetId = e.target.getAttribute('href').substring(1);
48
+ const targetElement = document.getElementById(targetId);
49
+
50
+ if (targetElement) {
51
+ // Calculate offset to account for sticky header
52
+ const globalNavHeight = getComputedStyle(document.documentElement)
53
+ .getPropertyValue('--global-nav-height') || '48px';
54
+ const offset = parseInt(globalNavHeight) + 40; // Adjusted offset (was +20, now -29 for 49px less)
55
+
56
+ const targetPosition = targetElement.getBoundingClientRect().top + window.pageYOffset - offset;
57
+
58
+ window.scrollTo({
59
+ top: targetPosition,
60
+ behavior: 'smooth'
61
+ });
62
+ }
63
+ }
64
+
65
+ // Update active TOC item based on scroll position
66
+ function updateActiveTocItem() {
67
+ const scrollPosition = window.pageYOffset;
68
+ const globalNavHeight = getComputedStyle(document.documentElement)
69
+ .getPropertyValue('--global-nav-height') || '48px';
70
+ const offset = parseInt(globalNavHeight) + 100;
71
+
72
+ let activeHeading = null;
73
+
74
+ // Find the current heading based on scroll position
75
+ headings.forEach(heading => {
76
+ const headingTop = heading.getBoundingClientRect().top + window.pageYOffset;
77
+ if (headingTop <= scrollPosition + offset) {
78
+ activeHeading = heading;
79
+ }
80
+ });
81
+
82
+ updateTocActiveState(activeHeading);
83
+ }
84
+
85
+ // Update TOC active state
86
+ function updateTocActiveState(activeHeading) {
87
+ const tocLinks = tocNav.querySelectorAll('a');
88
+ tocLinks.forEach(link => {
89
+ link.classList.remove('active');
90
+ });
91
+
92
+ if (activeHeading) {
93
+ const activeLink = tocNav.querySelector(`a[href="#${activeHeading.id}"]`);
94
+ if (activeLink) {
95
+ activeLink.classList.add('active');
96
+ }
97
+ }
98
+ }
99
+
100
+ // Listen for heading stuck state changes from sticky.js
101
+ document.addEventListener('headingStuckStateChanged', (event) => {
102
+ if (event.detail.currentStuckHeading) {
103
+ updateTocActiveState(event.detail.currentStuckHeading);
104
+ } else {
105
+ // If no heading is stuck, fall back to scroll-based detection
106
+ updateActiveTocItem();
107
+ }
108
+ });
109
+
110
+ // Listen for scroll events
111
+ let ticking = false;
112
+ window.addEventListener('scroll', () => {
113
+ if (!ticking) {
114
+ requestAnimationFrame(() => {
115
+ updateActiveTocItem();
116
+ ticking = false;
117
+ });
118
+ ticking = true;
119
+ }
120
+ });
121
+
122
+ // Initial update
123
+ updateActiveTocItem();
124
+ });
package/meta/toc.js ADDED
@@ -0,0 +1,93 @@
1
+ (() => {
2
+ const toc = document.getElementById('toc');
3
+ if (!toc) return;
4
+
5
+ const links = Array.from(toc.querySelectorAll('a[href^="#"]'));
6
+ const heads = links
7
+ .map(a => document.getElementById(decodeURIComponent(a.hash.slice(1))))
8
+ .filter(Boolean);
9
+
10
+ const linkById = new Map(links.map(a => [decodeURIComponent(a.hash.slice(1)), a]));
11
+
12
+ // === sticky top detector ===
13
+ let STICKY_TOP = 48; // fallback
14
+ const nav = document.getElementById('nav-global');
15
+
16
+ function readStickyTop() {
17
+ const b = nav?.getBoundingClientRect();
18
+ // if nav is fixed at top, its bottom is the sticky line
19
+ STICKY_TOP = b ? Math.max(0, Math.round(b.bottom)) : 48;
20
+ }
21
+
22
+ readStickyTop();
23
+ window.addEventListener('resize', readStickyTop, { passive: true });
24
+ if (nav && 'ResizeObserver' in window) {
25
+ new ResizeObserver(readStickyTop).observe(nav);
26
+ }
27
+
28
+ // === build 1px sentinels just above each heading ===
29
+ function ensureSentinels() {
30
+ heads.forEach(h => {
31
+ if (h.previousElementSibling?.classList.contains('toc-sentinel')) return;
32
+ const s = document.createElement('div');
33
+ s.className = 'toc-sentinel';
34
+ s.style.position = 'relative';
35
+ s.style.height = '1px';
36
+ s.style.marginTop = `-${STICKY_TOP}px`; // place sentinel STICKY_TOP above h
37
+ s.style.pointerEvents = 'none';
38
+ s.dataset.forId = h.id;
39
+ h.before(s);
40
+ });
41
+ }
42
+
43
+ // === observer factory tied to current STICKY_TOP ===
44
+ let observer = null;
45
+ function (re)buildObserver() {
46
+ if (observer) observer.disconnect();
47
+ // ignore intersections near the bottom; we only care about the top line
48
+ const bottomRM = -(window.innerHeight - 1) + 'px';
49
+ observer = new IntersectionObserver(updateActiveFromSentinels, {
50
+ root: null,
51
+ rootMargin: `-${STICKY_TOP}px 0px ${bottomRM} 0px`,
52
+ threshold: 0
53
+ });
54
+ document.querySelectorAll('.toc-sentinel').forEach(s => observer.observe(s));
55
+ }
56
+
57
+ function updateActiveFromSentinels() {
58
+ // Pick the last sentinel whose top is <= 0 relative to the adjusted rootMargin,
59
+ // i.e. the heading whose sticky line has been crossed.
60
+ const sentinels = Array.from(document.querySelectorAll('.toc-sentinel'));
61
+ let candidate = null;
62
+ for (const s of sentinels) {
63
+ const top = s.getBoundingClientRect().top - STICKY_TOP; // compare to sticky line
64
+ if (top <= 0) candidate = s; else break;
65
+ }
66
+ const activeId = candidate ? candidate.dataset.forId : heads[0]?.id;
67
+ links.forEach(a => a.classList.toggle('active', decodeURIComponent(a.hash.slice(1)) === activeId));
68
+ }
69
+
70
+ // keep anchor jumps clear of the sticky bar
71
+ heads.forEach(h => h.style.scrollMarginTop = (STICKY_TOP + 8) + 'px');
72
+
73
+ // initial setup
74
+ ensureSentinels();
75
+ (re)buildObserver();
76
+ updateActiveFromSentinels();
77
+
78
+ // react when sticky top changes
79
+ let resizeTick = false;
80
+ window.addEventListener('resize', () => {
81
+ if (resizeTick) return;
82
+ resizeTick = true;
83
+ requestAnimationFrame(() => {
84
+ resizeTick = false;
85
+ readStickyTop();
86
+ // update sentinel offsets
87
+ document.querySelectorAll('.toc-sentinel').forEach(s => s.style.marginTop = `-${STICKY_TOP}px`);
88
+ heads.forEach(h => h.style.scrollMarginTop = (STICKY_TOP + 8) + 'px');
89
+ (re)buildObserver();
90
+ updateActiveFromSentinels();
91
+ });
92
+ }, { passive: true });
93
+ })();
package/package.json CHANGED
@@ -2,12 +2,17 @@
2
2
  "name": "@kenjura/ursa",
3
3
  "author": "Andrew London <andrew@kenjura.com>",
4
4
  "type": "module",
5
- "version": "0.10.0",
5
+ "version": "0.33.0",
6
6
  "description": "static site generator from MD/wikitext/YML",
7
7
  "main": "lib/index.js",
8
+ "bin": {
9
+ "ursa": "bin/ursa.js"
10
+ },
8
11
  "scripts": {
9
12
  "serve": "nodemon --config nodemon.json src/serve.js",
10
13
  "serve:debug": "nodemon --config nodemon.json --inspect-brk src/serve.js",
14
+ "cli:debug": "node --inspect bin/ursa.js",
15
+ "cli:debug-brk": "node --inspect-brk bin/ursa.js",
11
16
  "start": "node src/index.js",
12
17
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
13
18
  },
@@ -27,7 +32,8 @@
27
32
  "markdown-it-sup": "^1.0.0",
28
33
  "node-watch": "^0.7.3",
29
34
  "object-to-xml": "^2.0.0",
30
- "yaml": "^2.1.3"
35
+ "yaml": "^2.1.3",
36
+ "yargs": "^17.7.2"
31
37
  },
32
38
  "devDependencies": {
33
39
  "jest": "^29.6.2",
@@ -43,6 +49,21 @@
43
49
  },
44
50
  "keywords": [
45
51
  "static-site",
46
- "markdown"
47
- ]
52
+ "markdown",
53
+ "static-site-generator",
54
+ "wikitext",
55
+ "cli"
56
+ ],
57
+ "files": [
58
+ "lib/",
59
+ "src/",
60
+ "bin/",
61
+ "meta/",
62
+ "README.md",
63
+ "CHANGELOG.md"
64
+ ],
65
+ "publishConfig": {
66
+ "access": "public",
67
+ "registry": "https://registry.npmjs.org/"
68
+ }
48
69
  }
@@ -0,0 +1,138 @@
1
+ export function getImageTag(args) {
2
+ if (!args) args = {};
3
+ if (!args.name) { console.error('WikiImage.getImage > cannot get image with no name.'); return ''; }
4
+ // if (!args.article) { console.error('WikiImage.getImage > article not supplied.'); return ''; }
5
+
6
+ // var images = args.article.images;
7
+ // var image = null;
8
+ // if (images) {
9
+ // for (var i = 0; i < images.length; i++) {
10
+ // if ( images[i].name == args.name ) {
11
+ // image = images[i];
12
+ // break;
13
+ // }
14
+ // }
15
+ // }
16
+ // if (!image) {
17
+ // // image not yet uploaded
18
+ // return '<div class="noImage" ng-click="activateImage(\''+args.name+'\')">?</div>';
19
+ // } else {
20
+ // path
21
+ // var imgUrl = WikiImage.imageRoot + image.path;
22
+ var imgUrl = args.imgUrl;
23
+ // style
24
+ var width = 'auto', height = 'auto', classes = '', caption = '', fillMode = null, float = '';
25
+ if (args.args&&args.args.length) {
26
+ for (var i = 0; i < args.args.length; i++) {
27
+ var arg = args.args[i];
28
+ // numbers = width/height. for now, let's just do width
29
+ if (!isNaN(arg)) {
30
+ if (width=='auto') width = parseFloat(arg) + 'px';
31
+ else height = parseFloat(arg) + 'px';
32
+ continue;
33
+ }
34
+ if (arg.substr(-1)=='%') {
35
+ if (width=='auto') width = arg;
36
+ else height = arg;
37
+ continue;
38
+ }
39
+ if (arg.substr(-2)=='px') {
40
+ if (width=='auto') width = arg;
41
+ else height = arg;
42
+ continue;
43
+ }
44
+ // string values might mean something...
45
+ if (arg=='right') { float = 'float: right; clear: right;'; continue; }
46
+ if (arg=='fit'||arg=='box') { classes += arg + ' '; continue; }
47
+ if (arg=='center') { classes += 'center '; continue; }
48
+ if (arg=='cover'||arg=='contain') { fillMode = arg; continue; }
49
+ // else, assume it's the caption
50
+ caption = arg;
51
+ }
52
+ }
53
+ var style = 'width: '+width+'; height: '+height+';' + float;
54
+
55
+ // events
56
+ // var events = 'onclick="_scope.activateImage(\''+args.name+'\',\''+imgUrl+'\')"';
57
+ var events = 'ng-click="activateImage(\''+args.name+'\',\''+imgUrl+'\')"';
58
+
59
+ //return '<img class="wikiImage" src="'+imgUrl+'" style="'+style+'" '+events+' />';
60
+
61
+ var template = ''+
62
+ '<a href="{src}">'+
63
+ '<div class="wikiImage {class}" style="{style}" title="{caption}">'+
64
+ '<img src="{src}" {events} />'+
65
+ '<div class="wikiImage_caption">{caption}</div>'+
66
+ '</div>'+
67
+ '</a>';
68
+
69
+ // var template2 = ''+
70
+ // '<a href="{src}">'+
71
+ // '<div class="wikiImage {class}" style="background: url(\'{src}\'); background-size: {fillMode}; {style}" {events}>'+
72
+ // '<div class="wikiImage_caption">{caption}</div>'+
73
+ // '</div>'+
74
+ // '</a>';
75
+
76
+ const template2 = `<span class="wiki-image-container"><img src="${imgUrl}" style="${style}" class="wikiImage wiki-image" onClick="evt => imageZoom(evt)" />`;
77
+ return template2;
78
+
79
+
80
+ var html = template;
81
+ if (fillMode) html = template2;
82
+
83
+ html = html.replace( /\{src\}/g , imgUrl );
84
+ html = html.replace( '{class}' , classes );
85
+ html = html.replace( '{style}' , style );
86
+ html = html.replace( '{events}' , events );
87
+ html = html.replace( /\{caption\}/g , caption );
88
+ if (fillMode)
89
+ html = html.replace( '{fillMode}' , fillMode );
90
+
91
+ return html;
92
+ // }
93
+
94
+ /*
95
+ var imgCache = localStorage.getItem('imageCache');
96
+ if (!imgCache) imgCache = JSON.stringify({});
97
+
98
+ try {
99
+ imgCache = JSON.parse(imgCache);
100
+ var imgUrl = imgCache[args.name];
101
+ if (!imgUrl) {
102
+ //var img = new WikiImage(args);
103
+ var img = new WikiImage(args);
104
+ var tag = img.render();
105
+ imgCache[args.name] = img.url;
106
+ } else {
107
+ args.url = imgUrl;
108
+ var img = new WikiImage(args);
109
+ var tag = img.render();
110
+ }
111
+ localStorage.setItem('imageCache',JSON.stringify(imgCache));
112
+ return tag;
113
+ } catch(e) {
114
+ error('WikiImage.getImage > could not retrieve or parse the image cache. error to follow.');
115
+ error(e);
116
+ return new WikiImage(args);
117
+ }
118
+ */
119
+
120
+
121
+ }
122
+
123
+
124
+
125
+
126
+ /* how images should work:
127
+ * when you load an article, you also get a list of all images used in that article, and their URLs
128
+ * when rendering an image, check that list first. if the image name isn't in that list, don't bother trying to load the image
129
+ ** if it is in the list, load it, relying on browser cache to reduce load
130
+ ** important: do not use the server method to find each image's url; get them all from the loadArticle call
131
+ ** the FS db option should store image (and link) data in a special section at the end of the article body. it also doesn't allow images to have a different name than their url
132
+ * when an image is not yet uploaded, show a gray box with a question mark
133
+ * when the grey box or the image is clicked, pop up the image upload modal
134
+ * the image upload modal uses the appropriate service endpoint to upload, then returns the new URL
135
+ ** the new URL is then cached
136
+ ** the image is loaded from the URL
137
+ ** the image is associated with the article, which is immediately saved with the new image association
138
+ */