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.
- package/LICENSE +1 -1
- package/lib/manifest.accordion.css +4 -4
- package/lib/manifest.avatar.css +8 -8
- package/lib/manifest.button.css +7 -7
- package/lib/manifest.checkbox.css +5 -5
- package/lib/manifest.code.css +148 -201
- package/lib/manifest.code.js +841 -881
- package/lib/manifest.code.min.css +1 -1
- package/lib/manifest.colorpicker.css +11 -11
- package/lib/manifest.css +253 -207
- package/lib/manifest.data.js +4 -1
- package/lib/manifest.dialog.css +2 -2
- package/lib/manifest.divider.css +2 -2
- package/lib/manifest.dropdown.css +9 -9
- package/lib/manifest.form.css +10 -10
- package/lib/manifest.input.css +9 -9
- package/lib/manifest.integrity.json +6 -6
- package/lib/manifest.js +5 -5
- package/lib/manifest.markdown.js +129 -108
- package/lib/manifest.min.css +1 -1
- package/lib/manifest.radio.css +1 -1
- package/lib/manifest.range.css +7 -7
- package/lib/manifest.resize.css +1 -1
- package/lib/manifest.sidebar.css +2 -3
- package/lib/manifest.slides.css +5 -5
- package/lib/manifest.svg.js +34 -27
- package/lib/manifest.switch.css +4 -4
- package/lib/manifest.table.css +4 -4
- package/lib/manifest.theme.css +46 -41
- package/lib/manifest.toast.css +7 -7
- package/lib/manifest.tooltip.css +3 -3
- package/lib/manifest.tooltips.js +41 -0
- package/lib/manifest.typography.css +109 -50
- package/lib/manifest.utilities.css +41 -53
- package/package.json +1 -1
package/lib/manifest.code.js
CHANGED
|
@@ -1,974 +1,934 @@
|
|
|
1
1
|
/* Manifest Code
|
|
2
2
|
/* By Andrew Matlock under MIT license
|
|
3
|
-
/* https://
|
|
3
|
+
/* https://manifestx.dev
|
|
4
4
|
/*
|
|
5
5
|
/* With reference to:
|
|
6
6
|
/* - highlight.js (https://highlightjs.org)
|
|
7
|
-
/* -
|
|
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
|
-
//
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
if (hljsPromise) {
|
|
23
|
-
return hljsPromise;
|
|
24
|
-
}
|
|
96
|
+
});
|
|
97
|
+
return hljsFullPromise;
|
|
98
|
+
}
|
|
25
99
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
67
|
-
async function processExistingCodeBlocks() {
|
|
68
|
-
try {
|
|
69
|
-
const hljs = await loadHighlightJS();
|
|
184
|
+
// ─── Language resolution ─────────────────────────────────────────────────────
|
|
70
185
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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 `<`/`>` 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 < (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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
const codeElements = this.querySelectorAll('x-code');
|
|
292
|
+
// ─── Inline (<code x-code>, <span x-code>, etc.) ─────────────────────────────
|
|
238
293
|
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
//
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
return this.querySelector('code') || this;
|
|
366
|
-
}
|
|
493
|
+
// ─── Dispatch ────────────────────────────────────────────────────────────────
|
|
367
494
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
}
|
|
898
|
+
async function ensureCodePluginInitialized() {
|
|
899
|
+
if (codePluginInitialized) return;
|
|
900
|
+
codePluginInitialized = true;
|
|
862
901
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
if (title) {
|
|
866
|
-
codeElement.setAttribute('name', title);
|
|
867
|
-
}
|
|
902
|
+
registerAlpine();
|
|
903
|
+
document.addEventListener('alpine:init', registerAlpine);
|
|
868
904
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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',
|
|
912
|
+
document.addEventListener('DOMContentLoaded', () => observeAll());
|
|
893
913
|
} else {
|
|
894
|
-
|
|
914
|
+
observeAll();
|
|
895
915
|
}
|
|
896
916
|
|
|
897
|
-
//
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
//
|
|
974
|
-
|
|
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();
|