sdocs-dev 1.1.2 → 1.3.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.
@@ -9,12 +9,12 @@ var S = SDocs;
9
9
  var LINK_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
10
10
  var COPY_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
11
11
  var CHECK_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
12
+ var CHEVRON_SVG = '<span class="section-toggle"><svg width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M3 2l4 3-4 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></span>';
13
+ var COPY_FEEDBACK_MS = 1500;
12
14
 
13
15
  // ── Slugify + section helpers ──────────────────────
14
16
 
15
- function slugify(text) {
16
- return text.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
17
- }
17
+ var slugify = SDocSlugify.slugify;
18
18
 
19
19
  function buildSectionUrl(slug) {
20
20
  var base = window.location.origin + window.location.pathname;
@@ -50,15 +50,11 @@ function getSectionMarkdown(headingIndex) {
50
50
  return lines.slice(target.line, endLine).join('\n').trimEnd();
51
51
  }
52
52
 
53
- // ── Render ──────────────────────────────────
54
-
55
- function render() {
56
- var oldSpacer = S.renderedEl.querySelector('.sec-scroll-spacer');
57
- if (oldSpacer) oldSpacer.remove();
58
- S.renderedEl.innerHTML = marked.parse(S.currentBody);
53
+ // ── Render sub-functions ──────────────────────────────────
59
54
 
55
+ function attachHeadingAnchors(container) {
60
56
  var slugCounts = {};
61
- var allHeadings = [].slice.call(S.renderedEl.querySelectorAll('h1, h2, h3, h4'));
57
+ var allHeadings = [].slice.call(container.querySelectorAll('h1, h2, h3, h4'));
62
58
  allHeadings.forEach(function(h, idx) {
63
59
  var slug = slugify(h.textContent);
64
60
  if (!slug) slug = 'section';
@@ -78,7 +74,7 @@ function render() {
78
74
  e.preventDefault();
79
75
  navigator.clipboard.writeText(buildSectionUrl(slug)).then(function() {
80
76
  anchor.innerHTML = CHECK_SVG;
81
- setTimeout(function() { anchor.innerHTML = LINK_SVG; }, 1500);
77
+ setTimeout(function() { anchor.innerHTML = LINK_SVG; }, COPY_FEEDBACK_MS);
82
78
  });
83
79
  });
84
80
  h.appendChild(anchor);
@@ -94,57 +90,75 @@ function render() {
94
90
  var md = getSectionMarkdown(hIdx);
95
91
  navigator.clipboard.writeText(md).then(function() {
96
92
  copyBtn.innerHTML = CHECK_SVG;
97
- setTimeout(function() { copyBtn.innerHTML = COPY_SVG; }, 1500);
93
+ setTimeout(function() { copyBtn.innerHTML = COPY_SVG; }, COPY_FEEDBACK_MS);
98
94
  });
99
95
  });
100
96
  h.appendChild(copyBtn);
101
97
  });
98
+ }
102
99
 
103
- S.renderedEl.querySelectorAll('pre').forEach(function(pre) {
100
+ function attachCodeCopyButtons(container) {
101
+ container.querySelectorAll('pre').forEach(function(pre) {
104
102
  var wrapper = document.createElement('div');
105
103
  wrapper.className = 'pre-wrapper';
106
104
  pre.parentNode.insertBefore(wrapper, pre);
107
105
  wrapper.appendChild(pre);
108
106
  var btn = document.createElement('button');
109
107
  btn.className = 'copy-btn';
110
- btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
108
+ btn.innerHTML = COPY_SVG;
111
109
  btn.title = 'Copy code';
112
110
  btn.addEventListener('click', function() {
113
111
  var code = pre.querySelector('code');
114
112
  navigator.clipboard.writeText(code ? code.textContent : pre.textContent).then(function() {
115
- btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
116
- setTimeout(function() {
117
- btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
118
- }, 1500);
113
+ btn.innerHTML = CHECK_SVG;
114
+ setTimeout(function() { btn.innerHTML = COPY_SVG; }, COPY_FEEDBACK_MS);
119
115
  });
120
116
  });
121
117
  wrapper.appendChild(btn);
122
118
  });
119
+ }
120
+
121
+ var SECTION_LEVELS = { H2: 2, H3: 3, H4: 4 };
122
+
123
+ function buildCollapsibleSections(container) {
124
+ // H1 expand/collapse toggle (controls all sections below)
125
+ container.querySelectorAll('h1').forEach(function(h1) {
126
+ h1.insertAdjacentHTML('afterbegin', CHEVRON_SVG);
127
+ h1.style.cursor = 'pointer';
128
+ h1.addEventListener('click', function(e) {
129
+ if (e.target.closest('.header-anchor') || e.target.closest('.header-copy-btn')) return;
130
+ var toggle = h1.querySelector('.section-toggle');
131
+ var isOpen = toggle.classList.toggle('open');
132
+ container.querySelectorAll('.md-section-body').forEach(function(b) { b.classList.toggle('open', isOpen); });
133
+ container.querySelectorAll('.md-section > h2 > .section-toggle, .md-section > h3 > .section-toggle, .md-section > h4 > .section-toggle').forEach(function(t) { t.classList.toggle('open', isOpen); });
134
+ });
135
+ });
123
136
 
124
- // Collapsible heading sections (H2, H3, H4)
125
- var SECTION_CHEVRON = '<span class="section-toggle"><svg width="10" height="10" viewBox="0 0 10 10" fill="none"><path d="M3 2l4 3-4 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></span>';
126
- var SECTION_LEVELS = { H2: 2, H3: 3, H4: 4 };
127
- var children = [].slice.call(S.renderedEl.children);
128
- var bodyStack = [S.renderedEl];
137
+ // Nest H2/H3/H4 into collapsible section wrappers.
138
+ // Each stack frame tracks its heading level so siblings are siblings
139
+ // even when intermediate levels are skipped (e.g. h1 h4 directly).
140
+ var children = [].slice.call(container.children);
141
+ var stack = [{ body: container, level: 0 }];
129
142
  children.forEach(function(child) {
130
143
  var level = SECTION_LEVELS[child.tagName];
131
144
  if (level) {
132
- while (bodyStack.length > level - 1) bodyStack.pop();
145
+ while (stack[stack.length - 1].level >= level) stack.pop();
133
146
  var sectionDiv = document.createElement('div');
134
147
  sectionDiv.className = 'md-section';
135
148
  var sectionBody = document.createElement('div');
136
149
  sectionBody.className = 'md-section-body';
137
- child.insertAdjacentHTML('afterbegin', SECTION_CHEVRON);
138
- bodyStack[bodyStack.length - 1].appendChild(sectionDiv);
150
+ child.insertAdjacentHTML('afterbegin', CHEVRON_SVG);
151
+ stack[stack.length - 1].body.appendChild(sectionDiv);
139
152
  sectionDiv.appendChild(child);
140
153
  sectionDiv.appendChild(sectionBody);
141
- bodyStack.push(sectionBody);
154
+ stack.push({ body: sectionBody, level: level });
142
155
  } else {
143
- bodyStack[bodyStack.length - 1].appendChild(child);
156
+ stack[stack.length - 1].body.appendChild(child);
144
157
  }
145
158
  });
146
159
 
147
- S.renderedEl.querySelectorAll('.md-section > h2, .md-section > h3, .md-section > h4').forEach(function(heading) {
160
+ // Attach click handlers for section heading toggles
161
+ container.querySelectorAll('.md-section > h2, .md-section > h3, .md-section > h4').forEach(function(heading) {
148
162
  heading.addEventListener('click', function(e) {
149
163
  if (e.target.closest('.header-anchor') || e.target.closest('.header-copy-btn')) return;
150
164
  var yBefore = heading.getBoundingClientRect().top;
@@ -163,6 +177,96 @@ function render() {
163
177
  });
164
178
  }
165
179
 
180
+ // ── Render (orchestrator) ──────────────────────────────────
181
+
182
+ function render() {
183
+ S.destroyCharts();
184
+ var oldSpacer = S.renderedEl.querySelector('.sec-scroll-spacer');
185
+ if (oldSpacer) oldSpacer.remove();
186
+ S.renderedEl.innerHTML = DOMPurify.sanitize(marked.parse(S.currentBody));
187
+
188
+ attachHeadingAnchors(S.renderedEl);
189
+ attachCodeCopyButtons(S.renderedEl);
190
+ buildCollapsibleSections(S.renderedEl);
191
+ S.processCharts(S.renderedEl);
192
+ renderFileInfoCard();
193
+ }
194
+
195
+ // ── File-info card ─────────────────────────────────────────
196
+
197
+ function renderFileInfoCard() {
198
+ var card = document.getElementById('file-info-card');
199
+ if (!card) return;
200
+ var meta = S.currentMeta || {};
201
+ var local = S.localMeta || {};
202
+ var rowsEl = card.querySelector('.fic-rows');
203
+
204
+ var rows = [];
205
+ if (meta.file) rows.push({ key: 'file', label: 'File', value: meta.file, local: false });
206
+ if (local.path) rows.push({ key: 'path', label: 'Path', value: local.path, local: true });
207
+ if (local.fullPath) rows.push({ key: 'fullPath', label: 'Full', value: local.fullPath, local: true });
208
+
209
+ if (rows.length === 0) {
210
+ card.hidden = true;
211
+ rowsEl.innerHTML = '';
212
+ return;
213
+ }
214
+
215
+ card.hidden = false;
216
+ var note = card.querySelector('.fic-privacy-note');
217
+ if (note) note.hidden = !rows.some(function(r) { return r.local; });
218
+ rowsEl.innerHTML = rows.map(function(r) {
219
+ var pill = r.local ? '<span class="fic-local-pill" title="Only visible on this device — not included in shared sdocs">local only</span>' : '';
220
+ return '<div class="fic-row" data-key="' + r.key + '">'
221
+ + '<span class="fic-label">' + r.label + '</span>'
222
+ + '<span class="fic-value">' + escapeHtml(r.value) + '</span>'
223
+ + pill
224
+ + '<button class="fic-copy" title="Copy ' + r.label.toLowerCase() + '">' + COPY_SVG + '</button>'
225
+ + '</div>';
226
+ }).join('');
227
+
228
+ rowsEl.querySelectorAll('.fic-copy').forEach(function(btn) {
229
+ btn.addEventListener('click', function() {
230
+ var row = btn.closest('.fic-row');
231
+ var val = row.querySelector('.fic-value').textContent;
232
+ copyWithIconFeedback(val, btn);
233
+ });
234
+ });
235
+ }
236
+
237
+ function escapeHtml(s) {
238
+ return String(s).replace(/[&<>"']/g, function(c) {
239
+ return ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[c];
240
+ });
241
+ }
242
+
243
+ // Swap the button's <svg> to a check, restore after COPY_FEEDBACK_MS.
244
+ // Works for both icon-only and icon+label buttons.
245
+ function copyWithIconFeedback(text, btn) {
246
+ navigator.clipboard.writeText(text).then(function() {
247
+ if (!btn) return;
248
+ var svg = btn.querySelector('svg');
249
+ if (svg) {
250
+ svg.outerHTML = CHECK_SVG;
251
+ setTimeout(function() {
252
+ var current = btn.querySelector('svg');
253
+ if (current) current.outerHTML = COPY_SVG;
254
+ }, COPY_FEEDBACK_MS);
255
+ }
256
+ });
257
+ }
258
+
259
+ document.addEventListener('DOMContentLoaded', function() {
260
+ var copyAll = document.getElementById('fic-copy-all');
261
+ if (copyAll) {
262
+ copyAll.addEventListener('click', function() {
263
+ var meta = Object.assign({}, S.currentMeta || {}, { styles: S.collectStyles() });
264
+ var full = SDocYaml.serializeFrontMatter(meta) + '\n' + (S.currentBody || '');
265
+ copyWithIconFeedback(full, copyAll);
266
+ });
267
+ }
268
+ });
269
+
166
270
  // ── Status ──────────────────────────────────
167
271
 
168
272
  function setStatus(msg) {
@@ -175,6 +279,7 @@ function loadText(text, filename) {
175
279
  var parsed = SDocYaml.parseFrontMatter(text);
176
280
  S.currentMeta = parsed.meta;
177
281
  S.currentBody = parsed.body;
282
+ S.chartStyles = (parsed.meta.styles && parsed.meta.styles.chart) || null;
178
283
  render();
179
284
  if (parsed.meta.styles) S.applyStylesFromMeta(parsed.meta.styles);
180
285
  // Re-apply theme defaults for standalone colors (front matter may have
@@ -193,11 +298,30 @@ function loadText(text, filename) {
193
298
  });
194
299
  S.currentMeta = Object.assign({}, S.currentMeta, { styles: S.collectStyles() });
195
300
  S.rawEl.value = SDocYaml.serializeFrontMatter(S.currentMeta) + '\n' + S.currentBody;
196
- setStatus(filename ? 'Loaded: ' + filename : 'Ready');
301
+ setStatus(filename ? 'Loaded: ' + filename : '');
197
302
  syncAll('load');
198
303
  }
199
304
 
200
- // ── Compression helpers (deflate-raw + base64url) ──
305
+ // ── Compression helpers (brotli + base64url) ──
306
+
307
+ function concatChunks(chunks) {
308
+ var total = chunks.reduce(function(n, c) { return n + c.length; }, 0);
309
+ var buf = new Uint8Array(total);
310
+ var offset = 0;
311
+ for (var i = 0; i < chunks.length; i++) { buf.set(chunks[i], offset); offset += chunks[i].length; }
312
+ return buf;
313
+ }
314
+
315
+ async function readAllChunks(readable) {
316
+ var chunks = [];
317
+ var reader = readable.getReader();
318
+ while (true) {
319
+ var result = await reader.read();
320
+ if (result.done) break;
321
+ chunks.push(result.value);
322
+ }
323
+ return concatChunks(chunks);
324
+ }
201
325
 
202
326
  function toBase64Url(bytes) {
203
327
  var bin = Array.from(new Uint8Array(bytes), function(b) { return String.fromCharCode(b); }).join('');
@@ -212,44 +336,46 @@ function fromBase64Url(str) {
212
336
  return bytes;
213
337
  }
214
338
 
215
- async function compressText(text) {
339
+ async function compressDeflate(text) {
216
340
  var encoded = new TextEncoder().encode(text);
217
341
  var cs = new CompressionStream('deflate-raw');
218
342
  var writer = cs.writable.getWriter();
219
343
  writer.write(encoded);
220
344
  writer.close();
221
- var chunks = [];
222
- var reader = cs.readable.getReader();
223
- while (true) {
224
- var result = await reader.read();
225
- if (result.done) break;
226
- chunks.push(result.value);
345
+ return toBase64Url(await readAllChunks(cs.readable));
346
+ }
347
+
348
+ async function compressText(text) {
349
+ if (typeof BrotliWasm === 'undefined') return compressDeflate(text);
350
+ try {
351
+ await BrotliWasm.ready;
352
+ var encoded = new TextEncoder().encode(text);
353
+ var compressed = BrotliWasm.compress(encoded, { quality: 11 });
354
+ return toBase64Url(compressed);
355
+ } catch (_) {
356
+ return compressDeflate(text);
227
357
  }
228
- var total = chunks.reduce(function(n, c) { return n + c.length; }, 0);
229
- var buf = new Uint8Array(total);
230
- var offset = 0;
231
- for (var i = 0; i < chunks.length; i++) { buf.set(chunks[i], offset); offset += chunks[i].length; }
232
- return toBase64Url(buf);
233
358
  }
234
359
 
235
- async function decompressText(b64url) {
236
- var bytes = fromBase64Url(b64url);
360
+ async function decompressDeflate(bytes) {
237
361
  var ds = new DecompressionStream('deflate-raw');
238
362
  var writer = ds.writable.getWriter();
239
363
  writer.write(bytes);
240
364
  writer.close();
241
- var chunks = [];
242
- var reader = ds.readable.getReader();
243
- while (true) {
244
- var result = await reader.read();
245
- if (result.done) break;
246
- chunks.push(result.value);
365
+ return new TextDecoder().decode(await readAllChunks(ds.readable));
366
+ }
367
+
368
+ async function decompressText(b64url) {
369
+ var bytes = fromBase64Url(b64url);
370
+ // Try brotli first, fall back to deflate for old URLs or missing WASM
371
+ if (typeof BrotliWasm !== 'undefined') {
372
+ try {
373
+ await BrotliWasm.ready;
374
+ var decompressed = BrotliWasm.decompress(bytes);
375
+ return new TextDecoder().decode(decompressed);
376
+ } catch (_) {}
247
377
  }
248
- var total = chunks.reduce(function(n, c) { return n + c.length; }, 0);
249
- var buf = new Uint8Array(total);
250
- var offset = 0;
251
- for (var i = 0; i < chunks.length; i++) { buf.set(chunks[i], offset); offset += chunks[i].length; }
252
- return new TextDecoder().decode(buf);
378
+ return decompressDeflate(bytes);
253
379
  }
254
380
 
255
381
  // ── Auto-save to URL hash ──────────────────────────
@@ -287,6 +413,7 @@ function syncAll(source) {
287
413
  try {
288
414
  if (source === 'controls') {
289
415
  S._isDefaultState = false;
416
+ S.invalidateLocalMeta();
290
417
  S.currentMeta = Object.assign({}, S.currentMeta, { styles: S.collectStyles() });
291
418
  S.rawEl.value = SDocYaml.serializeFrontMatter(S.currentMeta) + '\n' + S.currentBody;
292
419
  updateHash();
@@ -333,6 +460,9 @@ contentArea.addEventListener('drop', function(e) {
333
460
  });
334
461
 
335
462
  S.rawEl.addEventListener('input', function() {
463
+ // Content has diverged from the on-disk file — drop local paths.
464
+ S.invalidateLocalMeta();
465
+
336
466
  clearTimeout(S._rawSyncTimer);
337
467
  S._rawSyncTimer = setTimeout(function() { syncAll('raw'); }, 300);
338
468
  });
@@ -370,6 +500,10 @@ function setMode(mode, skipHash) {
370
500
  S.enterWriteMode();
371
501
  }
372
502
 
503
+ if (mode === 'read') {
504
+ document.getElementById('content-area').focus();
505
+ }
506
+
373
507
  if (!skipHash) updateHash();
374
508
  }
375
509
 
@@ -387,14 +521,6 @@ document.getElementById('btn-new').addEventListener('click', function() {
387
521
  startNewDocument();
388
522
  });
389
523
 
390
- document.getElementById('btn-expand-all').addEventListener('click', function() {
391
- S.renderedEl.querySelectorAll('.md-section-body').forEach(function(b) { b.classList.add('open'); });
392
- S.renderedEl.querySelectorAll('.section-toggle').forEach(function(t) { t.classList.add('open'); });
393
- });
394
- document.getElementById('btn-collapse-all').addEventListener('click', function() {
395
- S.renderedEl.querySelectorAll('.md-section-body').forEach(function(b) { b.classList.remove('open'); });
396
- S.renderedEl.querySelectorAll('.section-toggle').forEach(function(t) { t.classList.remove('open'); });
397
- });
398
524
 
399
525
  document.getElementById('right-header').addEventListener('click', function() {
400
526
  if (window.innerWidth <= 768) {
@@ -450,7 +576,7 @@ document.querySelectorAll('.sub-header').forEach(function(h) {
450
576
  // ── Default content ──────────────────────────────────
451
577
 
452
578
  var DEFAULT_MD = '';
453
- var _defaultReady = fetch('/public/default.md').then(function(r) { return r.text(); }).then(function(t) { DEFAULT_MD = t; });
579
+ var _defaultReady = fetch('/public/sdoc.md').then(function(r) { return r.text(); }).then(function(t) { DEFAULT_MD = t; });
454
580
 
455
581
  // ── Register on SDocs for cross-module access ──────────
456
582
 
@@ -479,6 +605,18 @@ S.setStatus = setStatus;
479
605
  S.setMode = setMode;
480
606
  S.render = render;
481
607
  S.loadText = loadText;
608
+ S.renderFileInfoCard = renderFileInfoCard;
609
+
610
+ // Clear runtime-only local metadata (paths) once the user has edited the
611
+ // document, since the content no longer corresponds to the file on disk.
612
+ // Suppressed while a document is loading — style changes during load come from
613
+ // applying saved styles, not from user edits.
614
+ S.invalidateLocalMeta = function() {
615
+ if (S._loadingDocument) return;
616
+ if (!S.localMeta || Object.keys(S.localMeta).length === 0) return;
617
+ S.localMeta = {};
618
+ renderFileInfoCard();
619
+ };
482
620
 
483
621
  // Sync theme tabs to initial theme
484
622
  S.updateThemeTabs(S.activeTheme);
@@ -500,14 +638,33 @@ async function loadFromHash() {
500
638
  var stylesParam = params.get('styles');
501
639
  var themeParam = params.get('theme');
502
640
  var secParam = params.get('sec');
641
+ var localParam = params.get('local');
642
+
643
+ // Read &local=<base64url-json> into memory, then strip it from the URL bar
644
+ // so anything the user copies/shares no longer contains it.
645
+ if (localParam) {
646
+ try {
647
+ var b64 = localParam.replace(/-/g, '+').replace(/_/g, '/');
648
+ while (b64.length % 4) b64 += '=';
649
+ S.localMeta = JSON.parse(atob(b64)) || {};
650
+ } catch (e) {
651
+ S.localMeta = {};
652
+ }
653
+ params.delete('local');
654
+ var newHash = params.toString();
655
+ var newUrl = window.location.pathname + (newHash ? '#' + newHash : '');
656
+ window.history.replaceState(null, '', newUrl);
657
+ _lastLoadedHash = newHash; // prevent re-trigger
658
+ }
503
659
 
660
+ S._loadingDocument = true;
504
661
  S.resetAllStyles();
505
662
 
506
663
  if (mdParam) {
507
664
  try {
508
665
  S._isDefaultState = false;
509
666
  var text = await decompressText(mdParam);
510
- loadText(text, 'document.md');
667
+ loadText(text);
511
668
  } catch (e) {
512
669
  console.warn('sdocs-dev: could not decode hash', e);
513
670
  }