sdocs-dev 1.2.0 → 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,12 +298,31 @@ 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
305
  // ── Compression helpers (brotli + base64url) ──
201
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
+ }
325
+
202
326
  function toBase64Url(bytes) {
203
327
  var bin = Array.from(new Uint8Array(bytes), function(b) { return String.fromCharCode(b); }).join('');
204
328
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
@@ -212,11 +336,25 @@ function fromBase64Url(str) {
212
336
  return bytes;
213
337
  }
214
338
 
215
- async function compressText(text) {
216
- await BrotliWasm.ready;
339
+ async function compressDeflate(text) {
217
340
  var encoded = new TextEncoder().encode(text);
218
- var compressed = BrotliWasm.compress(encoded, { quality: 11 });
219
- return toBase64Url(compressed);
341
+ var cs = new CompressionStream('deflate-raw');
342
+ var writer = cs.writable.getWriter();
343
+ writer.write(encoded);
344
+ writer.close();
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);
357
+ }
220
358
  }
221
359
 
222
360
  async function decompressDeflate(bytes) {
@@ -224,30 +362,20 @@ async function decompressDeflate(bytes) {
224
362
  var writer = ds.writable.getWriter();
225
363
  writer.write(bytes);
226
364
  writer.close();
227
- var chunks = [];
228
- var reader = ds.readable.getReader();
229
- while (true) {
230
- var result = await reader.read();
231
- if (result.done) break;
232
- chunks.push(result.value);
233
- }
234
- var total = chunks.reduce(function(n, c) { return n + c.length; }, 0);
235
- var buf = new Uint8Array(total);
236
- var offset = 0;
237
- for (var i = 0; i < chunks.length; i++) { buf.set(chunks[i], offset); offset += chunks[i].length; }
238
- return new TextDecoder().decode(buf);
365
+ return new TextDecoder().decode(await readAllChunks(ds.readable));
239
366
  }
240
367
 
241
368
  async function decompressText(b64url) {
242
- await BrotliWasm.ready;
243
369
  var bytes = fromBase64Url(b64url);
244
- // Try brotli first, fall back to deflate for old URLs
245
- try {
246
- var decompressed = BrotliWasm.decompress(bytes);
247
- return new TextDecoder().decode(decompressed);
248
- } catch (_) {
249
- return decompressDeflate(bytes);
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 (_) {}
250
377
  }
378
+ return decompressDeflate(bytes);
251
379
  }
252
380
 
253
381
  // ── Auto-save to URL hash ──────────────────────────
@@ -285,6 +413,7 @@ function syncAll(source) {
285
413
  try {
286
414
  if (source === 'controls') {
287
415
  S._isDefaultState = false;
416
+ S.invalidateLocalMeta();
288
417
  S.currentMeta = Object.assign({}, S.currentMeta, { styles: S.collectStyles() });
289
418
  S.rawEl.value = SDocYaml.serializeFrontMatter(S.currentMeta) + '\n' + S.currentBody;
290
419
  updateHash();
@@ -331,6 +460,9 @@ contentArea.addEventListener('drop', function(e) {
331
460
  });
332
461
 
333
462
  S.rawEl.addEventListener('input', function() {
463
+ // Content has diverged from the on-disk file — drop local paths.
464
+ S.invalidateLocalMeta();
465
+
334
466
  clearTimeout(S._rawSyncTimer);
335
467
  S._rawSyncTimer = setTimeout(function() { syncAll('raw'); }, 300);
336
468
  });
@@ -368,6 +500,10 @@ function setMode(mode, skipHash) {
368
500
  S.enterWriteMode();
369
501
  }
370
502
 
503
+ if (mode === 'read') {
504
+ document.getElementById('content-area').focus();
505
+ }
506
+
371
507
  if (!skipHash) updateHash();
372
508
  }
373
509
 
@@ -385,14 +521,6 @@ document.getElementById('btn-new').addEventListener('click', function() {
385
521
  startNewDocument();
386
522
  });
387
523
 
388
- document.getElementById('btn-expand-all').addEventListener('click', function() {
389
- S.renderedEl.querySelectorAll('.md-section-body').forEach(function(b) { b.classList.add('open'); });
390
- S.renderedEl.querySelectorAll('.section-toggle').forEach(function(t) { t.classList.add('open'); });
391
- });
392
- document.getElementById('btn-collapse-all').addEventListener('click', function() {
393
- S.renderedEl.querySelectorAll('.md-section-body').forEach(function(b) { b.classList.remove('open'); });
394
- S.renderedEl.querySelectorAll('.section-toggle').forEach(function(t) { t.classList.remove('open'); });
395
- });
396
524
 
397
525
  document.getElementById('right-header').addEventListener('click', function() {
398
526
  if (window.innerWidth <= 768) {
@@ -448,7 +576,7 @@ document.querySelectorAll('.sub-header').forEach(function(h) {
448
576
  // ── Default content ──────────────────────────────────
449
577
 
450
578
  var DEFAULT_MD = '';
451
- 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; });
452
580
 
453
581
  // ── Register on SDocs for cross-module access ──────────
454
582
 
@@ -477,6 +605,18 @@ S.setStatus = setStatus;
477
605
  S.setMode = setMode;
478
606
  S.render = render;
479
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
+ };
480
620
 
481
621
  // Sync theme tabs to initial theme
482
622
  S.updateThemeTabs(S.activeTheme);
@@ -498,14 +638,33 @@ async function loadFromHash() {
498
638
  var stylesParam = params.get('styles');
499
639
  var themeParam = params.get('theme');
500
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
+ }
501
659
 
660
+ S._loadingDocument = true;
502
661
  S.resetAllStyles();
503
662
 
504
663
  if (mdParam) {
505
664
  try {
506
665
  S._isDefaultState = false;
507
666
  var text = await decompressText(mdParam);
508
- loadText(text, 'document.md');
667
+ loadText(text);
509
668
  } catch (e) {
510
669
  console.warn('sdocs-dev: could not decode hash', e);
511
670
  }