mnfst 0.5.81 → 0.5.82

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,974 +1,934 @@
1
1
  /* Manifest Code
2
2
  /* By Andrew Matlock under MIT license
3
- /* https://github.com/andrewmatlock/Manifest
3
+ /* https://manifestx.dev
4
4
  /*
5
5
  /* With reference to:
6
6
  /* - highlight.js (https://highlightjs.org)
7
- /* - Marked JS (https://marked.js.org)
7
+ /* - CodeJar (https://github.com/antonmedv/codejar)
8
8
  /*
9
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>
10
30
  */
11
31
 
12
- // Cache for highlight.js loading
13
- let hljsPromise = null;
32
+ // ─── Library loaders ─────────────────────────────────────────────────────────
33
+
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).
58
+
59
+ 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.
62
+ const HLJS_CORE_URL = 'https://esm.run/highlight.js@11.11.1/lib/core';
63
+ const HLJS_LANG_BASE = 'https://esm.run/highlight.js@11.11.1/lib/languages/';
64
+
65
+ let hljsCorePromise = null;
66
+ let hljsFullPromise = null;
67
+ 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.
71
+ let usingFullBundle = false;
72
+
73
+ function injectScript(src) {
74
+ return new Promise((resolve, reject) => {
75
+ const existing = document.querySelector(`script[src="${src}"]`);
76
+ if (existing) {
77
+ if (existing.dataset.loaded === 'yes') return resolve();
78
+ existing.addEventListener('load', () => resolve());
79
+ existing.addEventListener('error', () => reject(new Error(`load failed: ${src}`)));
80
+ return;
81
+ }
82
+ const s = document.createElement('script');
83
+ s.src = src;
84
+ s.async = false;
85
+ s.onload = () => { s.dataset.loaded = 'yes'; resolve(); };
86
+ s.onerror = () => reject(new Error(`load failed: ${src}`));
87
+ document.head.appendChild(s);
88
+ });
89
+ }
14
90
 
15
- // Load highlight.js from CDN
16
- async function loadHighlightJS() {
17
- if (typeof hljs !== 'undefined') {
91
+ async function loadHighlightFull() {
92
+ if (hljsFullPromise) return hljsFullPromise;
93
+ hljsFullPromise = injectScript(HLJS_FULL_URL).then(() => {
94
+ if (typeof hljs === 'undefined') throw new Error('hljs undefined after full load');
18
95
  return hljs;
19
- }
20
-
21
- // Return existing promise if already loading
22
- if (hljsPromise) {
23
- return hljsPromise;
24
- }
96
+ });
97
+ return hljsFullPromise;
98
+ }
25
99
 
26
- hljsPromise = new Promise((resolve, reject) => {
27
- const script = document.createElement('script');
28
- script.src = 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js';
29
- script.onload = () => {
30
- // Initialize highlight.js
31
- if (typeof hljs !== 'undefined') {
32
- resolve(hljs);
33
- } else {
34
- console.error('[Manifest Code] Highlight.js failed to load - hljs is undefined');
35
- hljsPromise = null; // Reset so we can try again
36
- reject(new Error('highlight.js failed to load'));
37
- }
38
- };
39
- script.onerror = (error) => {
40
- console.error('[Manifest Code] Script failed to load:', error);
41
- hljsPromise = null; // Reset so we can try again
42
- reject(error);
43
- };
44
- document.head.appendChild(script);
100
+ async function loadHighlightCore() {
101
+ if (hljsCorePromise) return hljsCorePromise;
102
+ hljsCorePromise = import(HLJS_CORE_URL).then(mod => {
103
+ // esm.run's CJS→ESM shim exposes hljs as the default export. Mirror
104
+ // it onto window so existing call sites (Alpine evaluator, the hero
105
+ // editor, etc.) that read `hljs` as a global keep working.
106
+ const hl = mod.default;
107
+ if (!hl) throw new Error('hljs undefined after core ESM import');
108
+ window.hljs = hl;
109
+ return hl;
45
110
  });
111
+ return hljsCorePromise;
112
+ }
113
+
114
+ async function registerLanguage(lang) {
115
+ if (!lang || lang === 'auto') return;
116
+ const resolved = LANGUAGE_ALIASES[lang] || lang;
117
+ const core = await loadHighlightCore();
118
+ if (core.listLanguages().includes(resolved)) return;
119
+ if (langLoadPromises.has(resolved)) return langLoadPromises.get(resolved);
120
+ const p = import(`${HLJS_LANG_BASE}${resolved}`)
121
+ .then(mod => { core.registerLanguage(resolved, mod.default); })
122
+ .catch(() => {
123
+ // If a language module fails to load (typo, network, deprecated
124
+ // grammar, etc.) the next call will fall back to the full bundle
125
+ // so the block at least renders with auto-detect.
126
+ usingFullBundle = true;
127
+ langLoadPromises.delete(resolved);
128
+ });
129
+ langLoadPromises.set(resolved, p);
130
+ return p;
131
+ }
46
132
 
47
- return hljsPromise;
133
+ // Public entry point. Called per code block with the block's requested
134
+ // language (or null/empty for auto-detect). Returns hljs ready to highlight
135
+ // `requestedLang`, or to auto-detect if we're in full mode.
136
+ async function loadHighlightJS(requestedLang = null) {
137
+ // Once we've committed to the full bundle (a prior block needed
138
+ // auto-detect, or a language module load failed) every subsequent
139
+ // call funnels through it.
140
+ if (usingFullBundle || hljsFullPromise) return loadHighlightFull();
141
+ // A per-block call asking for auto-detect → escalate.
142
+ if (!requestedLang || requestedLang === 'auto') {
143
+ usingFullBundle = true;
144
+ return loadHighlightFull();
145
+ }
146
+ // Lean path: core + just this language.
147
+ const core = await loadHighlightCore();
148
+ await registerLanguage(requestedLang);
149
+ return core;
48
150
  }
49
151
 
50
- // Optional optimization: Configure utilities plugin if present
152
+ let codeJarPromise = null;
153
+ async function loadCodeJar() {
154
+ if (typeof window.CodeJar === 'function') return window.CodeJar;
155
+ if (codeJarPromise) return codeJarPromise;
156
+ // codejar@4.3.0 ships as an ESM-only module. Import dynamically and expose
157
+ // on window for downstream consumers (e.g. the hero-editor demo).
158
+ codeJarPromise = import('https://cdn.jsdelivr.net/npm/codejar@4.3.0/dist/codejar.js')
159
+ .then(mod => {
160
+ window.CodeJar = mod.CodeJar;
161
+ return mod.CodeJar;
162
+ })
163
+ .catch(err => {
164
+ codeJarPromise = null;
165
+ throw err;
166
+ });
167
+ return codeJarPromise;
168
+ }
169
+
170
+ // Tell the utilities plugin to ignore code-related classes / elements so its
171
+ // utility-class scanner doesn't fight with hljs's mutations.
51
172
  if (window.ManifestUtilities) {
52
- // Tell utilities plugin to ignore code-related DOM changes and classes
53
173
  window.ManifestUtilities.addIgnoredClassPattern(/^hljs/);
54
174
  window.ManifestUtilities.addIgnoredClassPattern(/^language-/);
55
175
  window.ManifestUtilities.addIgnoredClassPattern(/^copy$/);
56
176
  window.ManifestUtilities.addIgnoredClassPattern(/^copied$/);
57
177
  window.ManifestUtilities.addIgnoredClassPattern(/^lines$/);
58
178
  window.ManifestUtilities.addIgnoredClassPattern(/^selected$/);
59
-
179
+ window.ManifestUtilities.addIgnoredClassPattern(/^expand$/);
60
180
  window.ManifestUtilities.addIgnoredElementSelector('pre');
61
181
  window.ManifestUtilities.addIgnoredElementSelector('code');
62
- window.ManifestUtilities.addIgnoredElementSelector('x-code');
63
- window.ManifestUtilities.addIgnoredElementSelector('x-code-group');
64
182
  }
65
183
 
66
- // Process existing pre/code blocks
67
- async function processExistingCodeBlocks() {
68
- try {
69
- const hljs = await loadHighlightJS();
184
+ // ─── Language resolution ─────────────────────────────────────────────────────
70
185
 
71
- // Find all pre > code blocks that aren't already processed
72
- // Exclude elements with frame class but allow those inside asides (frames)
73
- const codeBlocks = document.querySelectorAll('pre > code:not(.hljs):not([data-highlighted="yes"]):not(.frame)');
186
+ // Common shortenings that authors expect to work. highlight.js accepts most of
187
+ // these via its own alias system, but we resolve here so we can short-circuit
188
+ // the supported-language check before calling hljs.
189
+ const LANGUAGE_ALIASES = {
190
+ js: 'javascript', ts: 'typescript', py: 'python', rb: 'ruby',
191
+ sh: 'bash', shell: 'bash', yml: 'yaml', html: 'xml', svg: 'xml'
192
+ };
74
193
 
75
- for (const codeBlock of codeBlocks) {
76
- try {
77
-
78
- // Skip if the element contains HTML (has child elements)
79
- if (codeBlock.children.length > 0) {
80
- continue;
81
- }
82
-
83
- // Skip if the content looks like HTML (contains tags)
84
- let content = codeBlock.textContent || '';
85
- if (content.includes('<') && content.includes('>') && content.includes('</')) {
86
- // This looks like HTML content, skip highlighting to avoid security warnings
87
- continue;
88
- }
89
-
90
- // Special handling for frames - clean up content
91
- const isInsideFrame = codeBlock.closest('aside');
92
- if (isInsideFrame) {
93
- // Remove leading empty lines and whitespace
94
- content = content.replace(/^\s*\n+/, '');
95
- // Remove trailing empty lines and whitespace
96
- content = content.replace(/\n+\s*$/, '');
97
- // Also trim any remaining leading/trailing whitespace
98
- content = content.trim();
99
- // Update the code block content
100
- codeBlock.textContent = content;
101
- }
102
-
103
- const pre = codeBlock.parentElement;
104
-
105
- // Add title if present
106
- if (pre.hasAttribute('name') || pre.hasAttribute('title')) {
107
- const title = pre.getAttribute('name') || pre.getAttribute('title');
108
- const header = document.createElement('header');
109
-
110
- const titleElement = document.createElement('div');
111
- titleElement.textContent = title;
112
- header.appendChild(titleElement);
113
-
114
- pre.insertBefore(header, codeBlock);
115
- }
116
-
117
- // Add line numbers if requested
118
- if (pre.hasAttribute('numbers')) {
119
- const codeText = codeBlock.textContent;
120
- const lines = codeText.split('\n');
121
-
122
- const linesContainer = document.createElement('div');
123
- linesContainer.className = 'lines';
124
-
125
- for (let i = 0; i < lines.length; i++) {
126
- const lineSpan = document.createElement('span');
127
- lineSpan.textContent = (i + 1).toString();
128
- linesContainer.appendChild(lineSpan);
129
- }
130
-
131
- pre.insertBefore(linesContainer, codeBlock);
132
- }
133
-
134
- // Check if element has a supported language class
135
- const languageMatch = codeBlock.className.match(/language-(\w+)/);
136
- if (languageMatch) {
137
- const language = languageMatch[1];
138
-
139
- // Skip non-programming languages
140
- if (language === 'frame') {
141
- continue;
142
- }
143
-
144
- const supportedLanguages = hljs.listLanguages();
145
- const languageAliases = {
146
- 'js': 'javascript',
147
- 'ts': 'typescript',
148
- 'py': 'python',
149
- 'rb': 'ruby',
150
- 'sh': 'bash',
151
- 'yml': 'yaml'
152
- };
153
-
154
- let actualLanguage = language;
155
- if (languageAliases[language]) {
156
- actualLanguage = languageAliases[language];
157
- // Update the class name to use the correct language
158
- codeBlock.className = codeBlock.className.replace(`language-${language}`, `language-${actualLanguage}`);
159
- }
160
-
161
- // Only highlight if the language is supported
162
- if (!supportedLanguages.includes(actualLanguage)) {
163
- // Skip unsupported languages instead of warning
164
- continue;
165
- }
166
- } else {
167
- // Add default language class if not present
168
- codeBlock.className += ' language-css'; // Default to CSS for the example
169
- }
170
-
171
- // Highlight the code block
172
- hljs.highlightElement(codeBlock);
173
-
174
- } catch (error) {
175
- console.warn('[Manifest] Failed to process code block:', error);
176
- }
177
- }
178
- } catch (error) {
179
- console.warn('[Manifest] Failed to process existing code blocks:', error);
180
- }
194
+ function resolveLanguage(hljs, langAttr) {
195
+ if (!langAttr || langAttr === 'auto') return null;
196
+ const lang = LANGUAGE_ALIASES[langAttr] || langAttr;
197
+ return hljs.listLanguages().includes(lang) ? lang : null;
181
198
  }
182
199
 
183
- // Initialize plugin when either DOM is ready or Alpine is ready
184
- function initializeCodePlugin() {
185
- // Configuration object for the code plugin
186
- const config = {
187
- // Auto-highlight all code blocks
188
- autoHighlight: true,
189
- // Enable line numbers by default
190
- lineNumbers: false,
191
- // Show titles by default
192
- showTitles: true
193
- };
200
+ // ─── Content prep helpers ────────────────────────────────────────────────────
194
201
 
195
- // X-Code-Group custom element for tabbed code blocks
196
- class XCodeGroupElement extends HTMLElement {
197
- constructor() {
198
- super();
199
- }
200
-
201
- static get observedAttributes() {
202
- return ['numbers', 'copy'];
203
- }
204
-
205
- get numbers() {
206
- return this.hasAttribute('numbers');
207
- }
202
+ // Drop one leading and one trailing newline if present. Authors typically write
203
+ // <pre x-code> on its own line, which leaves stray newlines that throw off line
204
+ // numbering and the collapse threshold.
205
+ function trimWrappingNewlines(text) {
206
+ return text.replace(/^\n/, '').replace(/\n$/, '');
207
+ }
208
208
 
209
- get copy() {
210
- return this.hasAttribute('copy');
211
- }
209
+ // Remove the smallest common leading-whitespace block from every non-empty
210
+ // line. Sourced text that came indented under HTML markup looks indented
211
+ // inside the code block too, which is rarely what the author wants.
212
+ function dedent(text) {
213
+ const lines = text.split('\n');
214
+ let minIndent = Infinity;
215
+ for (const line of lines) {
216
+ if (line.trim() === '') continue;
217
+ const indent = line.length - line.trimStart().length;
218
+ if (indent < minIndent) minIndent = indent;
219
+ }
220
+ if (minIndent === Infinity || minIndent === 0) return text;
221
+ return lines.map(l => l.length >= minIndent ? l.slice(minIndent) : l).join('\n');
222
+ }
212
223
 
213
- connectedCallback() {
214
- // Small delay to ensure x-code elements are initialized
215
- setTimeout(() => {
216
- this.setupCodeGroup();
217
- }, 0);
218
- }
224
+ // Convert the raw textContent / innerHTML pulled from a host element into the
225
+ // canonical source string we feed to hljs and CodeJar.
226
+ function prepSource(raw) {
227
+ return dedent(trimWrappingNewlines(raw));
228
+ }
219
229
 
220
- attributeChangedCallback(name, oldValue, newValue) {
221
- if (oldValue !== newValue) {
222
- if (name === 'numbers' || name === 'copy') {
223
- this.updateAttributes();
224
- }
225
- }
226
- }
230
+ // Resolve the content for an element, honouring `from="#id"` (which reads the
231
+ // referenced element's innerHTML, preserving HTML markup as the rendered
232
+ // source) before falling back to the host element's own content.
233
+ //
234
+ // HTML examples are the special case: authors expect to write the literal
235
+ // markup (`<button>`, `<div>` etc.) without entity-escaping every char. The
236
+ // browser parses those into real DOM nodes inside the <pre>, so textContent
237
+ // would only see "ClickMe" instead of `<button>ClickMe</button>`. Reading
238
+ // innerHTML serialises the DOM back to source text, and a textarea-based
239
+ // decode unwraps any `&lt;`/`&gt;` entities so mixed-style authoring also
240
+ // works. For non-HTML languages we keep textContent — JS/CSS/etc. content
241
+ // rarely contains tags, and innerHTML would re-encode any entities the
242
+ // author DID write back as &lt; (preserving them in the highlighted output
243
+ // as literal text, which is wrong).
244
+ function resolveSource(el) {
245
+ const fromRef = el.getAttribute('from');
246
+ if (fromRef) {
247
+ const target = document.querySelector(fromRef);
248
+ if (target) return prepSource(target.innerHTML);
249
+ }
250
+ const lang = (el.getAttribute('x-code') || el.getAttribute('language') || '').toLowerCase();
251
+ if (HTML_LIKE_LANGS.has(lang)) {
252
+ const decoder = document.createElement('textarea');
253
+ decoder.innerHTML = el.innerHTML;
254
+ return prepSource(decoder.value);
255
+ }
256
+ return prepSource(el.textContent);
257
+ }
227
258
 
228
- setupCodeGroup() {
229
- // Idempotency: if a tab header is already present (from prerender output
230
- // or a previous connection), skip rebuilding it. Otherwise the DOM ends
231
- // up with a doubled tab row when the static page rehydrates.
232
- if (this.querySelector(':scope > header[aria-label="Code examples"]')) {
233
- return;
234
- }
259
+ // hljs aliases (resolved upstream) map to "xml" internally, but authors may
260
+ // type any of these. Keep them all in the set so the source-reading path
261
+ // behaves the same regardless of the spelling used in the attribute.
262
+ const HTML_LIKE_LANGS = new Set(['html', 'xml', 'svg', 'xhtml', 'rss', 'atom']);
263
+
264
+ // ─── Highlighting ────────────────────────────────────────────────────────────
265
+
266
+ // Apply syntax highlighting to a <code> element's textContent. Returns the
267
+ // language hljs actually used (or null when no highlighting was applied), so
268
+ // callers can mark the host element accordingly.
269
+ function highlightInto(codeEl, source, hljs, requestedLang) {
270
+ const lang = resolveLanguage(hljs, requestedLang);
271
+ if (lang) {
272
+ const result = hljs.highlight(source, { language: lang, ignoreIllegals: true });
273
+ codeEl.innerHTML = result.value;
274
+ codeEl.className = `hljs language-${lang}`;
275
+ codeEl.dataset.highlighted = 'yes';
276
+ return lang;
277
+ }
278
+ // Auto-detect path. Skip when the content looks like HTML markup — hljs
279
+ // logs a noisy warning for content that contains < and > in close
280
+ // proximity, which fires constantly on any HTML-fragment example.
281
+ codeEl.textContent = source;
282
+ if (!/^[^<]*<\w[^>]*>[^<]*<\/\w/.test(source)) {
283
+ try {
284
+ hljs.highlightElement(codeEl);
285
+ const detected = (codeEl.className.match(/language-([\w-]+)/) || [])[1] || null;
286
+ return detected;
287
+ } catch (e) { /* swallow; leave content as plain text */ }
288
+ }
289
+ return null;
290
+ }
235
291
 
236
- // Find all x-code elements within this group
237
- const codeElements = this.querySelectorAll('x-code');
292
+ // ─── Inline (<code x-code>, <span x-code>, etc.) ─────────────────────────────
238
293
 
239
- if (codeElements.length === 0) {
240
- return;
241
- }
294
+ async function setupInline(el, hljs) {
295
+ const source = resolveSource(el);
296
+ const requested = el.getAttribute('x-code') || el.getAttribute('language');
297
+ highlightInto(el, source, hljs, requested);
298
+ if (el.hasAttribute('copy')) setupInlineCopy(el);
299
+ }
242
300
 
243
- // Set default tab to first named code element
244
- // Always initialize codeTabs to prevent "codeTabs is not defined" errors
245
- const firstNamedCode = Array.from(codeElements).find(code => code.getAttribute('name'));
246
- if (firstNamedCode) {
247
- const defaultTab = firstNamedCode.getAttribute('name');
248
- this.setAttribute('x-data', `{ codeTabs: '${defaultTab}' }`);
249
- } else {
250
- // Initialize with empty string if no named code blocks exist
251
- // This prevents errors when buttons reference codeTabs
252
- this.setAttribute('x-data', `{ codeTabs: '' }`);
253
- }
301
+ function setupInlineCopy(el) {
302
+ if (!el.hasAttribute('role')) el.setAttribute('role', 'button');
303
+ if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
304
+ if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Click to copy');
305
+ const fire = async (ev) => {
306
+ if (ev.type === 'keydown' && ev.key !== 'Enter' && ev.key !== ' ') return;
307
+ if (ev.type === 'keydown') ev.preventDefault();
308
+ try {
309
+ await navigator.clipboard.writeText(el.textContent);
310
+ el.classList.add('copied');
311
+ setTimeout(() => el.classList.remove('copied'), 1500);
312
+ // Progressive enhancement: when the tooltip plugin is loaded and
313
+ // the author hasn't already bound an x-tooltip to this element,
314
+ // flash the copied-icon in a tooltip as confirmation. Skipped
315
+ // entirely if either condition isn't met — the .copied class
316
+ // toggle above is always the baseline feedback.
317
+ if (window.ManifestTooltips && typeof window.ManifestTooltips.showTransient === 'function'
318
+ && !el.hasAttribute('x-tooltip')) {
319
+ window.ManifestTooltips.showTransient(
320
+ el,
321
+ '<span class="code-copied-icon" aria-hidden="true"></span>',
322
+ 1500,
323
+ ['top', 'end']
324
+ );
325
+ }
326
+ } catch { /* clipboard rejected (browser permissions) — fail silently */ }
327
+ };
328
+ el.addEventListener('click', fire);
329
+ el.addEventListener('keydown', fire);
330
+ }
254
331
 
255
- // Create header for tabs
256
- const header = document.createElement('header');
257
-
258
- // Process each code element
259
- codeElements.forEach((codeElement, index) => {
260
- const name = codeElement.getAttribute('name');
261
-
262
- if (!name) {
263
- return; // Skip if no name attribute
264
- }
265
-
266
- // Create tab button
267
- const tabButton = document.createElement('button');
268
- tabButton.setAttribute('x-on:click', `codeTabs = '${name}'`);
269
- tabButton.setAttribute('x-bind:class', `codeTabs === '${name}' ? 'selected' : ''`);
270
- tabButton.setAttribute('role', 'tab');
271
- tabButton.setAttribute('aria-controls', `code-${name.replace(/\s+/g, '-').toLowerCase()}`);
272
- tabButton.setAttribute('x-bind:aria-selected', `codeTabs === '${name}' ? 'true' : 'false'`);
273
- tabButton.textContent = name;
274
-
275
- // Add keyboard navigation
276
- tabButton.addEventListener('keydown', (e) => {
277
- const tabs = header.querySelectorAll('button[role="tab"]');
278
- const currentIndex = Array.from(tabs).indexOf(tabButton);
279
-
280
- if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
281
- e.preventDefault();
282
- const nextIndex = e.key === 'ArrowRight'
283
- ? (currentIndex + 1) % tabs.length
284
- : (currentIndex - 1 + tabs.length) % tabs.length;
285
- tabs[nextIndex].focus();
286
- tabs[nextIndex].click();
287
- }
288
- });
289
-
290
- header.appendChild(tabButton);
291
-
292
- // Set up the code element for tabs
293
- codeElement.setAttribute('x-show', `codeTabs === '${name}'`);
294
- codeElement.setAttribute('id', `code-${name.replace(/\s+/g, '-').toLowerCase()}`);
295
- codeElement.setAttribute('role', 'tabpanel');
296
- codeElement.setAttribute('aria-labelledby', `tab-${name.replace(/\s+/g, '-').toLowerCase()}`);
297
-
298
- // Apply numbers and copy attributes from group if present
299
- if (this.numbers && !codeElement.hasAttribute('numbers')) {
300
- codeElement.setAttribute('numbers', '');
301
- }
302
- if (this.copy && !codeElement.hasAttribute('copy')) {
303
- codeElement.setAttribute('copy', '');
304
- }
305
- });
306
-
307
- // Set up header with proper ARIA attributes
308
- header.setAttribute('aria-label', 'Code examples');
309
-
310
- // Insert header at the beginning
311
- this.insertBefore(header, this.firstChild);
312
-
313
- // Set initial tab IDs after header is added
314
- const tabs = header.querySelectorAll('button[role="tab"]');
315
- tabs.forEach((tab, index) => {
316
- const name = tab.textContent.replace(/\s+/g, '-').toLowerCase();
317
- tab.setAttribute('id', `tab-${name}`);
318
- });
319
- }
332
+ // ─── Block (<pre x-code>) ────────────────────────────────────────────────────
333
+
334
+ async function setupBlock(pre, hljs) {
335
+ // Build-once guard. setupBlock can reach this function from two paths
336
+ // (processCodeElement for standalone, setupCodeGroup for panels) and a
337
+ // re-entry would read pre.textContent — which now includes the .lines
338
+ // gutter's "1\n2\n…" — as fresh source, repeatedly prepending the
339
+ // gutter digits to the code.
340
+ if (pre.dataset.codeBlockBuilt === 'yes') return;
341
+ pre.dataset.codeBlockBuilt = 'yes';
342
+
343
+ const source = resolveSource(pre);
344
+ const requested = pre.getAttribute('x-code') || pre.getAttribute('language');
345
+
346
+ // Reset internal structure. We always rebuild deterministically so the
347
+ // first call is the only one that lays anything out.
348
+ pre.innerHTML = '';
349
+
350
+ // ARIA: region + label when titled
351
+ const title = pre.getAttribute('name') || pre.getAttribute('title');
352
+ if (title && !pre.hasAttribute('aria-label')) pre.setAttribute('aria-label', title);
353
+ if (!pre.hasAttribute('role')) pre.setAttribute('role', 'region');
354
+
355
+ // Title bar — render only when not inside a code-group (the group's tab
356
+ // strip already shows the [name], so a per-panel title bar would
357
+ // duplicate it visually). The aria-label is set either way for assistive
358
+ // tech.
359
+ const inGroup = !!pre.closest('[x-code-group]');
360
+ if (title && !inGroup) {
361
+ const header = document.createElement('header');
362
+ const titleEl = document.createElement('div');
363
+ titleEl.textContent = title;
364
+ header.appendChild(titleEl);
365
+ pre.appendChild(header);
366
+ }
320
367
 
321
- updateAttributes() {
322
- const codeElements = this.querySelectorAll('x-code');
323
- codeElements.forEach(codeElement => {
324
- if (this.numbers) {
325
- codeElement.setAttribute('numbers', '');
326
- } else {
327
- codeElement.removeAttribute('numbers');
328
- }
329
- if (this.copy) {
330
- codeElement.setAttribute('copy', '');
331
- } else {
332
- codeElement.removeAttribute('copy');
333
- }
334
- });
368
+ // Line numbers
369
+ if (pre.hasAttribute('lines')) {
370
+ const lines = document.createElement('div');
371
+ lines.className = 'lines';
372
+ lines.setAttribute('aria-hidden', 'true');
373
+ const count = source.split('\n').length;
374
+ for (let i = 1; i <= count; i++) {
375
+ const span = document.createElement('span');
376
+ span.textContent = String(i);
377
+ lines.appendChild(span);
335
378
  }
379
+ pre.appendChild(lines);
336
380
  }
337
381
 
338
- // X-Code custom element
339
- class XCodeElement extends HTMLElement {
340
- constructor() {
341
- super();
342
- }
382
+ // Code element (the highlight target)
383
+ const code = document.createElement('code');
384
+ const actualLang = highlightInto(code, source, hljs, requested);
385
+ pre.appendChild(code);
386
+
387
+ // Copy button (floating, top-end). Suppressed when this block is a
388
+ // panel inside an [x-code-group] — the group itself owns the single
389
+ // wrapper-level copy button (which targets the active panel) so the
390
+ // affordance stays at the outermost container's top-end regardless
391
+ // of which child(ren) carry [copy].
392
+ if (pre.hasAttribute('copy') && !inGroup) setupBlockCopy(pre, code);
393
+
394
+ // Collapse
395
+ if (pre.hasAttribute('collapse')) setupCollapse(pre, code);
396
+
397
+ // Editor (lazy CodeJar)
398
+ if (pre.hasAttribute('edit')) {
399
+ const lang = actualLang || resolveLanguage(hljs, requested);
400
+ setupEditor(pre, code, lang, hljs);
401
+ }
402
+ }
343
403
 
344
- static get observedAttributes() {
345
- return ['language', 'numbers', 'title', 'copy'];
346
- }
404
+ function setupBlockCopy(pre, code) {
405
+ const btn = document.createElement('button');
406
+ btn.className = 'copy';
407
+ btn.type = 'button';
408
+ btn.setAttribute('aria-label', 'Copy code to clipboard');
409
+ btn.addEventListener('click', async () => {
410
+ try {
411
+ await navigator.clipboard.writeText(code.textContent);
412
+ btn.classList.add('copied');
413
+ setTimeout(() => btn.classList.remove('copied'), 1500);
414
+ } catch { /* clipboard rejected (browser permissions) — fail silently */ }
415
+ });
416
+ pre.appendChild(btn);
417
+ }
347
418
 
348
- get language() {
349
- return this.getAttribute('language') || 'auto';
350
- }
419
+ function setupCollapse(pre, code) {
420
+ const value = pre.getAttribute('collapse');
421
+ const threshold = parseInt(value, 10);
422
+ const collapseAt = Number.isFinite(threshold) && threshold > 0 ? threshold : 20;
423
+ const lineCount = code.textContent.split('\n').length;
424
+ if (lineCount <= collapseAt) return;
425
+
426
+ // Expose the threshold to CSS so the max-height matches the visible-line
427
+ // count exactly. Line-height in our typography is 1.5, so N lines === N
428
+ // × 1.5em of content height (plus pre padding handled by the selector).
429
+ pre.style.setProperty('--collapse-lines', String(collapseAt));
430
+ pre.setAttribute('data-collapsed', '');
431
+
432
+ const btn = document.createElement('button');
433
+ btn.className = 'expand';
434
+ btn.type = 'button';
435
+ btn.setAttribute('aria-expanded', 'false');
436
+ const hiddenCount = lineCount - collapseAt;
437
+ // Visual label is locale-safe (no translatable strings) — "+N" reads
438
+ // universally as "expand to see N more", "−" (U+2212 minus sign) as
439
+ // "collapse". Screen readers receive an explicit English aria-label
440
+ // so the action remains intelligible regardless of the visual glyph.
441
+ const updateLabel = () => {
442
+ const collapsed = pre.hasAttribute('data-collapsed');
443
+ btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
444
+ btn.textContent = collapsed ? `+${hiddenCount}` : '−';
445
+ btn.setAttribute('aria-label', collapsed
446
+ ? `Show ${hiddenCount} more line${hiddenCount === 1 ? '' : 's'}`
447
+ : 'Show less');
448
+ };
449
+ btn.addEventListener('click', () => {
450
+ if (pre.hasAttribute('data-collapsed')) pre.removeAttribute('data-collapsed');
451
+ else pre.setAttribute('data-collapsed', '');
452
+ updateLabel();
453
+ });
454
+ updateLabel();
455
+ pre.appendChild(btn);
456
+ }
351
457
 
352
- get numbers() {
353
- return this.hasAttribute('numbers');
458
+ async function setupEditor(pre, code, lang, hljs) {
459
+ try {
460
+ const CodeJar = await loadCodeJar();
461
+ code.setAttribute('contenteditable', 'plaintext-only');
462
+ // Some browsers (older Safari) don't support plaintext-only — fall back
463
+ // to plain "true". CodeJar handles paste sanitization either way.
464
+ if (code.getAttribute('contenteditable') !== 'plaintext-only' && code.contentEditable !== 'plaintext-only') {
465
+ code.setAttribute('contenteditable', 'true');
354
466
  }
355
-
356
- get title() {
357
- return this.getAttribute('name') || this.getAttribute('title');
467
+ code.setAttribute('spellcheck', 'false');
468
+ if (!pre.hasAttribute('aria-label')) {
469
+ pre.setAttribute('aria-label', lang ? `${lang} editor` : 'Code editor');
358
470
  }
359
471
 
360
- get copy() {
361
- return this.hasAttribute('copy');
362
- }
472
+ const editor = CodeJar(code, (el) => {
473
+ if (!lang) { /* no language: leave textContent as-is */ return; }
474
+ const text = el.textContent;
475
+ const result = hljs.highlight(text, { language: lang, ignoreIllegals: true });
476
+ el.innerHTML = result.value;
477
+ }, {
478
+ tab: ' ',
479
+ indentOn: /[{[(]\s*$|<[a-zA-Z][^<>]*(?<!\/)>$/,
480
+ addClosing: true
481
+ });
482
+ // CodeJar applies white-space: pre-wrap. Our convention for code blocks
483
+ // is true no-wrap (lines extend, parent scrolls), so override.
484
+ code.style.whiteSpace = 'pre';
485
+ // Expose the editor instance on the host so consumers (e.g. the hero
486
+ // editor) can wire onUpdate / updateCode / save / restore without
487
+ // re-mounting CodeJar themselves.
488
+ pre._codeJar = editor;
489
+ pre.dispatchEvent(new CustomEvent('code:editor-ready', { detail: { editor } }));
490
+ } catch { /* CodeJar load / mount failed — fail silently */ }
491
+ }
363
492
 
364
- get contentElement() {
365
- return this.querySelector('code') || this;
366
- }
493
+ // ─── Dispatch ────────────────────────────────────────────────────────────────
367
494
 
368
- connectedCallback() {
369
- // Idempotency: if the element already has a fully-rendered structure
370
- // (a <pre><code> child from prerender output), skip the rebuild.
371
- // Reading innerHTML and re-setting it as textContent (extractContent
372
- // line ~524) treats the prerendered hljs <span> markup as code
373
- // source, producing nested highlight-of-highlight output.
374
- const alreadyRendered =
375
- this.querySelector(':scope > pre > code') &&
376
- (this.hasAttribute('data-pre-rendered') ||
377
- this.querySelector(':scope > pre > code.hljs, :scope > pre > code[data-highlighted="yes"]'));
378
- if (!alreadyRendered) {
379
- this.setupElement();
380
- this.highlightCode();
381
- }
382
- }
495
+ // Process one host element. Routes to inline vs block based on tag.
496
+ async function processCodeElement(el) {
497
+ if (el.dataset.codeProcessed === 'yes') return;
498
+ // Group-owned panels are handled wholesale by setupCodeGroup: each
499
+ // <pre x-code> sibling is consumed into a <code> child of the new
500
+ // group wrapper. Skip here so we don't double-process / re-parent.
501
+ if (el.parentElement && el.parentElement.hasAttribute('x-code-group')) return;
502
+ el.dataset.codeProcessed = 'yes';
383
503
 
384
- attributeChangedCallback(name, oldValue, newValue) {
385
- if (oldValue !== newValue) {
386
- if (name === 'language') {
387
- this.highlightCode();
388
- } else if (name === 'numbers') {
389
- this.updateLineNumbers();
390
- } else if (name === 'title') {
391
- this.updateTitle();
392
- } else if (name === 'copy' && typeof this.updateCopyButton === 'function') {
393
- this.updateCopyButton();
394
- }
395
- }
504
+ try {
505
+ // Pass the requested language to the loader so it can stay in lean
506
+ // mode (core + only this language) when the rest of the page only
507
+ // uses explicit languages too.
508
+ const requestedLang = el.getAttribute('x-code') || el.getAttribute('language');
509
+ const hljs = await loadHighlightJS(requestedLang);
510
+ const tag = el.tagName;
511
+ const inPre = tag === 'CODE' && el.parentElement && el.parentElement.tagName === 'PRE';
512
+
513
+ if (tag === 'PRE') {
514
+ await setupBlock(el, hljs);
515
+ } else if (tag === 'CODE' && !inPre) {
516
+ await setupInline(el, hljs);
517
+ } else if (inPre) {
518
+ // <pre><code x-code="…"> — bubble up to the <pre> as the block host
519
+ const pre = el.parentElement;
520
+ // Migrate x-code attribute up to pre so the structure is uniform
521
+ if (!pre.hasAttribute('x-code')) {
522
+ pre.setAttribute('x-code', el.getAttribute('x-code') || '');
523
+ }
524
+ await setupBlock(pre, hljs);
525
+ } else {
526
+ // Arbitrary element (div, span, etc.) — treat as inline
527
+ await setupInline(el, hljs);
396
528
  }
529
+ } catch { /* highlight / setup failure — leave the block as plain text */ }
530
+ }
397
531
 
398
- setupElement() {
399
- // Extract content BEFORE adding any UI elements
400
- let content = this.extractContent();
401
-
402
- // Check if we have preserved original content for complete HTML documents
403
- const originalContent = this.getAttribute('data-original-content');
404
- if (originalContent) {
405
- // Use the preserved original content that includes document-level tags
406
- content = originalContent;
407
- // Remove the data attribute as we no longer need it
408
- this.removeAttribute('data-original-content');
409
- }
410
-
411
- // Create semantically correct structure: pre > code
412
- const pre = document.createElement('pre');
413
- const code = document.createElement('code');
414
-
415
- // Use textContent to preserve HTML tags as literal text
416
- // This ensures highlight.js treats the content as code, not HTML
417
- code.textContent = content;
418
- pre.appendChild(code);
419
- this.textContent = '';
420
- this.appendChild(pre);
421
-
422
- // Create title if present (after pre element is created) - but only if not in a code group
423
- if (this.title && !this.closest('x-code-group')) {
424
- const header = document.createElement('header');
425
-
426
- const title = document.createElement('div');
427
- title.textContent = this.title;
428
- header.appendChild(title);
429
-
430
- this.insertBefore(header, pre);
431
- }
432
-
433
- // Add line numbers if enabled
434
- if (this.numbers) {
435
- this.setupLineNumbers();
436
- }
437
-
438
- // Add copy button if enabled (after content extraction)
439
- if (this.copy) {
440
- this.setupCopyButton();
441
- }
442
-
443
- // If this is in a code group, ensure copy button comes after title in tab order
444
- const codeGroup = this.closest('x-code-group');
445
- if (codeGroup && this.copy) {
446
- const copyButton = this.querySelector('.copy');
447
- if (copyButton) {
448
- // Set tabindex to ensure it comes after header buttons in tab order
449
- copyButton.setAttribute('tabindex', '0');
450
- }
532
+ // ─── Code groups (tab strip across [name] siblings) ──────────────────────────
533
+
534
+ // Build the canonical group structure:
535
+ //
536
+ // <pre x-code-group> ← wrapper coordinates tabs
537
+ // <header role=tablist> (a <div x-code-group> source
538
+ // <button role=tab>HTML</button> is normalized to <pre> at
539
+ // <button role=tab>CSS</button> process time)
540
+ // </header>
541
+ // <pre x-code="html" name=HTML role=tabpanel>...full block...</pre>
542
+ // <pre x-code="css" name=CSS role=tabpanel style="display:none">...</pre>
543
+ // <aside class=frame name=Preview role=tabpanel style="display:none">...</aside>
544
+ // <button.copy> ← present when wrapper has [copy]
545
+ // </pre>
546
+ //
547
+ // Each code panel is a full <pre x-code> block with its own line numbers,
548
+ // collapse toggle, editor — i.e. it runs through setupBlock. The wrapper
549
+ // inherits per-panel feature attributes (lines, edit, collapse) to child
550
+ // panels that don't set them, so:
551
+ //
552
+ // <pre x-code-group lines>
553
+ // <pre x-code="html" name=HTML>...</pre>
554
+ // <pre x-code="css" name=CSS>...</pre>
555
+ // </pre>
556
+ //
557
+ // behaves as if each child had `lines` written on it directly. Children
558
+ // that DO set the attribute (or set it to a different value, e.g. per-panel
559
+ // collapse="5") win over inheritance.
560
+ //
561
+ // `copy` is NOT inherited — instead, when present on the wrapper, the
562
+ // plugin attaches a single copy button to the wrapper itself that targets
563
+ // whichever panel is currently active. This keeps the button on the
564
+ // element that actually carries the [copy] attribute, and avoids the
565
+ // visual clutter of one button per tab.
566
+ const GROUP_INHERITABLE_ATTRS = ['lines', 'edit', 'collapse'];
567
+
568
+ async function setupCodeGroup(group) {
569
+ if (group.dataset.groupProcessed === 'yes') return;
570
+
571
+ const sourcePanels = Array.from(group.children).filter(c => c.hasAttribute('name'));
572
+ if (sourcePanels.length === 0) return;
573
+ // Claim the group synchronously so re-entrant callers (the directive +
574
+ // observer can both arrive before the first call's `await` resolves)
575
+ // bail out — otherwise the wrapper accumulates duplicate tab strips.
576
+ group.dataset.groupProcessed = 'yes';
577
+
578
+ // Inherit feature attributes from wrapper to child <pre x-code> panels
579
+ // that don't set them. Run BEFORE setupBlock so the inherited attrs
580
+ // drive that block's setup.
581
+ for (const panel of sourcePanels) {
582
+ if (panel.tagName !== 'PRE') continue;
583
+ for (const attr of GROUP_INHERITABLE_ATTRS) {
584
+ if (group.hasAttribute(attr) && !panel.hasAttribute(attr)) {
585
+ panel.setAttribute(attr, group.getAttribute(attr));
451
586
  }
452
587
  }
588
+ }
453
589
 
454
- extractContent() {
455
- // Get the content and preserve original formatting
456
- let content = this.textContent;
457
-
458
- // Preserve intentional line breaks at the beginning and end
459
- // Only trim if there are no intentional line breaks
460
- const hasLeadingLineBreak = content.startsWith('\n');
461
- const hasTrailingLineBreak = content.endsWith('\n');
462
-
463
- // Trim but preserve intentional line breaks
464
- if (hasLeadingLineBreak) {
465
- content = '\n' + content.trimStart();
466
- } else {
467
- content = content.trimStart();
468
- }
469
-
470
- if (hasTrailingLineBreak) {
471
- content = content.trimEnd() + '\n';
472
- } else {
473
- content = content.trimEnd();
474
- }
475
-
476
- // Check if this is markdown-generated content (has preserved indentation)
477
- // Also check if this is inside a frame (aside element)
478
- const isInsideFrame = this.closest('aside');
479
- const hasPreservedIndentation = content.includes('\n ') || content.includes('\n\t');
480
-
481
- // Special handling for frames - remove leading and trailing empty lines
482
- if (isInsideFrame) {
483
- // If we have a title and the content starts with it, remove it
484
- if (this.title && content.startsWith(this.title)) {
485
- content = content.substring(this.title.length);
486
- // Remove any leading newline after removing title
487
- content = content.replace(/^\n+/, '');
488
- }
489
-
490
- // Remove leading empty lines and whitespace
491
- content = content.replace(/^\s*\n+/, '');
492
- // Remove trailing empty lines and whitespace
493
- content = content.replace(/\n+\s*$/, '');
494
- // Also trim any remaining leading/trailing whitespace
495
- content = content.trim();
496
- }
497
-
498
- if (!hasPreservedIndentation && content.includes('\n') && !isInsideFrame) {
499
- // Only normalize indentation for non-markdown content
500
- const hasTrailingLineBreakText = content.endsWith('\n');
501
- const lines = content.split('\n');
502
-
503
- // Find the minimum indentation (excluding empty lines and lines with no indentation)
504
- let minIndent = Infinity;
505
- for (const line of lines) {
506
- if (line.trim() !== '') {
507
- const indent = line.length - line.trimStart().length;
508
- if (indent > 0) { // Only consider lines that actually have indentation
509
- minIndent = Math.min(minIndent, indent);
510
- }
511
- }
512
- }
513
-
514
- // Remove the common indentation from all lines
515
- if (minIndent < Infinity) {
516
- content = lines.map(line => {
517
- if (line.trim() === '') return '';
518
- const indent = line.length - line.trimStart().length;
519
- // Only remove indentation if the line has enough spaces
520
- return indent >= minIndent ? line.slice(minIndent) : line;
521
- }).join('\n');
522
-
523
- // Preserve trailing line break if it was originally there
524
- if (hasTrailingLineBreakText) {
525
- content += '\n';
526
- }
527
- }
528
- }
529
-
530
- // Check if the content was interpreted as HTML (has child nodes)
531
- if (this.children.length > 0) {
532
- // Extract the original HTML from the child nodes
533
- content = this.innerHTML;
534
-
535
- // Preserve intentional line breaks at the beginning and end
536
- const hasLeadingLineBreak = content.startsWith('\n');
537
- const hasTrailingLineBreak = content.endsWith('\n');
538
-
539
- // Trim but preserve intentional line breaks
540
- if (hasLeadingLineBreak) {
541
- content = '\n' + content.trimStart();
542
- } else {
543
- content = content.trimStart();
544
- }
545
-
546
- if (hasTrailingLineBreak) {
547
- content = content.trimEnd() + '\n';
548
- } else {
549
- content = content.trimEnd();
550
- }
551
-
552
- // Remove any copy button that might have been included
553
- content = content.replace(/<button[^>]*class="copy"[^>]*>.*?<\/button>/g, '');
554
-
555
- // Clean up empty attribute values (data-head="" -> data-head)
556
- content = content.replace(/(\w+)=""/g, '$1');
557
-
558
- // For HTML content, normalize indentation (but not for frames)
559
- const isInsideFrame = this.closest('aside');
560
- const hasTrailingLineBreakHtml = content.endsWith('\n');
561
- const lines = content.split('\n');
562
- if (lines.length > 1 && !isInsideFrame) {
563
- // Find the minimum indentation
564
- let minIndent = Infinity;
565
- for (const line of lines) {
566
- if (line.trim() !== '') {
567
- const indent = line.length - line.trimStart().length;
568
- if (indent > 0) {
569
- minIndent = Math.min(minIndent, indent);
570
- }
571
- }
572
- }
573
-
574
- // Remove the common indentation from all lines
575
- if (minIndent < Infinity) {
576
- content = lines.map(line => {
577
- if (line.trim() === '') return '';
578
- const indent = line.length - line.trimStart().length;
579
- return indent >= minIndent ? line.slice(minIndent) : line;
580
- }).join('\n');
581
-
582
- // Preserve trailing line break if it was originally there
583
- if (hasTrailingLineBreakHtml) {
584
- content += '\n';
585
- }
586
- }
587
- }
588
- }
590
+ // Ordered unique tab names. Multiple panels may share a name (e.g. a
591
+ // frame + a code block co-visible under one tab); each panel is shown
592
+ // independently when its tab activates.
593
+ const tabNames = [];
594
+ for (const p of sourcePanels) {
595
+ const n = p.getAttribute('name');
596
+ if (!tabNames.includes(n)) tabNames.push(n);
597
+ }
598
+ const active = tabNames[0];
599
+ const slugify = s => s.replace(/\s+/g, '-').toLowerCase();
600
+
601
+ // Preload hljs + every language needed across the group so each panel's
602
+ // setupBlock can synchronously highlight without re-loading.
603
+ const codeLangs = sourcePanels
604
+ .filter(p => p.tagName === 'PRE' && p.hasAttribute('x-code'))
605
+ .map(p => p.getAttribute('x-code'))
606
+ .filter(Boolean);
607
+ let hljs = null;
608
+ if (codeLangs.length > 0) {
609
+ hljs = await loadHighlightJS(codeLangs[0]);
610
+ for (const l of codeLangs.slice(1)) await registerLanguage(l);
611
+ }
589
612
 
590
- return content;
613
+ // Normalize wrapper to <pre>. Authors may write <div x-code-group> or
614
+ // <pre x-code-group>; we always end up with a <pre> for CSS uniformity.
615
+ // When converting from <div>, transplant children rather than re-creating
616
+ // — the same <pre x-code> nodes that Alpine has wired up keep their
617
+ // identity, so subsequent processOne calls (and any author refs into
618
+ // them) stay valid.
619
+ let pre;
620
+ if (group.tagName === 'PRE') {
621
+ pre = group;
622
+ } else {
623
+ pre = document.createElement('pre');
624
+ for (const a of group.attributes) pre.setAttribute(a.name, a.value);
625
+ while (group.firstChild) pre.appendChild(group.firstChild);
626
+ group.replaceWith(pre);
627
+ }
628
+ pre.dataset.groupProcessed = 'yes';
629
+ if (!pre.hasAttribute('role')) pre.setAttribute('role', 'region');
630
+
631
+ // Run each code panel through setupBlock so it gets its full feature
632
+ // treatment (line numbers, copy button, collapse, editor). Frames stay
633
+ // as-is. setupBlock detects [x-code-group] ancestor and suppresses the
634
+ // per-panel title bar — the tab strip header serves that role.
635
+ for (const panel of sourcePanels) {
636
+ if (panel.tagName === 'PRE' && panel.hasAttribute('x-code')) {
637
+ await setupBlock(panel, hljs);
638
+ panel.dataset.codeProcessed = 'yes';
591
639
  }
640
+ }
592
641
 
593
- async setupLineNumbers() {
594
- try {
595
- // Ensure the pre element exists and has content
596
- const pre = this.querySelector('pre');
597
-
598
- if (pre && !this.querySelector('.lines')) {
599
- // Make sure the pre element is properly set up first
600
- if (!pre.querySelector('code')) {
601
- const code = document.createElement('code');
602
- code.textContent = pre.textContent;
603
- pre.textContent = '';
604
- pre.appendChild(code);
605
- }
606
-
607
- // Count the lines using the actual DOM content
608
- const codeText = pre.textContent;
609
- const lines = codeText.split('\n');
610
-
611
- // Create the lines container
612
- const linesContainer = document.createElement('div');
613
- linesContainer.className = 'lines';
614
-
615
- // Add line number items for all lines (including empty ones)
616
- for (let i = 0; i < lines.length; i++) {
617
- const lineSpan = document.createElement('span');
618
- lineSpan.textContent = (i + 1).toString();
619
- linesContainer.appendChild(lineSpan);
620
- }
621
-
622
- // Insert line numbers before the pre element
623
- this.insertBefore(linesContainer, pre);
624
- }
625
- } catch (error) {
626
- console.warn('[Manifest] Failed to setup line numbers:', error);
642
+ // Tab strip. The <header> is purely a layout container; the role=tablist
643
+ // sits on an inner <div> that holds the tab buttons. That way the
644
+ // tablist can have its own overflow-x scrolling (when there are too
645
+ // many tabs to fit) without dragging sibling header content into the
646
+ // scroll region. CSS targets the inner element via [role="tablist"]
647
+ // no extra class needed.
648
+ const header = document.createElement('header');
649
+ const tablist = document.createElement('div');
650
+ tablist.setAttribute('role', 'tablist');
651
+ tablist.setAttribute('aria-label', 'Code examples');
652
+ header.appendChild(tablist);
653
+
654
+ const tabButtons = tabNames.map((name, i) => {
655
+ const btn = document.createElement('button');
656
+ btn.type = 'button';
657
+ btn.setAttribute('role', 'tab');
658
+ btn.id = `${slugify(name)}-tab-${i}`;
659
+ btn.textContent = name;
660
+ btn.setAttribute('aria-selected', name === active ? 'true' : 'false');
661
+ btn.tabIndex = name === active ? 0 : -1;
662
+ btn.addEventListener('click', () => activate(name));
663
+ tablist.appendChild(btn);
664
+ return btn;
665
+ });
666
+ tabButtons.forEach((btn, idx) => {
667
+ btn.addEventListener('keydown', (ev) => {
668
+ if (ev.key === 'ArrowRight' || ev.key === 'ArrowLeft') {
669
+ ev.preventDefault();
670
+ const next = ev.key === 'ArrowRight'
671
+ ? (idx + 1) % tabButtons.length
672
+ : (idx - 1 + tabButtons.length) % tabButtons.length;
673
+ tabButtons[next].focus();
674
+ tabButtons[next].click();
675
+ } else if (ev.key === 'Home') {
676
+ ev.preventDefault(); tabButtons[0].focus(); tabButtons[0].click();
677
+ } else if (ev.key === 'End') {
678
+ ev.preventDefault(); tabButtons[tabButtons.length - 1].focus(); tabButtons[tabButtons.length - 1].click();
627
679
  }
680
+ });
681
+ });
682
+ pre.insertBefore(header, pre.firstChild);
683
+
684
+ // Wire ARIA / IDs on each panel so screen-reader navigation maps tab → panel.
685
+ // setupBlock sets role="region" on its <pre>; override to "tabpanel" here
686
+ // because the group context is more specific (the tablist is what labels it).
687
+ sourcePanels.forEach((panel, i) => {
688
+ const name = panel.getAttribute('name');
689
+ const tabBtn = tabButtons[tabNames.indexOf(name)];
690
+ panel.id = panel.id || `${slugify(name)}-panel-${i}`;
691
+ panel.setAttribute('role', 'tabpanel');
692
+ if (tabBtn && !panel.hasAttribute('aria-labelledby')) {
693
+ panel.setAttribute('aria-labelledby', tabBtn.id);
628
694
  }
695
+ });
629
696
 
630
- async setupCopyButton() {
697
+ // Wrapper-level copy button. Created when [copy] is on the wrapper OR on
698
+ // any child panel — the button always lives at the group's top-end so
699
+ // the affordance is in the same spot regardless of which child carries
700
+ // [copy]. setupBlock suppresses the per-panel copy button when inside a
701
+ // group (the inGroup check) so we never duplicate.
702
+ //
703
+ // Appended as a direct child of the <pre> (sibling to the header) so it
704
+ // can be absolutely positioned over the top-end of the wrapper without
705
+ // competing with the header's own overflow-scroll region. Same
706
+ // positioning rule as for a standalone <pre x-code copy>.
707
+ const wrapperHasCopy = pre.hasAttribute('copy');
708
+ const anyPanelCopy = sourcePanels.some(p => p.hasAttribute('copy'));
709
+ let copyBtn = null;
710
+ if (wrapperHasCopy || anyPanelCopy) {
711
+ copyBtn = document.createElement('button');
712
+ copyBtn.className = 'copy';
713
+ copyBtn.type = 'button';
714
+ copyBtn.setAttribute('aria-label', 'Copy code to clipboard');
715
+ copyBtn.addEventListener('click', async () => {
716
+ // When multiple panels share the active name (e.g. a paired
717
+ // frame + code), prefer the <pre x-code> for copy — the source
718
+ // is what an author wants to take, not the rendered frame's
719
+ // text content.
720
+ const sameName = sourcePanels.filter(p => p.getAttribute('name') === activeName);
721
+ const activePanel = sameName.find(p => p.tagName === 'PRE' && p.hasAttribute('x-code')) || sameName[0];
722
+ if (!activePanel) return;
723
+ const code = activePanel.querySelector(':scope > code') || activePanel;
631
724
  try {
632
- const copyButton = document.createElement('button');
633
- copyButton.className = 'copy';
634
- copyButton.setAttribute('aria-label', 'Copy code to clipboard');
635
- copyButton.setAttribute('type', 'button');
636
-
637
- copyButton.addEventListener('click', () => {
638
- this.copyCodeToClipboard();
639
- });
640
-
641
- // Add keyboard support
642
- copyButton.addEventListener('keydown', (e) => {
643
- if (e.key === 'Enter' || e.key === ' ') {
644
- e.preventDefault();
645
- this.copyCodeToClipboard();
646
- }
647
- });
648
-
649
- this.appendChild(copyButton);
650
- } catch (error) {
651
- console.warn('[Manifest] Failed to setup copy button:', error);
652
- }
653
- }
725
+ await navigator.clipboard.writeText(code.textContent);
726
+ copyBtn.classList.add('copied');
727
+ setTimeout(() => copyBtn.classList.remove('copied'), 1500);
728
+ } catch { /* clipboard rejected (browser permissions) — fail silently */ }
729
+ });
730
+ pre.appendChild(copyBtn);
731
+ }
654
732
 
655
- async copyCodeToClipboard() {
656
- try {
657
- const codeElement = this.contentElement;
658
- const codeText = codeElement.textContent;
659
-
660
- await navigator.clipboard.writeText(codeText);
661
-
662
- // Show copied state using CSS classes
663
- const copyButton = this.querySelector('.copy');
664
- if (copyButton) {
665
- copyButton.classList.add('copied');
666
- setTimeout(() => {
667
- copyButton.classList.remove('copied');
668
- }, 2000);
669
- }
670
- } catch (error) {
671
- console.warn('[Manifest] Failed to copy code:', error);
672
- }
733
+ // Visibility toggle. Explicit style.display rather than the [hidden]
734
+ // attribute, because pre's display:flex from typography.css outweighs
735
+ // the UA `[hidden] { display: none }` rule. Also flips the copy button
736
+ // visibility per-tab: when [copy] sits on the wrapper it stays visible
737
+ // for every tab; when it sits only on individual panels, the button
738
+ // shows for tabs whose panels carry [copy] and hides for the rest.
739
+ let activeName = active;
740
+ function activate(name) {
741
+ activeName = name;
742
+ tabButtons.forEach(btn => {
743
+ const isActive = btn.textContent === name;
744
+ btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
745
+ btn.tabIndex = isActive ? 0 : -1;
746
+ });
747
+ sourcePanels.forEach(panel => {
748
+ panel.style.display = panel.getAttribute('name') === name ? '' : 'none';
749
+ });
750
+ if (copyBtn) {
751
+ const activeCanCopy = wrapperHasCopy || sourcePanels
752
+ .filter(p => p.getAttribute('name') === name)
753
+ .some(p => p.hasAttribute('copy'));
754
+ copyBtn.style.display = activeCanCopy ? '' : 'none';
673
755
  }
756
+ }
757
+ activate(active);
758
+ }
674
759
 
675
- updateLineNumbers() {
676
- if (this.numbers) {
677
- this.setupLineNumbers();
678
- } else {
679
- // Remove line numbers if disabled
680
- const lines = this.querySelector('.lines');
681
- if (lines) {
682
- lines.remove();
683
- }
684
- }
760
+ // ─── Page scan + observation ─────────────────────────────────────────────────
761
+
762
+ // Markdown emits <pre><code class="language-X">…</code></pre>. Promote these
763
+ // to first-class hosts by setting x-code on the <pre> when we encounter them,
764
+ // so they flow through the same processor as authored <pre x-code> blocks.
765
+ // Accepts either a Document/Element (scans descendants) or a single <pre>
766
+ // element (adopts just that one).
767
+ function adoptMarkdownBlocks(root = document) {
768
+ if (root && root.tagName === 'PRE' && !root.hasAttribute('x-code')) {
769
+ const code = root.querySelector(':scope > code[class*="language-"]');
770
+ if (!code) return;
771
+ const match = code.className.match(/language-([\w-]+)/);
772
+ root.setAttribute('x-code', match ? match[1] : '');
773
+ if (!root.hasAttribute('name') && root.hasAttribute('title')) {
774
+ root.setAttribute('name', root.getAttribute('title'));
685
775
  }
686
-
687
- async highlightCode() {
688
- try {
689
- // Ensure highlight.js is loaded
690
- const hljs = await loadHighlightJS();
691
-
692
- const codeElement = this.contentElement;
693
-
694
- // Skip if this element contains HTML (has child elements)
695
- if (codeElement.children.length > 0) {
696
- return;
697
- }
698
-
699
- // Only skip HTML content for auto-detection, not when language is explicitly specified
700
- const content = codeElement.textContent || '';
701
-
702
- // Reset highlighting if already highlighted
703
- if (codeElement.dataset.highlighted === 'yes') {
704
- delete codeElement.dataset.highlighted;
705
- // Clear all highlight.js related classes
706
- codeElement.className = codeElement.className.replace(/\bhljs\b|\blanguage-\w+\b/g, '').trim();
707
- }
708
-
709
- // Set language class if specified
710
- if (this.language && this.language !== 'auto') {
711
- // Skip non-programming languages
712
- if (this.language === 'frame') {
713
- return;
714
- }
715
-
716
- // Check if the language is supported by highlight.js
717
- const supportedLanguages = hljs.listLanguages();
718
- const languageAliases = {
719
- 'js': 'javascript',
720
- 'ts': 'typescript',
721
- 'py': 'python',
722
- 'rb': 'ruby',
723
- 'sh': 'bash',
724
- 'yml': 'yaml',
725
- 'html': 'xml'
726
- };
727
-
728
- let actualLanguage = this.language;
729
- if (languageAliases[this.language]) {
730
- actualLanguage = languageAliases[this.language];
731
- }
732
-
733
- // Only highlight if language is supported, otherwise skip highlighting
734
- if (supportedLanguages.includes(actualLanguage)) {
735
- // Use hljs.highlight() with specific language to avoid auto-detection
736
- const result = hljs.highlight(codeElement.textContent, { language: actualLanguage });
737
- codeElement.innerHTML = result.value;
738
- codeElement.className = `language-${actualLanguage} hljs`;
739
- codeElement.dataset.highlighted = 'yes';
740
- } else {
741
- // Skip unsupported languages
742
- return;
743
- }
744
- } else {
745
- // For auto-detection, only proceed if content doesn't look like HTML
746
- if (content.includes('<') && content.includes('>') && content.includes('</')) {
747
- // Skip HTML-like content to avoid security warnings during auto-detection
748
- return;
749
- }
750
-
751
- // Remove any existing language class for auto-detection
752
- codeElement.className = codeElement.className.replace(/\blanguage-\w+/g, '');
753
-
754
- // Use highlightElement for auto-detection when no specific language
755
- hljs.highlightElement(codeElement);
756
- }
757
-
758
- } catch (error) {
759
- console.warn(`[Manifest] Failed to highlight code:`, error);
760
- }
776
+ return;
777
+ }
778
+ const candidates = root.querySelectorAll('pre:not([x-code]):not([data-code-processed]) > code[class*="language-"]');
779
+ for (const code of candidates) {
780
+ const pre = code.parentElement;
781
+ if (!pre) continue;
782
+ const match = code.className.match(/language-([\w-]+)/);
783
+ const lang = match ? match[1] : '';
784
+ pre.setAttribute('x-code', lang);
785
+ if (!pre.hasAttribute('name') && pre.hasAttribute('title')) {
786
+ pre.setAttribute('name', pre.getAttribute('title'));
761
787
  }
788
+ }
789
+ }
762
790
 
763
- update() {
764
- this.highlightCode();
791
+ // Per-element IntersectionObserver — each candidate processes on its own
792
+ // when it scrolls into view. This is important for two reasons:
793
+ // 1. SPA routes that aren't currently visible (display:none from the
794
+ // router) shouldn't trigger loadHighlightJS for their hidden blocks.
795
+ // A page-wide scan would scoop them up and an auto-detect block in a
796
+ // hidden route would push the loader into full-bundle mode even
797
+ // though the visible route is lean-mode eligible.
798
+ // 2. Long pages with many code blocks don't pay the highlight cost up
799
+ // front — each block runs hljs only when it nears the viewport.
800
+ let codeIO = null;
801
+
802
+ function ensureObserver() {
803
+ if (codeIO) return codeIO;
804
+ codeIO = new IntersectionObserver((entries, observer) => {
805
+ for (const entry of entries) {
806
+ if (!entry.isIntersecting) continue;
807
+ // The router uses display:none to hide inactive SPA routes. There's
808
+ // a small window during initial Alpine boot where the IO's initial
809
+ // entry for an element fires as "intersecting" before the router
810
+ // has applied display:none. checkVisibility() is the source of
811
+ // truth; if the element is actually hidden, leave it observed so
812
+ // a future route change re-fires the IO when it becomes visible.
813
+ const t = entry.target;
814
+ if (typeof t.checkVisibility === 'function' && !t.checkVisibility()) continue;
815
+ observer.unobserve(t);
816
+ handleVisible(t);
765
817
  }
818
+ }, { rootMargin: '100px', threshold: 0 });
819
+ return codeIO;
820
+ }
766
821
 
767
- updateTitle() {
768
- let titleElement = this.querySelector('header div');
769
- if (this.title) {
770
- if (!titleElement) {
771
- titleElement = document.createElement('div');
772
- titleElement.textContent = this.title;
773
- this.insertBefore(titleElement, this.firstChild);
774
- }
775
- titleElement.textContent = this.title;
776
- } else if (titleElement) {
777
- titleElement.remove();
778
- }
779
- }
822
+ function handleVisible(el) {
823
+ // When any code element in the active route first crosses into view,
824
+ // eagerly process every currently-visible candidate on the page. This
825
+ // avoids the "popping" effect of incremental highlighting as the user
826
+ // scrolls — once hljs is loaded, everything currently on screen gets
827
+ // styled at once. Below-the-fold and hidden-route candidates stay
828
+ // observed and process when they later become visible.
829
+ const candidates = document.querySelectorAll(
830
+ '[x-code]:not([data-code-processed]),' +
831
+ '[x-code-group]:not([data-group-processed]),' +
832
+ 'pre:not([x-code]):not([data-code-processed]) > code[class*="language-"]'
833
+ );
834
+ // Always include the triggering element (it's already known to be visible).
835
+ processOne(el);
836
+ for (const c of candidates) {
837
+ if (c === el) continue;
838
+ const visible = typeof c.checkVisibility === 'function' ? c.checkVisibility() : true;
839
+ if (!visible) continue;
840
+ codeIO && codeIO.unobserve(c);
841
+ processOne(c);
842
+ }
843
+ }
780
844
 
781
- updateCopyButton() {
782
- const existingCopyButton = this.querySelector('.copy');
783
-
784
- if (this.copy) {
785
- if (!existingCopyButton) {
786
- // Only add copy button if setupElement has already been called
787
- // (i.e., if we have a pre element)
788
- if (this.querySelector('pre')) {
789
- this.setupCopyButton();
790
- }
791
- // Otherwise, the copy button will be added in setupElement()
792
- }
793
- } else {
794
- if (existingCopyButton) {
795
- existingCopyButton.remove();
796
- }
797
- }
798
- }
845
+ function processOne(el) {
846
+ if (el.hasAttribute && el.hasAttribute('x-code-group')) {
847
+ setupCodeGroup(el);
848
+ } else if (el.hasAttribute && el.hasAttribute('x-code')) {
849
+ processCodeElement(el);
850
+ } else if (el.matches && el.matches('pre > code[class*="language-"]')) {
851
+ adoptMarkdownBlocks(el.parentElement);
852
+ processCodeElement(el.parentElement);
799
853
  }
854
+ }
800
855
 
801
- // Initialize the plugin
802
- async function initialize() {
803
- try {
804
- // Register the custom element
805
- if (!customElements.get('x-code')) {
806
- customElements.define('x-code', XCodeElement);
807
- }
808
- if (!customElements.get('x-code-group')) {
809
- customElements.define('x-code-group', XCodeGroupElement);
810
- }
856
+ // Start observing every candidate in `root` that hasn't already been
857
+ // processed. Idempotent — re-observation of an already-observed element
858
+ // is a no-op per the IntersectionObserver spec.
859
+ function observeAll(root = document) {
860
+ const io = ensureObserver();
861
+ root.querySelectorAll('[x-code]:not([data-code-processed])').forEach(el => io.observe(el));
862
+ root.querySelectorAll('[x-code-group]:not([data-group-processed])').forEach(el => io.observe(el));
863
+ root.querySelectorAll('pre:not([x-code]):not([data-code-processed]) > code[class*="language-"]').forEach(el => io.observe(el));
864
+ }
811
865
 
812
- // Listen for markdown plugin conversions (always process when new blocks appear)
813
- const runProcess = () => processExistingCodeBlocks();
814
- document.addEventListener('manifest:code-blocks-converted', runProcess);
815
- if (document.body) {
816
- document.body.addEventListener('manifest:code-blocks-converted', runProcess);
817
- }
866
+ // Re-scan after markdown injections (the markdown plugin dispatches this
867
+ // when a fenced-code render is appended to the DOM).
868
+ function onCodeBlocksConverted() {
869
+ observeAll();
870
+ }
818
871
 
819
- // Defer loading highlight.js until first code block is in view (or process immediately if none to observe)
820
- const codeTargets = document.querySelectorAll('pre > code:not(.hljs):not([data-highlighted="yes"]), x-code:not([data-highlighted="yes"])');
821
- if (codeTargets.length === 0) {
822
- return;
823
- }
824
- const io = new IntersectionObserver((entries) => {
825
- if (!entries.some(e => e.isIntersecting)) return;
826
- io.disconnect();
827
- runProcess();
828
- }, { rootMargin: '100px', threshold: 0 });
829
- codeTargets.forEach(el => io.observe(el));
830
- } catch (error) {
831
- console.error('[Manifest] Failed to initialize code plugin:', error);
832
- }
833
- }
872
+ // ─── Initialization ──────────────────────────────────────────────────────────
834
873
 
835
- // Alpine.js directive for code highlighting (only if Alpine is available)
836
- if (typeof Alpine !== 'undefined') {
837
- Alpine.directive('code', (el, { expression, modifiers }, { effect, evaluateLater }) => {
838
- // Create x-code element
839
- const codeElement = document.createElement('x-code');
840
-
841
- // Get language from various possible sources
842
- let language = 'auto';
843
-
844
- // Check for language attribute first
845
- const languageAttr = el.getAttribute('language');
846
- if (languageAttr) {
847
- language = languageAttr;
848
- } else if (expression && typeof expression === 'string' && !expression.includes('.')) {
849
- // Fallback to expression if it's a simple string
850
- language = expression;
851
- } else if (modifiers.length > 0) {
852
- // Fallback to first modifier
853
- language = modifiers[0];
854
- }
874
+ let codePluginInitialized = false;
875
+
876
+ function registerAlpine() {
877
+ if (typeof Alpine === 'undefined' || typeof Alpine.directive !== 'function') return;
878
+ if (window.__manifestCodeDirectivesRegistered) return;
879
+ window.__manifestCodeDirectivesRegistered = true;
880
+
881
+ // `x-code="language"` on any element. Alpine fires the callback once
882
+ // when the element enters its tree. We don't process immediately —
883
+ // observe instead, so a hidden route's blocks don't run hljs until the
884
+ // user actually navigates there.
885
+ Alpine.directive('code', (el) => {
886
+ if (el.dataset.codeProcessed === 'yes') return;
887
+ ensureObserver().observe(el);
888
+ });
855
889
 
856
- codeElement.setAttribute('language', language);
890
+ // `x-code-group` on a wrapper element; sets up tabs across [name]
891
+ // children. The wrapper has no expression value.
892
+ Alpine.directive('code-group', (el) => {
893
+ if (el.dataset.groupProcessed === 'yes') return;
894
+ ensureObserver().observe(el);
895
+ });
896
+ }
857
897
 
858
- // Enable line numbers if specified
859
- if (modifiers.includes('numbers') || modifiers.includes('line-numbers') || el.hasAttribute('numbers')) {
860
- codeElement.setAttribute('numbers', '');
861
- }
898
+ async function ensureCodePluginInitialized() {
899
+ if (codePluginInitialized) return;
900
+ codePluginInitialized = true;
862
901
 
863
- // Set title from various possible sources
864
- const title = el.getAttribute('name') || el.getAttribute('title') || el.getAttribute('data-title');
865
- if (title) {
866
- codeElement.setAttribute('name', title);
867
- }
902
+ registerAlpine();
903
+ document.addEventListener('alpine:init', registerAlpine);
868
904
 
869
- // Move content to x-code element
870
- const content = el.textContent.trim();
871
- codeElement.textContent = content;
872
- el.textContent = '';
873
- el.appendChild(codeElement);
874
-
875
- // Handle dynamic content updates only if expression is a variable
876
- if (expression && (expression.includes('.') || !['javascript', 'css', 'html', 'python', 'ruby', 'php', 'java', 'c', 'cpp', 'csharp', 'go', 'sql', 'json', 'yaml', 'markdown', 'typescript', 'jsx', 'tsx', 'scss', 'sass', 'less', 'xml', 'markup'].includes(expression))) {
877
- const getContent = evaluateLater(expression);
878
- effect(() => {
879
- getContent((content) => {
880
- if (content && typeof content === 'string') {
881
- codeElement.textContent = content;
882
- codeElement.update();
883
- }
884
- });
885
- });
886
- }
887
- });
905
+ // Markdown plugin hand-off
906
+ document.addEventListener('manifest:code-blocks-converted', onCodeBlocksConverted);
907
+ if (document.body) {
908
+ document.body.addEventListener('manifest:code-blocks-converted', onCodeBlocksConverted);
888
909
  }
889
910
 
890
- // Handle both DOMContentLoaded and alpine:init
891
911
  if (document.readyState === 'loading') {
892
- document.addEventListener('DOMContentLoaded', initialize);
912
+ document.addEventListener('DOMContentLoaded', () => observeAll());
893
913
  } else {
894
- initialize();
914
+ observeAll();
895
915
  }
896
916
 
897
- // Listen for Alpine initialization (only if Alpine is available)
898
- if (typeof Alpine !== 'undefined') {
899
- document.addEventListener('alpine:init', initialize);
900
- } else {
901
- // If Alpine isn't available yet, listen for it to become available
902
- document.addEventListener('alpine:init', () => {
903
- // Re-register the directive when Alpine becomes available
904
- if (typeof Alpine !== 'undefined') {
905
- Alpine.directive('code', (el, { expression, modifiers }, { effect, evaluateLater }) => {
906
- // Create x-code element
907
- const codeElement = document.createElement('x-code');
908
-
909
- // Get language from various possible sources
910
- let language = 'auto';
911
-
912
- // Check for language attribute first
913
- const languageAttr = el.getAttribute('language');
914
- if (languageAttr) {
915
- language = languageAttr;
916
- } else if (expression && typeof expression === 'string' && !expression.includes('.')) {
917
- // Fallback to expression if it's a simple string
918
- language = expression;
919
- } else if (modifiers.length > 0) {
920
- // Fallback to first modifier
921
- language = modifiers[0];
922
- }
923
-
924
- codeElement.setAttribute('language', language);
925
-
926
- // Enable line numbers if specified
927
- if (modifiers.includes('numbers') || modifiers.includes('line-numbers') || el.hasAttribute('numbers')) {
928
- codeElement.setAttribute('numbers', '');
929
- }
930
-
931
- // Set title from various possible sources
932
- const title = el.getAttribute('name') || el.getAttribute('title') || el.getAttribute('data-title');
933
- if (title) {
934
- codeElement.setAttribute('name', title);
935
- }
936
-
937
- // Move content to x-code element
938
- const content = el.textContent.trim();
939
- codeElement.textContent = content;
940
- el.textContent = '';
941
- el.appendChild(codeElement);
942
-
943
- // Handle dynamic content updates only if expression is a variable
944
- if (expression && (expression.includes('.') || !['javascript', 'css', 'html', 'python', 'ruby', 'php', 'java', 'c', 'cpp', 'csharp', 'go', 'sql', 'json', 'yaml', 'markdown', 'typescript', 'jsx', 'tsx', 'scss', 'sass', 'less', 'xml', 'markup'].includes(expression))) {
945
- const getContent = evaluateLater(expression);
946
- effect(() => {
947
- getContent((content) => {
948
- if (content && typeof content === 'string') {
949
- codeElement.textContent = content;
950
- codeElement.update();
951
- }
952
- });
953
- });
954
- }
955
- });
956
- }
957
- });
958
- }
959
- }
960
-
961
- // Track initialization to prevent duplicates
962
- let codePluginInitialized = false;
963
-
964
- async function ensureCodePluginInitialized() {
965
- if (codePluginInitialized) return;
966
- codePluginInitialized = true;
967
- await initializeCodePlugin();
917
+ // Re-observe after SPA route changes so newly-visible routes' blocks
918
+ // are picked up (Alpine directives already fired on initial mount).
919
+ window.addEventListener('manifest:route-change', () => observeAll());
968
920
  }
969
921
 
970
- // Expose on window for loader to call if needed
971
922
  window.ensureCodePluginInitialized = ensureCodePluginInitialized;
972
923
 
973
- // Initialize the plugin
974
- ensureCodePluginInitialized();
924
+ // Expose select internals so the markdown plugin and other consumers can hook
925
+ // in without re-implementing the loaders or processors.
926
+ window.ManifestCode = {
927
+ loadHighlightJS,
928
+ loadCodeJar,
929
+ processCodeElement,
930
+ setupCodeGroup,
931
+ observeAll
932
+ };
933
+
934
+ ensureCodePluginInitialized();