mnfst 0.5.163 → 0.5.164

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.
@@ -1,73 +1,24 @@
1
1
  /* Manifest Code
2
2
  /* By Andrew Matlock under MIT license
3
3
  /* https://manifestx.dev
4
- /*
5
- /* With reference to:
6
- /* - highlight.js (https://highlightjs.org)
7
- /* - CodeJar (https://github.com/antonmedv/codejar)
8
- /*
9
- /* Requires Alpine JS (alpinejs.dev) to operate.
10
- /*
11
- /* Public API
12
- /* ----------
13
- /* <pre x-code> Block; auto-detect language
14
- /* <pre x-code="javascript"> Block; explicit language
15
- /* <pre x-code="javascript" lines copy> Line numbers + floating copy button
16
- /* <pre x-code="javascript" name="API call"> Adds a header/title bar
17
- /* <pre x-code="javascript" collapse> Collapsed when long; default 20 lines
18
- /* <pre x-code="javascript" collapse="10"> Collapsed to first 10 lines
19
- /* <pre x-code="javascript" edit> CodeJar-powered editor
20
- /* <pre x-code="html" from="#demo-id"> Content sourced from another element's innerHTML
21
- /*
22
- /* <code x-code="bash">npm i mnfst</code> Inline; highlighted in place
23
- /* <code x-code="bash" copy>npm i mnfst</code>Inline; click-to-copy
24
- /*
25
- /* <div x-code-group> Tab strip across direct children with [name]
26
- /* <aside class="frame" name="HTML">… Frame is a tab panel
27
- /* <pre x-code="html" name="HTML">… Code block is a tab panel
28
- /* … Children without [name] are always visible
29
- /* </div>
30
4
  */
31
5
 
32
6
  // ─── Library loaders ─────────────────────────────────────────────────────────
33
7
 
34
- // Highlight.js loading is a two-mode affair:
35
- //
36
- // Lean mode (preferred): core.min.js (~8 KB gz) + one language module
37
- // (~2-7 KB gz each, depending on grammar) per
38
- // distinct language on the page. Used when every
39
- // code block declares an explicit language.
40
- // Loaded via ESM dynamic imports from esm.run
41
- // (jsDelivr's CJS→ESM transpiler); the npm
42
- // package's lib/core and lib/languages/* are
43
- // CommonJS-only, so we go through esm.run to
44
- // get browser-native ESM.
45
- //
46
- // Full mode (fallback): highlight.min.js (~42 KB gz with ~36 common
47
- // languages, IIFE-wrapped). Used when any block
48
- // requests auto-detect (empty/auto x-code value)
49
- // since hljs needs the whole language set to
50
- // pick. Loaded as a classic <script> from
51
- // cdn-release because it's the only build with
52
- // the languages baked in.
53
- //
54
- // We decide once on first call by scanning the page. If a later markdown
55
- // injection introduces an auto-detect block, the next loadHighlightJS()
56
- // transparently switches to full mode — the prior core load is harmless
57
- // (hljs is a single global namespace, the full bundle overwrites it).
8
+ // Lean mode: core + one language module per explicit language (via esm.run
9
+ // ESM). Full mode: the whole bundle, used once any block wants auto-detect.
10
+ // Decided on first call; a later auto-detect block flips to full mode
11
+ // (harmless the full bundle overwrites the single hljs global).
58
12
 
59
13
  const HLJS_FULL_URL = 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js';
60
- // esm.run resolves extension-less specifiers; including ".js" triggers a
61
- // deprecation warning at module-evaluation time.
14
+ // esm.run resolves extension-less specifiers; ".js" triggers a deprecation warning.
62
15
  const HLJS_CORE_URL = 'https://esm.run/highlight.js@11.11.1/lib/core';
63
16
  const HLJS_LANG_BASE = 'https://esm.run/highlight.js@11.11.1/lib/languages/';
64
17
 
65
18
  let hljsCorePromise = null;
66
19
  let hljsFullPromise = null;
67
20
  const langLoadPromises = new Map();
68
- // Becomes true once we've committed to the full bundle (any auto-detect
69
- // block, any language-module load failure). Once flipped, never resets —
70
- // we keep using the full bundle for the rest of the page lifecycle.
21
+ // Set once we've committed to the full bundle; never resets.
71
22
  let usingFullBundle = false;
72
23
 
73
24
  function injectScript(src) {
@@ -88,12 +39,8 @@ function injectScript(src) {
88
39
  });
89
40
  }
90
41
 
91
- // Sentinel used by both loaders to detect whether `window.hljs` is already
92
- // the FULL bundle (which registers ~190 languages) vs the lean core (which
93
- // starts with very few). Threshold of 50 separates the two reliably —
94
- // neither the lean core nor any realistic per-language top-up will reach
95
- // 50 languages before the full bundle does, and the full bundle always
96
- // ships well above that.
42
+ // Detect whether window.hljs is the full bundle (~190 langs) vs lean core.
43
+ // 50 separates the two reliably.
97
44
  function hljsIsFullBundle() {
98
45
  return typeof window.hljs?.listLanguages === 'function'
99
46
  && window.hljs.listLanguages().length > 50;
@@ -109,25 +56,16 @@ async function loadHighlightFull() {
109
56
  }
110
57
 
111
58
  async function loadHighlightCore() {
112
- // Don't downgrade. If the full bundle is already loaded on window.hljs
113
- // (because the hero editor or any other caller asked for full mode
114
- // first), returning the lean core would mean OVERWRITING window.hljs
115
- // with a 4-language instance, deleting the full bundle's grammars.
116
- // Per-block callers downstream would then re-request languages,
117
- // re-fire reactive bumps, and feed an Alpine re-eval loop. Just hand
118
- // back the full instance — registerLanguage's "already includes"
119
- // check below makes the rest of the lean path a no-op.
59
+ // Don't downgrade: if the full bundle is already on window.hljs, returning
60
+ // the lean core would clobber its grammars. Hand back the full instance.
120
61
  if (hljsIsFullBundle()) return window.hljs;
121
62
  if (hljsCorePromise) return hljsCorePromise;
122
63
  hljsCorePromise = import(HLJS_CORE_URL).then(mod => {
123
- // esm.run's CJS→ESM shim exposes hljs as the default export. Mirror
124
- // it onto window so existing call sites (Alpine evaluator, the hero
125
- // editor, etc.) that read `hljs` as a global keep working.
64
+ // esm.run's CJS→ESM shim exposes hljs as the default export; mirror
65
+ // onto window for call sites that read `hljs` as a global.
126
66
  const hl = mod.default;
127
67
  if (!hl) throw new Error('hljs undefined after core ESM import');
128
- // Second guard: even after we awaited the dynamic import, the full
129
- // bundle may have arrived in the meantime (its <script> tag races
130
- // our ESM fetch). Don't clobber it with the lean core.
68
+ // Guard again: the full bundle's <script> may have raced our ESM fetch.
131
69
  if (hljsIsFullBundle()) return window.hljs;
132
70
  window.hljs = hl;
133
71
  return hl;
@@ -146,9 +84,7 @@ async function registerLanguage(lang) {
146
84
  const p = import(`${HLJS_LANG_BASE}${resolved}`)
147
85
  .then(mod => { core.registerLanguage(resolved, mod.default); })
148
86
  .catch(() => {
149
- // If a language module fails to load (typo, network, deprecated
150
- // grammar, etc.) the next call will fall back to the full bundle
151
- // so the block at least renders with auto-detect.
87
+ // Module load failed fall back to the full bundle (auto-detect).
152
88
  usingFullBundle = true;
153
89
  langLoadPromises.delete(resolved);
154
90
  });
@@ -156,18 +92,13 @@ async function registerLanguage(lang) {
156
92
  return p;
157
93
  }
158
94
 
159
- // Public entry point. Called per code block with the block's requested
160
- // language (or null/empty for auto-detect). Returns hljs ready to highlight
161
- // `requestedLang`, or to auto-detect if we're in full mode.
95
+ // Public entry point. Returns hljs ready to highlight `requestedLang` (or to
96
+ // auto-detect in full mode).
162
97
  async function loadHighlightJS(requestedLang = null) {
163
- // Once we've committed to the full bundle (a prior block needed
164
- // auto-detect, or a language module load failed) every subsequent
165
- // call funnels through it.
166
98
  if (usingFullBundle || hljsFullPromise) return loadHighlightFull();
167
- // Plain-text blocks need an hljs instance for the (no-op) highlightInto
168
- // call, but must NOT escalate to the full bundle or fetch a module.
99
+ // Plain-text needs an hljs instance but must not escalate or fetch a module.
169
100
  if (PLAINTEXT_LANGS.has((requestedLang || '').toLowerCase())) return loadHighlightCore();
170
- // A per-block call asking for auto-detect → escalate.
101
+ // Per-block auto-detect → escalate.
171
102
  if (!requestedLang || requestedLang === 'auto') {
172
103
  usingFullBundle = true;
173
104
  return loadHighlightFull();
@@ -182,8 +113,7 @@ let codeJarPromise = null;
182
113
  async function loadCodeJar() {
183
114
  if (typeof window.CodeJar === 'function') return window.CodeJar;
184
115
  if (codeJarPromise) return codeJarPromise;
185
- // codejar@4.3.0 ships as an ESM-only module. Import dynamically and expose
186
- // on window for downstream consumers (e.g. the hero-editor demo).
116
+ // codejar@4.3.0 is ESM-only; import dynamically and expose on window.
187
117
  codeJarPromise = import('https://cdn.jsdelivr.net/npm/codejar@4.3.0/dist/codejar.js')
188
118
  .then(mod => {
189
119
  window.CodeJar = mod.CodeJar;
@@ -196,8 +126,7 @@ async function loadCodeJar() {
196
126
  return codeJarPromise;
197
127
  }
198
128
 
199
- // Tell the utilities plugin to ignore code-related classes / elements so its
200
- // utility-class scanner doesn't fight with hljs's mutations.
129
+ // Ignore code classes/elements in the utilities scanner so it doesn't fight hljs.
201
130
  if (window.ManifestUtilities) {
202
131
  window.ManifestUtilities.addIgnoredClassPattern(/^hljs/);
203
132
  window.ManifestUtilities.addIgnoredClassPattern(/^language-/);
@@ -212,20 +141,15 @@ if (window.ManifestUtilities) {
212
141
 
213
142
  // ─── Language resolution ─────────────────────────────────────────────────────
214
143
 
215
- // Common shortenings that authors expect to work. highlight.js accepts most of
216
- // these via its own alias system, but we resolve here so we can short-circuit
217
- // the supported-language check before calling hljs.
144
+ // Common shortenings; resolved here to short-circuit the supported-language
145
+ // check before calling hljs.
218
146
  const LANGUAGE_ALIASES = {
219
147
  js: 'javascript', ts: 'typescript', py: 'python', rb: 'ruby',
220
148
  sh: 'bash', shell: 'bash', yml: 'yaml', html: 'xml', svg: 'xml'
221
149
  };
222
150
 
223
- // Languages that mean "render as plain text — no highlighting." hljs ships a
224
- // trivial `plaintext` grammar, but there's no point loading a module (or
225
- // auto-detecting) for content the author explicitly marked plain. `txt` in
226
- // particular has NO module on the CDN — importing it 404s, and the failed
227
- // import escalates the whole page to the full bundle. Short-circuiting these
228
- // keeps the lean path intact and skips the network round-trip entirely.
151
+ // "Render as plain text" languages — no module fetch, no auto-detect. `txt`
152
+ // in particular has no CDN module (a 404 would escalate the page to full mode).
229
153
  const PLAINTEXT_LANGS = new Set(['txt', 'text', 'plain', 'plaintext', 'none', 'nohighlight']);
230
154
 
231
155
  function resolveLanguage(hljs, langAttr) {
@@ -236,18 +160,12 @@ function resolveLanguage(hljs, langAttr) {
236
160
 
237
161
  // ─── Content prep helpers ────────────────────────────────────────────────────
238
162
 
239
- // Drop any leading/trailing blank lines (including pure-whitespace lines).
240
- // Authors typically write <pre x-code> on its own line, leaving stray newlines
241
- // that throw off line numbering and the collapse threshold. Stripping multiple
242
- // also covers the case where authors leave a blank line before </pre> for
243
- // readability, which would otherwise render as a visible trailing gap.
163
+ // Drop leading/trailing blank lines (throw off line numbering and collapse).
244
164
  function trimWrappingNewlines(text) {
245
165
  return text.replace(/^(?:[ \t]*\n)+/, '').replace(/(?:\n[ \t]*)+$/, '');
246
166
  }
247
167
 
248
- // Remove the smallest common leading-whitespace block from every non-empty
249
- // line. Sourced text that came indented under HTML markup looks indented
250
- // inside the code block too, which is rarely what the author wants.
168
+ // Remove the smallest common leading-whitespace block from every non-empty line.
251
169
  function dedent(text) {
252
170
  const lines = text.split('\n');
253
171
  let minIndent = Infinity;
@@ -266,20 +184,13 @@ function prepSource(raw) {
266
184
  return dedent(trimWrappingNewlines(raw));
267
185
  }
268
186
 
269
- // Resolve the content for an element, honouring `from="#id"` (which reads the
270
- // referenced element's innerHTML, preserving HTML markup as the rendered
271
- // source) before falling back to the host element's own content.
187
+ // Resolve an element's source, honouring `from="#id"` (reads the referenced
188
+ // element's innerHTML) before the host's own content.
272
189
  //
273
- // HTML examples are the special case: authors expect to write the literal
274
- // markup (`<button>`, `<div>` etc.) without entity-escaping every char. The
275
- // browser parses those into real DOM nodes inside the <pre>, so textContent
276
- // would only see "ClickMe" instead of `<button>ClickMe</button>`. Reading
277
- // innerHTML serialises the DOM back to source text, and a textarea-based
278
- // decode unwraps any `&lt;`/`&gt;` entities so mixed-style authoring also
279
- // works. For non-HTML languages we keep textContent — JS/CSS/etc. content
280
- // rarely contains tags, and innerHTML would re-encode any entities the
281
- // author DID write back as &lt; (preserving them in the highlighted output
282
- // as literal text, which is wrong).
190
+ // HTML is the special case: authors write literal markup, so the browser parses
191
+ // it into real DOM nodes textContent would lose the tags. innerHTML serialises
192
+ // back to source and a textarea decodes entities. Non-HTML keeps textContent
193
+ // (innerHTML would wrongly re-encode any entities the author did write).
283
194
  function resolveSource(el) {
284
195
  const fromRef = el.getAttribute('from');
285
196
  if (fromRef) {
@@ -287,23 +198,16 @@ function resolveSource(el) {
287
198
  if (target) return prepSource(target.innerHTML);
288
199
  }
289
200
  const lang = (el.getAttribute('x-code') || el.getAttribute('language') || '').toLowerCase();
290
- // Markdown plugin emits <pre x-code="lang"><code>source</code></pre>. The
291
- // <code> body is text-only (entity-escaped HTML decoded by the browser
292
- // into a text node), so textContent returns the exact source the author
293
- // wrote — preserving valueless attributes (data-tailwind, not
294
- // data-tailwind=""), multi-line attribute lists, and other formatting
295
- // that an innerHTML round-trip would normalise away.
201
+ // Markdown emits <pre x-code><code>source</code></pre>. The <code> body is
202
+ // text-only, so textContent returns the exact author source (preserving
203
+ // valueless attributes and formatting an innerHTML round-trip would normalise).
296
204
  const childCode = el.querySelector(':scope > code');
297
205
  if (childCode) {
298
206
  return prepSource(childCode.textContent);
299
207
  }
300
- // Raw HTML case: the author wrote `<pre x-code="html"><div…></div></pre>`
301
- // directly in an .html file. Read innerHTML and decode any entities so
302
- // mixed-style authoring still works but valueless attributes will pick
303
- // up the browser's `=""` normalisation here, which is unavoidable once
304
- // the markup has been parsed into real DOM nodes. Authors who want exact
305
- // source preservation should use a fenced markdown block or write the
306
- // body as entity-escaped text inside the pre.
208
+ // Raw HTML written directly in an .html file: read innerHTML and decode
209
+ // entities. Valueless attributes pick up the browser's `=""` normalisation
210
+ // here unavoidable once parsed into DOM nodes.
307
211
  if (HTML_LIKE_LANGS.has(lang)) {
308
212
  const decoder = document.createElement('textarea');
309
213
  decoder.innerHTML = el.innerHTML;
@@ -312,19 +216,15 @@ function resolveSource(el) {
312
216
  return prepSource(el.textContent);
313
217
  }
314
218
 
315
- // hljs aliases (resolved upstream) map to "xml" internally, but authors may
316
- // type any of these. Keep them all in the set so the source-reading path
317
- // behaves the same regardless of the spelling used in the attribute.
219
+ // All map to "xml" internally; kept in one set so source-reading behaves the
220
+ // same regardless of spelling.
318
221
  const HTML_LIKE_LANGS = new Set(['html', 'xml', 'svg', 'xhtml', 'rss', 'atom']);
319
222
 
320
223
  // ─── Highlighting ────────────────────────────────────────────────────────────
321
224
 
322
- // Apply syntax highlighting to a <code> element's textContent. Returns the
323
- // language hljs actually used (or null when no highlighting was applied), so
324
- // callers can mark the host element accordingly.
225
+ // Highlight a <code> element. Returns the language hljs used (or null).
325
226
  function highlightInto(codeEl, source, hljs, requestedLang) {
326
- // Explicit plain text: keep the code-block chrome (hljs base class gives it
327
- // the background/padding) but apply no token colours and skip auto-detect.
227
+ // Explicit plain text: keep the code-block chrome, no token colours.
328
228
  if (PLAINTEXT_LANGS.has((requestedLang || '').toLowerCase())) {
329
229
  codeEl.textContent = source;
330
230
  codeEl.className = 'hljs language-plaintext';
@@ -339,9 +239,8 @@ function highlightInto(codeEl, source, hljs, requestedLang) {
339
239
  codeEl.dataset.highlighted = 'yes';
340
240
  return lang;
341
241
  }
342
- // Auto-detect path. Skip when the content looks like HTML markup hljs
343
- // logs a noisy warning for content that contains < and > in close
344
- // proximity, which fires constantly on any HTML-fragment example.
242
+ // Auto-detect. Skip HTML-looking content hljs logs a noisy warning for
243
+ // < and > in close proximity.
345
244
  codeEl.textContent = source;
346
245
  if (!/^[^<]*<\w[^>]*>[^<]*<\/\w/.test(source)) {
347
246
  try {
@@ -373,11 +272,8 @@ function setupInlineCopy(el) {
373
272
  await navigator.clipboard.writeText(el.textContent);
374
273
  el.classList.add('copied');
375
274
  setTimeout(() => el.classList.remove('copied'), 1500);
376
- // Progressive enhancement: when the tooltip plugin is loaded and
377
- // the author hasn't already bound an x-tooltip to this element,
378
- // flash the copied-icon in a tooltip as confirmation. Skipped
379
- // entirely if either condition isn't met — the .copied class
380
- // toggle above is always the baseline feedback.
275
+ // Progressive enhancement: flash a copied-icon tooltip when the
276
+ // tooltip plugin is present and no x-tooltip is already bound.
381
277
  if (window.ManifestTooltips && typeof window.ManifestTooltips.showTransient === 'function'
382
278
  && !el.hasAttribute('x-tooltip')) {
383
279
  window.ManifestTooltips.showTransient(
@@ -396,19 +292,13 @@ function setupInlineCopy(el) {
396
292
  // ─── Block (<pre x-code>) ────────────────────────────────────────────────────
397
293
 
398
294
  async function setupBlock(pre, hljs) {
399
- // Build-once guard. setupBlock can reach this function from two paths
400
- // (processCodeElement for standalone, setupCodeGroup for panels) and a
401
- // re-entry would read pre.textContent — which now includes the .lines
402
- // gutter's "1\n2\n…" — as fresh source, repeatedly prepending the
403
- // gutter digits to the code.
295
+ // Build-once guard re-entry would read the .lines gutter digits back as source.
404
296
  if (pre.dataset.codeBlockBuilt === 'yes') return;
405
297
  pre.dataset.codeBlockBuilt = 'yes';
406
298
 
407
299
  const source = resolveSource(pre);
408
300
  const requested = pre.getAttribute('x-code') || pre.getAttribute('language');
409
301
 
410
- // Reset internal structure. We always rebuild deterministically so the
411
- // first call is the only one that lays anything out.
412
302
  pre.innerHTML = '';
413
303
 
414
304
  // ARIA: region + label when titled
@@ -416,10 +306,7 @@ async function setupBlock(pre, hljs) {
416
306
  if (title && !pre.hasAttribute('aria-label')) pre.setAttribute('aria-label', title);
417
307
  if (!pre.hasAttribute('role')) pre.setAttribute('role', 'region');
418
308
 
419
- // Title bar — render only when not inside a code-group (the group's tab
420
- // strip already shows the [name], so a per-panel title bar would
421
- // duplicate it visually). The aria-label is set either way for assistive
422
- // tech.
309
+ // Title bar — skipped inside a code-group (the tab strip already shows [name]).
423
310
  const inGroup = !!pre.closest('[x-code-group]');
424
311
  if (title && !inGroup) {
425
312
  const header = document.createElement('header');
@@ -448,11 +335,8 @@ async function setupBlock(pre, hljs) {
448
335
  const actualLang = highlightInto(code, source, hljs, requested);
449
336
  pre.appendChild(code);
450
337
 
451
- // Copy button (floating, top-end). Suppressed when this block is a
452
- // panel inside an [x-code-group] the group itself owns the single
453
- // wrapper-level copy button (which targets the active panel) so the
454
- // affordance stays at the outermost container's top-end regardless
455
- // of which child(ren) carry [copy].
338
+ // Copy button (floating, top-end). Suppressed inside a group the group
339
+ // owns a single wrapper-level copy button targeting the active panel.
456
340
  if (pre.hasAttribute('copy') && !inGroup) setupBlockCopy(pre, code);
457
341
 
458
342
  // Collapse
@@ -487,9 +371,7 @@ function setupCollapse(pre, code) {
487
371
  const lineCount = code.textContent.split('\n').length;
488
372
  if (lineCount <= collapseAt) return;
489
373
 
490
- // Expose the threshold to CSS so the max-height matches the visible-line
491
- // count exactly. Line-height in our typography is 1.5, so N lines === N
492
- // × 1.5em of content height (plus pre padding handled by the selector).
374
+ // Expose the threshold to CSS for the max-height (line-height is 1.5).
493
375
  pre.style.setProperty('--collapse-lines', String(collapseAt));
494
376
  pre.setAttribute('data-collapsed', '');
495
377
 
@@ -498,10 +380,8 @@ function setupCollapse(pre, code) {
498
380
  btn.type = 'button';
499
381
  btn.setAttribute('aria-expanded', 'false');
500
382
  const hiddenCount = lineCount - collapseAt;
501
- // Visual label is locale-safe (no translatable strings) "+N" reads
502
- // universally as "expand to see N more", "−" (U+2212 minus sign) as
503
- // "collapse". Screen readers receive an explicit English aria-label
504
- // so the action remains intelligible regardless of the visual glyph.
383
+ // Visual label is locale-safe ("+N" / "−"); screen readers get an explicit
384
+ // English aria-label.
505
385
  const updateLabel = () => {
506
386
  const collapsed = pre.hasAttribute('data-collapsed');
507
387
  btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
@@ -523,8 +403,7 @@ async function setupEditor(pre, code, lang, hljs) {
523
403
  try {
524
404
  const CodeJar = await loadCodeJar();
525
405
  code.setAttribute('contenteditable', 'plaintext-only');
526
- // Some browsers (older Safari) don't support plaintext-only — fall back
527
- // to plain "true". CodeJar handles paste sanitization either way.
406
+ // Older Safari lacks plaintext-only — fall back to "true".
528
407
  if (code.getAttribute('contenteditable') !== 'plaintext-only' && code.contentEditable !== 'plaintext-only') {
529
408
  code.setAttribute('contenteditable', 'true');
530
409
  }
@@ -543,12 +422,9 @@ async function setupEditor(pre, code, lang, hljs) {
543
422
  indentOn: /[{[(]\s*$|<[a-zA-Z][^<>]*(?<!\/)>$/,
544
423
  addClosing: true
545
424
  });
546
- // CodeJar applies white-space: pre-wrap. Our convention for code blocks
547
- // is true no-wrap (lines extend, parent scrolls), so override.
425
+ // CodeJar applies pre-wrap; our blocks are no-wrap (parent scrolls).
548
426
  code.style.whiteSpace = 'pre';
549
- // Expose the editor instance on the host so consumers (e.g. the hero
550
- // editor) can wire onUpdate / updateCode / save / restore without
551
- // re-mounting CodeJar themselves.
427
+ // Expose the editor instance on the host for consumers (e.g. hero editor).
552
428
  pre._codeJar = editor;
553
429
  pre.dispatchEvent(new CustomEvent('code:editor-ready', { detail: { editor } }));
554
430
  } catch { /* CodeJar load / mount failed — fail silently */ }
@@ -559,16 +435,11 @@ async function setupEditor(pre, code, lang, hljs) {
559
435
  // Process one host element. Routes to inline vs block based on tag.
560
436
  async function processCodeElement(el) {
561
437
  if (el.dataset.codeProcessed === 'yes') return;
562
- // Group-owned panels are handled wholesale by setupCodeGroup: each
563
- // <pre x-code> sibling is consumed into a <code> child of the new
564
- // group wrapper. Skip here so we don't double-process / re-parent.
438
+ // Group-owned panels are handled wholesale by setupCodeGroup — skip here.
565
439
  if (el.parentElement && el.parentElement.hasAttribute('x-code-group')) return;
566
440
  el.dataset.codeProcessed = 'yes';
567
441
 
568
442
  try {
569
- // Pass the requested language to the loader so it can stay in lean
570
- // mode (core + only this language) when the rest of the page only
571
- // uses explicit languages too.
572
443
  const requestedLang = el.getAttribute('x-code') || el.getAttribute('language');
573
444
  const hljs = await loadHighlightJS(requestedLang);
574
445
  const tag = el.tagName;
@@ -595,61 +466,26 @@ async function processCodeElement(el) {
595
466
 
596
467
  // ─── Code groups (tab strip across [name] siblings) ──────────────────────────
597
468
 
598
- // Build the canonical group structure:
599
- //
600
- // <pre x-code-group> ← wrapper coordinates tabs
601
- // <header role=tablist> (a <div x-code-group> source
602
- // <button role=tab>HTML</button> is normalized to <pre> at
603
- // <button role=tab>CSS</button> process time)
604
- // </header>
605
- // <pre x-code="html" name=HTML role=tabpanel>...full block...</pre>
606
- // <pre x-code="css" name=CSS role=tabpanel style="display:none">...</pre>
607
- // <aside class=frame name=Preview role=tabpanel style="display:none">...</aside>
608
- // <button.copy> ← present when wrapper has [copy]
609
- // </pre>
610
- //
611
- // Each code panel is a full <pre x-code> block with its own line numbers,
612
- // collapse toggle, editor — i.e. it runs through setupBlock. The wrapper
613
- // inherits per-panel feature attributes (lines, edit, collapse) to child
614
- // panels that don't set them, so:
615
- //
616
- // <pre x-code-group lines>
617
- // <pre x-code="html" name=HTML>...</pre>
618
- // <pre x-code="css" name=CSS>...</pre>
619
- // </pre>
620
- //
621
- // behaves as if each child had `lines` written on it directly. Children
622
- // that DO set the attribute (or set it to a different value, e.g. per-panel
623
- // collapse="5") win over inheritance.
624
- //
625
- // `copy` is NOT inherited — instead, when present on the wrapper, the
626
- // plugin attaches a single copy button to the wrapper itself that targets
627
- // whichever panel is currently active. This keeps the button on the
628
- // element that actually carries the [copy] attribute, and avoids the
629
- // visual clutter of one button per tab.
469
+ // A <pre|div x-code-group> wrapper coordinates tabs across its [name] children.
470
+ // Each code panel is a full <pre x-code> block run through setupBlock. Feature
471
+ // attrs (lines/edit/collapse) inherit from wrapper to children that don't set
472
+ // them; children that do win. `copy` is NOT inherited — a single wrapper-level
473
+ // copy button targets the active panel instead.
630
474
  const GROUP_INHERITABLE_ATTRS = ['lines', 'edit', 'collapse'];
631
475
 
632
476
  async function setupCodeGroup(group) {
633
477
  if (group.dataset.groupProcessed === 'yes') return;
634
478
 
635
- // A "panel" is any direct child with a [name] these are tab panels.
636
- // Children without [name] are ambient (always visible alongside whichever
637
- // panel is active). When nothing is named, the group has no tabs at all
638
- // and renders as a borderless wrapper around its (always-visible) kids —
639
- // a frame + code pair, for instance, with no title overhead.
479
+ // Panels are children with [name]; children without are ambient (always
480
+ // visible). No names borderless wrapper around its kids, no tabs.
640
481
  const sourcePanels = Array.from(group.children).filter(c => c.hasAttribute('name'));
641
482
  const ambientChildren = Array.from(group.children).filter(c => !c.hasAttribute('name'));
642
483
  if (sourcePanels.length === 0 && ambientChildren.length === 0) return;
643
- // Claim the group synchronously so re-entrant callers (the directive +
644
- // observer can both arrive before the first call's `await` resolves)
645
- // bail out — otherwise the wrapper accumulates duplicate tab strips.
484
+ // Claim synchronously so re-entrant callers (directive + observer) bail out.
646
485
  group.dataset.groupProcessed = 'yes';
647
486
 
648
- // Inherit feature attributes from wrapper to child <pre x-code> elements
649
- // that don't set them. Ambient (unnamed) pres inherit too, so a frame +
650
- // code pair in an unnamed group still benefits from group-level [lines]
651
- // / [edit] / [collapse]. Run BEFORE setupBlock so the inherited attrs
652
- // drive that block's setup.
487
+ // Inherit feature attributes to child <pre x-code> that don't set them.
488
+ // Run BEFORE setupBlock so inherited attrs drive its setup.
653
489
  const allCodeChildren = [...sourcePanels, ...ambientChildren].filter(c => c.tagName === 'PRE');
654
490
  for (const panel of allCodeChildren) {
655
491
  for (const attr of GROUP_INHERITABLE_ATTRS) {
@@ -659,9 +495,7 @@ async function setupCodeGroup(group) {
659
495
  }
660
496
  }
661
497
 
662
- // Ordered unique tab names. Multiple panels may share a name (e.g. a
663
- // frame + a code block co-visible under one tab); each panel is shown
664
- // independently when its tab activates.
498
+ // Ordered unique tab names. Panels may share a name (frame + code co-visible).
665
499
  const tabNames = [];
666
500
  for (const p of sourcePanels) {
667
501
  const n = p.getAttribute('name');
@@ -670,9 +504,8 @@ async function setupCodeGroup(group) {
670
504
  const active = tabNames[0];
671
505
  const slugify = s => s.replace(/\s+/g, '-').toLowerCase();
672
506
 
673
- // Preload hljs + every language needed across the group (named panels +
674
- // ambient pres) so each block's setupBlock can synchronously highlight
675
- // without re-loading.
507
+ // Preload hljs + every language across the group so each setupBlock can
508
+ // highlight synchronously.
676
509
  const codeLangs = allCodeChildren
677
510
  .filter(p => p.hasAttribute('x-code'))
678
511
  .map(p => p.getAttribute('x-code'))
@@ -683,12 +516,8 @@ async function setupCodeGroup(group) {
683
516
  for (const l of codeLangs.slice(1)) await registerLanguage(l);
684
517
  }
685
518
 
686
- // Normalize wrapper to <pre>. Authors may write <div x-code-group> or
687
- // <pre x-code-group>; we always end up with a <pre> for CSS uniformity.
688
- // When converting from <div>, transplant children rather than re-creating
689
- // — the same <pre x-code> nodes that Alpine has wired up keep their
690
- // identity, so subsequent processOne calls (and any author refs into
691
- // them) stay valid.
519
+ // Normalize wrapper to <pre> for CSS uniformity. When converting from
520
+ // <div>, transplant children so their Alpine-wired identity stays valid.
692
521
  let pre;
693
522
  if (group.tagName === 'PRE') {
694
523
  pre = group;
@@ -701,12 +530,8 @@ async function setupCodeGroup(group) {
701
530
  pre.dataset.groupProcessed = 'yes';
702
531
  if (!pre.hasAttribute('role')) pre.setAttribute('role', 'region');
703
532
 
704
- // Run each code child (named or ambient) through setupBlock so it gets
705
- // its full feature treatment (line numbers, copy button, collapse,
706
- // editor). Frames stay as-is. setupBlock detects the [x-code-group]
707
- // ancestor and suppresses the per-panel title bar — the group header
708
- // (when present) serves that role; for headerless groups, the pre is
709
- // simply rendered without its own title.
533
+ // Run each code child through setupBlock for full feature treatment.
534
+ // Frames stay as-is; setupBlock suppresses the per-panel title bar in a group.
710
535
  for (const panel of allCodeChildren) {
711
536
  if (panel.hasAttribute('x-code')) {
712
537
  await setupBlock(panel, hljs);
@@ -714,11 +539,8 @@ async function setupCodeGroup(group) {
714
539
  }
715
540
  }
716
541
 
717
- // Header: tab strip when there are multiple named panels, plain title bar
718
- // when there's just one, no header at all when nothing is named. Skipping
719
- // the header for headerless groups lets authors pair a frame + code block
720
- // inside <div x-code-group> with no repetitive "HTML" title — both render
721
- // as ambient siblings inside the wrapper.
542
+ // Header: tab strip for multiple named panels, plain title bar for one,
543
+ // none when nothing is named.
722
544
  const isSingleTab = tabNames.length === 1;
723
545
  const isHeaderless = tabNames.length === 0;
724
546
  let header = null;
@@ -731,12 +553,8 @@ async function setupCodeGroup(group) {
731
553
  header.appendChild(titleEl);
732
554
  if (!pre.hasAttribute('aria-label')) pre.setAttribute('aria-label', active);
733
555
  } else if (!isHeaderless) {
734
- // The role=tablist sits on an inner <div> that holds the tab buttons.
735
- // That way the tablist can have its own overflow-x scrolling (when
736
- // there are too many tabs to fit) without dragging sibling header
737
- // content into the scroll region. CSS targets the inner element via
738
- // [role="tablist"]. The unstyle class opts out of the generic tab
739
- // bar styling in manifest.form.css; manifest.code.css styles it.
556
+ // role=tablist on an inner <div> so it can overflow-x scroll on its own.
557
+ // `unstyle` opts out of manifest.form.css's generic tab styling.
740
558
  tablist = document.createElement('div');
741
559
  tablist.className = 'unstyle';
742
560
  tablist.setAttribute('role', 'tablist');
@@ -774,11 +592,8 @@ async function setupCodeGroup(group) {
774
592
  }
775
593
  if (header) pre.insertBefore(header, pre.firstChild);
776
594
 
777
- // Wire ARIA / IDs on each panel. Multi-tab groups get role="tabpanel" with
778
- // aria-labelledby pointing at its tab button; single-tab groups keep the
779
- // standalone role="region" + aria-label shape (label set on the wrapper
780
- // above) so there's no orphan tabpanel without a tablist. Headerless
781
- // groups have no tabpanels — every child is ambient.
595
+ // Wire ARIA / IDs. Multi-tab: role="tabpanel" + aria-labelledby its tab.
596
+ // Single-tab keeps the region shape (no orphan tabpanel without a tablist).
782
597
  sourcePanels.forEach((panel, i) => {
783
598
  const name = panel.getAttribute('name');
784
599
  panel.id = panel.id || `${slugify(name)}-panel-${i}`;
@@ -791,16 +606,9 @@ async function setupCodeGroup(group) {
791
606
  }
792
607
  });
793
608
 
794
- // Wrapper-level copy button. Created when [copy] is on the wrapper OR on
795
- // any child panel the button always lives at the group's top-end so
796
- // the affordance is in the same spot regardless of which child carries
797
- // [copy]. setupBlock suppresses the per-panel copy button when inside a
798
- // group (the inGroup check) so we never duplicate.
799
- //
800
- // Appended as a direct child of the <pre> (sibling to the header) so it
801
- // can be absolutely positioned over the top-end of the wrapper without
802
- // competing with the header's own overflow-scroll region. Same
803
- // positioning rule as for a standalone <pre x-code copy>.
609
+ // Wrapper-level copy button created when [copy] is on the wrapper or any
610
+ // panel. Appended as a direct child of <pre> so it can position over the
611
+ // top-end without competing with the header's scroll region.
804
612
  const wrapperHasCopy = pre.hasAttribute('copy');
805
613
  const anyPanelCopy = [...sourcePanels, ...ambientChildren].some(p => p.hasAttribute('copy'));
806
614
  let copyBtn = null;
@@ -810,11 +618,8 @@ async function setupCodeGroup(group) {
810
618
  copyBtn.type = 'button';
811
619
  copyBtn.setAttribute('aria-label', 'Copy code to clipboard');
812
620
  copyBtn.addEventListener('click', async () => {
813
- // When multiple panels share the active name (e.g. a paired
814
- // frame + code), prefer the <pre x-code> for copy — the source
815
- // is what an author wants to take, not the rendered frame's
816
- // text content. Headerless groups have no active name, so pick
817
- // the first ambient <pre x-code> child instead.
621
+ // Prefer the <pre x-code> panel for copy (its source, not a frame's
622
+ // rendered text). Headerless groups pick the first ambient <pre x-code>.
818
623
  let activePanel;
819
624
  if (isHeaderless) {
820
625
  activePanel = ambientChildren.find(p => p.tagName === 'PRE' && p.hasAttribute('x-code'));
@@ -833,13 +638,9 @@ async function setupCodeGroup(group) {
833
638
  pre.appendChild(copyBtn);
834
639
  }
835
640
 
836
- // Visibility toggle. Explicit style.display rather than the [hidden]
837
- // attribute, because pre's display:flex from typography.css outweighs
838
- // the UA `[hidden] { display: none }` rule. Also flips the copy button
839
- // visibility per-tab: when [copy] sits on the wrapper it stays visible
840
- // for every tab; when it sits only on individual panels, the button
841
- // shows for tabs whose panels carry [copy] and hides for the rest.
842
- // Headerless groups have no tabs to switch, so activate() is a no-op.
641
+ // Visibility toggle via style.display (pre's display:flex outweighs the UA
642
+ // [hidden] rule). Also flips per-tab copy-button visibility when [copy] is
643
+ // per-panel rather than on the wrapper.
843
644
  let activeName = active;
844
645
  function activate(name) {
845
646
  activeName = name;
@@ -863,11 +664,8 @@ async function setupCodeGroup(group) {
863
664
 
864
665
  // ─── Page scan + observation ─────────────────────────────────────────────────
865
666
 
866
- // Markdown emits <pre><code class="language-X">…</code></pre>. Promote these
867
- // to first-class hosts by setting x-code on the <pre> when we encounter them,
868
- // so they flow through the same processor as authored <pre x-code> blocks.
869
- // Accepts either a Document/Element (scans descendants) or a single <pre>
870
- // element (adopts just that one).
667
+ // Promote markdown's <pre><code class="language-X"> to first-class hosts by
668
+ // setting x-code on the <pre>. Accepts a Document/Element (scans) or one <pre>.
871
669
  function adoptMarkdownBlocks(root = document) {
872
670
  if (root && root.tagName === 'PRE' && !root.hasAttribute('x-code')) {
873
671
  const code = root.querySelector(':scope > code[class*="language-"]');
@@ -892,15 +690,9 @@ function adoptMarkdownBlocks(root = document) {
892
690
  }
893
691
  }
894
692
 
895
- // Per-element IntersectionObserver — each candidate processes on its own
896
- // when it scrolls into view. This is important for two reasons:
897
- // 1. SPA routes that aren't currently visible (display:none from the
898
- // router) shouldn't trigger loadHighlightJS for their hidden blocks.
899
- // A page-wide scan would scoop them up and an auto-detect block in a
900
- // hidden route would push the loader into full-bundle mode even
901
- // though the visible route is lean-mode eligible.
902
- // 2. Long pages with many code blocks don't pay the highlight cost up
903
- // front — each block runs hljs only when it nears the viewport.
693
+ // Per-element IntersectionObserver — process each block only when it nears the
694
+ // viewport. Keeps hidden SPA routes from prematurely escalating the loader to
695
+ // full mode, and defers highlight cost on long pages.
904
696
  let codeIO = null;
905
697
 
906
698
  function ensureObserver() {
@@ -908,12 +700,9 @@ function ensureObserver() {
908
700
  codeIO = new IntersectionObserver((entries, observer) => {
909
701
  for (const entry of entries) {
910
702
  if (!entry.isIntersecting) continue;
911
- // The router uses display:none to hide inactive SPA routes. There's
912
- // a small window during initial Alpine boot where the IO's initial
913
- // entry for an element fires as "intersecting" before the router
914
- // has applied display:none. checkVisibility() is the source of
915
- // truth; if the element is actually hidden, leave it observed so
916
- // a future route change re-fires the IO when it becomes visible.
703
+ // During Alpine boot the IO can fire "intersecting" before the router
704
+ // applies display:none. checkVisibility() is the source of truth;
705
+ // if hidden, leave it observed to re-fire on a future route change.
917
706
  const t = entry.target;
918
707
  if (typeof t.checkVisibility === 'function' && !t.checkVisibility()) continue;
919
708
  observer.unobserve(t);
@@ -924,19 +713,14 @@ function ensureObserver() {
924
713
  }
925
714
 
926
715
  function handleVisible(el) {
927
- // When any code element in the active route first crosses into view,
928
- // eagerly process every currently-visible candidate on the page. This
929
- // avoids the "popping" effect of incremental highlighting as the user
930
- // scrolls — once hljs is loaded, everything currently on screen gets
931
- // styled at once. Below-the-fold and hidden-route candidates stay
932
- // observed and process when they later become visible.
716
+ // Once one block crosses into view, eagerly process every currently-visible
717
+ // candidate so highlighting doesn't "pop" in during scroll. Below-the-fold
718
+ // and hidden-route candidates stay observed for later.
933
719
  const candidates = document.querySelectorAll(
934
720
  '[x-code]:not([data-code-processed]),' +
935
721
  '[x-code-group]:not([data-group-processed]),' +
936
722
  'pre:not([x-code]):not([data-code-processed]) > code[class*="language-"],' +
937
- // Copy-only inline codespans from markdown (`text`{copy}). Skip those
938
- // that also carry x-code — they're already covered by the first
939
- // selector — and skip <code copy> inside a <pre> (block-level path).
723
+ // Copy-only inline codespans (`text`{copy}); skip x-code and in-pre ones.
940
724
  'code[copy]:not([x-code]):not([data-code-processed]):not(pre > code)'
941
725
  );
942
726
  // Always include the triggering element (it's already known to be visible).
@@ -959,9 +743,7 @@ function processOne(el) {
959
743
  adoptMarkdownBlocks(el.parentElement);
960
744
  processCodeElement(el.parentElement);
961
745
  } else if (el.tagName === 'CODE' && el.hasAttribute('copy')) {
962
- // Copy-only inline codespan (`text`{copy} in markdown) — no x-code,
963
- // no highlighting, just wire the click-to-copy handler. Marking it
964
- // as processed prevents re-discovery on subsequent scans.
746
+ // Copy-only inline codespan just wire click-to-copy.
965
747
  if (el.dataset.codeProcessed !== 'yes') {
966
748
  el.dataset.codeProcessed = 'yes';
967
749
  setupInlineCopy(el);
@@ -969,29 +751,21 @@ function processOne(el) {
969
751
  }
970
752
  }
971
753
 
972
- // Start observing every candidate in `root` that hasn't already been
973
- // processed. Idempotent re-observation of an already-observed element
974
- // is a no-op per the IntersectionObserver spec. Elements that are already
975
- // visible at call time are processed immediately, then unobserved — this
976
- // makes markdown-plugin-injected blocks render synchronously instead of
977
- // waiting for the next IO callback (which can stall in headless tests
978
- // and feels laggy for live markdown updates).
754
+ // Observe every unprocessed candidate in `root` (idempotent). Already-visible
755
+ // elements process immediately so markdown-injected blocks render synchronously
756
+ // instead of waiting for the next IO callback.
979
757
  function observeAll(root = document) {
980
758
  const io = ensureObserver();
981
759
  const candidates = [
982
760
  ...root.querySelectorAll('[x-code]:not([data-code-processed])'),
983
761
  ...root.querySelectorAll('[x-code-group]:not([data-group-processed])'),
984
762
  ...root.querySelectorAll('pre:not([x-code]):not([data-code-processed]) > code[class*="language-"]'),
985
- // Copy-only inline codespans (no x-code, not inside <pre>) — markdown's
986
- // `text`{copy} syntax. These need only the copy handler wired, no
987
- // highlighting work.
763
+ // Copy-only inline codespans (`text`{copy}) copy handler only.
988
764
  ...root.querySelectorAll('code[copy]:not([x-code]):not([data-code-processed]):not(pre > code)')
989
765
  ];
990
766
  for (const el of candidates) {
991
767
  io.observe(el);
992
- // Skip the immediate-process shortcut for elements that are hidden
993
- // (display:none route panels, off-screen drawers): keep them observed
994
- // so they highlight on the next intersection callback when revealed.
768
+ // Hidden elements stay observed to highlight when later revealed.
995
769
  if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) continue;
996
770
  io.unobserve(el);
997
771
  processOne(el);
@@ -1013,17 +787,14 @@ function registerAlpine() {
1013
787
  if (window.__manifestCodeDirectivesRegistered) return;
1014
788
  window.__manifestCodeDirectivesRegistered = true;
1015
789
 
1016
- // `x-code="language"` on any element. Alpine fires the callback once
1017
- // when the element enters its tree. We don't process immediately —
1018
- // observe instead, so a hidden route's blocks don't run hljs until the
1019
- // user actually navigates there.
790
+ // `x-code` on any element observe rather than process, so hidden routes
791
+ // don't run hljs until navigated to.
1020
792
  Alpine.directive('code', (el) => {
1021
793
  if (el.dataset.codeProcessed === 'yes') return;
1022
794
  ensureObserver().observe(el);
1023
795
  });
1024
796
 
1025
- // `x-code-group` on a wrapper element; sets up tabs across [name]
1026
- // children. The wrapper has no expression value.
797
+ // `x-code-group` wrapper; tabs across [name] children.
1027
798
  Alpine.directive('code-group', (el) => {
1028
799
  if (el.dataset.groupProcessed === 'yes') return;
1029
800
  ensureObserver().observe(el);
@@ -1056,8 +827,7 @@ async function ensureCodePluginInitialized() {
1056
827
 
1057
828
  window.ensureCodePluginInitialized = ensureCodePluginInitialized;
1058
829
 
1059
- // Expose select internals so the markdown plugin and other consumers can hook
1060
- // in without re-implementing the loaders or processors.
830
+ // Expose select internals for the markdown plugin and other consumers.
1061
831
  window.ManifestCode = {
1062
832
  loadHighlightJS,
1063
833
  loadCodeJar,