@hypermedia-components/core 0.1.4 → 0.1.5
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/dist/code-editor.d.ts +7 -3
- package/dist/code-editor.js +117 -24
- package/dist/code-syntax.d.ts +42 -0
- package/dist/code-syntax.js +308 -0
- package/dist/hc-code.css +76 -4
- package/dist/hc.behaviors.d.ts +1 -0
- package/dist/hc.behaviors.js +5 -0
- package/dist/hc.behaviors.min.js +4 -4
- package/dist/hc.core.css +12 -0
- package/dist/hc.core.min.css +1 -1
- package/dist/hc.css +88 -4
- package/dist/hc.min.css +1 -1
- package/dist/hc.min.js +4 -4
- package/dist/hc.tokens.core.css +12 -0
- package/dist/hc.tokens.css +12 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -1
- package/package.json +1 -1
- package/src/tokens/component.tokens.json +4 -1
- package/src/tokens/semantic.tokens.json +4 -1
- package/src/tokens/theme.dark.tokens.json +4 -1
package/dist/code-editor.d.ts
CHANGED
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
* Install the editable-code behavior on the given root.
|
|
3
3
|
*
|
|
4
4
|
* Enhances every `.hc-code[data-editable]` once and re-scans subtrees
|
|
5
|
-
* delivered by htmx (`htmx:load`).
|
|
6
|
-
*
|
|
5
|
+
* delivered by htmx (`htmx:load`). A field gets a synced line-number gutter
|
|
6
|
+
* when `data-gutter="line-numbers"` is set, and a live syntax-highlight overlay
|
|
7
|
+
* when `data-lang` resolves to a registered grammar (built-in or via
|
|
8
|
+
* `registerCodeLanguage()`). Repeated calls on the same root return the same
|
|
9
|
+
* uninstaller.
|
|
7
10
|
*
|
|
8
11
|
* @param {Document|Element} [root=document]
|
|
9
12
|
* The scope to scan. Defaults to the global document when available.
|
|
10
13
|
* @returns {() => void} an idempotent uninstaller that removes the synced
|
|
11
|
-
* gutters and listeners it added.
|
|
14
|
+
* gutters, overlays, and listeners it added.
|
|
12
15
|
*/
|
|
13
16
|
export function installCodeEditor(root?: Document | Element): () => void;
|
|
17
|
+
export { registerCodeLanguage } from "./code-syntax.js";
|
package/dist/code-editor.js
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
// installCodeEditor — upgrade an editable `hc-code` field with a synced
|
|
2
|
-
// line-number gutter (
|
|
2
|
+
// line-number gutter (#255) and an optional live syntax-highlight overlay
|
|
3
|
+
// (#264).
|
|
3
4
|
//
|
|
4
|
-
// <div class="hc-code" data-editable data-gutter="line-numbers">
|
|
5
|
+
// <div class="hc-code" data-editable data-gutter="line-numbers" data-lang="sql">
|
|
5
6
|
// <textarea class="hc-code__input" name="content" spellcheck="false">SELECT 1</textarea>
|
|
6
7
|
// </div>
|
|
7
8
|
//
|
|
8
9
|
// The value lives in a real <textarea name>, so it submits in forms, works
|
|
9
10
|
// with htmx (hx-post / hx-include / hx-vals), and degrades to a plain
|
|
10
|
-
// monospace textarea when this script is absent.
|
|
11
|
-
//
|
|
11
|
+
// monospace textarea when this script is absent.
|
|
12
|
+
//
|
|
13
|
+
// `data-gutter="line-numbers"` inserts a `.hc-code__gutter` element before the
|
|
12
14
|
// textarea and keeps it in sync: it re-numbers on input and matches the
|
|
13
|
-
// textarea's vertical scroll.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
15
|
+
// textarea's vertical scroll.
|
|
16
|
+
//
|
|
17
|
+
// `data-lang` opts into a live highlight overlay: when the value resolves to a
|
|
18
|
+
// registered grammar (see code-syntax.js — built-ins plus
|
|
19
|
+
// registerCodeLanguage()), the behavior inserts a decorative, aria-hidden
|
|
20
|
+
// `.hc-code__highlight` layer behind the textarea, re-tokenizes on input
|
|
21
|
+
// (throttled to one render per animation frame), and matches the textarea's
|
|
22
|
+
// scrollTop/scrollLeft. The textarea text is rendered transparent over the
|
|
23
|
+
// layer (CSS), so the coloured spans show through while the caret stays
|
|
24
|
+
// visible. An unknown `data-lang` (no grammar) leaves the field a plain
|
|
25
|
+
// textarea — no overlay, no transparent text, no regression to #255.
|
|
26
|
+
//
|
|
27
|
+
// To keep both overlays aligned with the lines the behavior sets the textarea
|
|
28
|
+
// to not soft-wrap (`wrap="off"`), so long lines scroll horizontally rather
|
|
29
|
+
// than pushing the numbers or tokens out of step.
|
|
16
30
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
31
|
+
// installCodeEditor(root = document) is idempotent and returns an uninstaller;
|
|
32
|
+
// fields swapped in by htmx are enhanced on `htmx:load`.
|
|
33
|
+
|
|
34
|
+
import { tokenizeCode, resolveCodeLanguage } from './code-syntax.js';
|
|
20
35
|
|
|
21
36
|
const INSTALL_KEY = '__hcCodeEditorUninstall';
|
|
22
37
|
|
|
@@ -29,44 +44,117 @@ function lineNumbers(count) {
|
|
|
29
44
|
function enhance(container) {
|
|
30
45
|
const textarea = container.querySelector('.hc-code__input');
|
|
31
46
|
if (!textarea) return null;
|
|
32
|
-
if (container.dataset.gutter !== 'line-numbers') return () => {};
|
|
33
|
-
if (container.querySelector('.hc-code__gutter')) return () => {};
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
const wantGutter = container.dataset.gutter === 'line-numbers';
|
|
49
|
+
const lang = container.dataset.lang;
|
|
50
|
+
const wantHighlight = !!resolveCodeLanguage(lang);
|
|
51
|
+
|
|
52
|
+
if (!wantGutter && !wantHighlight) return () => {};
|
|
53
|
+
// Already enhanced (defensive — the installer's WeakSet is the primary guard).
|
|
54
|
+
if (container.querySelector('.hc-code__gutter') || container.querySelector('.hc-code__highlight')) {
|
|
55
|
+
return () => {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const doc = container.ownerDocument;
|
|
59
|
+
const view = doc.defaultView;
|
|
60
|
+
|
|
61
|
+
// Keep the gutter numbers and overlay tokens aligned: a soft-wrapped line
|
|
62
|
+
// would span several rows while the gutter counts one and the overlay (pre)
|
|
63
|
+
// does not wrap. Horizontal scroll instead.
|
|
37
64
|
const prevWrap = textarea.getAttribute('wrap');
|
|
38
65
|
textarea.setAttribute('wrap', 'off');
|
|
39
66
|
|
|
40
|
-
|
|
41
|
-
gutter
|
|
42
|
-
gutter.setAttribute('aria-hidden', 'true');
|
|
43
|
-
container.insertBefore(gutter, textarea);
|
|
44
|
-
|
|
67
|
+
// --- Line-number gutter -------------------------------------------------
|
|
68
|
+
let gutter = null;
|
|
45
69
|
let lastCount = 0;
|
|
70
|
+
if (wantGutter) {
|
|
71
|
+
gutter = doc.createElement('div');
|
|
72
|
+
gutter.className = 'hc-code__gutter';
|
|
73
|
+
gutter.setAttribute('aria-hidden', 'true');
|
|
74
|
+
container.insertBefore(gutter, textarea);
|
|
75
|
+
}
|
|
46
76
|
const renumber = () => {
|
|
77
|
+
if (!gutter) return;
|
|
47
78
|
const count = Math.max(1, textarea.value.split('\n').length);
|
|
48
79
|
if (count !== lastCount) {
|
|
49
80
|
gutter.textContent = lineNumbers(count);
|
|
50
81
|
lastCount = count;
|
|
51
82
|
}
|
|
52
83
|
};
|
|
84
|
+
|
|
85
|
+
// --- Live highlight overlay --------------------------------------------
|
|
86
|
+
let highlight = null;
|
|
87
|
+
if (wantHighlight) {
|
|
88
|
+
highlight = doc.createElement('div');
|
|
89
|
+
highlight.className = 'hc-code__highlight';
|
|
90
|
+
highlight.setAttribute('aria-hidden', 'true');
|
|
91
|
+
container.insertBefore(highlight, textarea);
|
|
92
|
+
}
|
|
93
|
+
const renderHighlight = () => {
|
|
94
|
+
if (!highlight) return;
|
|
95
|
+
// Fall back to a single plain run if the grammar can't reconstruct this
|
|
96
|
+
// buffer, so the (transparent) textarea text always stays backed by
|
|
97
|
+
// visible overlay text.
|
|
98
|
+
const tokens = tokenizeCode(lang, textarea.value) || [{ tok: '', text: textarea.value }];
|
|
99
|
+
const frag = doc.createDocumentFragment();
|
|
100
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
101
|
+
const t = tokens[i];
|
|
102
|
+
if (t.tok) {
|
|
103
|
+
const span = doc.createElement('span');
|
|
104
|
+
span.className = 'hc-code__tok';
|
|
105
|
+
span.setAttribute('data-tok', t.tok);
|
|
106
|
+
span.textContent = t.text;
|
|
107
|
+
frag.appendChild(span);
|
|
108
|
+
} else {
|
|
109
|
+
frag.appendChild(doc.createTextNode(t.text));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
highlight.textContent = '';
|
|
113
|
+
highlight.appendChild(frag);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Throttle re-tokenization to one render per frame so large buffers stay
|
|
117
|
+
// responsive while typing.
|
|
118
|
+
let frame = 0;
|
|
119
|
+
const scheduleRender = () => {
|
|
120
|
+
if (!highlight) return;
|
|
121
|
+
if (!view || !view.requestAnimationFrame) {
|
|
122
|
+
renderHighlight();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (frame) return;
|
|
126
|
+
frame = view.requestAnimationFrame(() => {
|
|
127
|
+
frame = 0;
|
|
128
|
+
renderHighlight();
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
53
132
|
const syncScroll = () => {
|
|
54
|
-
gutter.scrollTop = textarea.scrollTop;
|
|
133
|
+
if (gutter) gutter.scrollTop = textarea.scrollTop;
|
|
134
|
+
if (highlight) {
|
|
135
|
+
highlight.scrollTop = textarea.scrollTop;
|
|
136
|
+
highlight.scrollLeft = textarea.scrollLeft;
|
|
137
|
+
}
|
|
55
138
|
};
|
|
139
|
+
|
|
56
140
|
const onInput = () => {
|
|
57
141
|
renumber();
|
|
142
|
+
scheduleRender();
|
|
58
143
|
syncScroll();
|
|
59
144
|
};
|
|
60
145
|
|
|
61
146
|
textarea.addEventListener('input', onInput);
|
|
62
147
|
textarea.addEventListener('scroll', syncScroll);
|
|
63
148
|
renumber();
|
|
149
|
+
renderHighlight();
|
|
64
150
|
syncScroll();
|
|
65
151
|
|
|
66
152
|
return () => {
|
|
153
|
+
if (frame && view && view.cancelAnimationFrame) view.cancelAnimationFrame(frame);
|
|
67
154
|
textarea.removeEventListener('input', onInput);
|
|
68
155
|
textarea.removeEventListener('scroll', syncScroll);
|
|
69
|
-
gutter.remove();
|
|
156
|
+
if (gutter) gutter.remove();
|
|
157
|
+
if (highlight) highlight.remove();
|
|
70
158
|
if (prevWrap == null) textarea.removeAttribute('wrap');
|
|
71
159
|
else textarea.setAttribute('wrap', prevWrap);
|
|
72
160
|
};
|
|
@@ -76,13 +164,16 @@ function enhance(container) {
|
|
|
76
164
|
* Install the editable-code behavior on the given root.
|
|
77
165
|
*
|
|
78
166
|
* Enhances every `.hc-code[data-editable]` once and re-scans subtrees
|
|
79
|
-
* delivered by htmx (`htmx:load`).
|
|
80
|
-
*
|
|
167
|
+
* delivered by htmx (`htmx:load`). A field gets a synced line-number gutter
|
|
168
|
+
* when `data-gutter="line-numbers"` is set, and a live syntax-highlight overlay
|
|
169
|
+
* when `data-lang` resolves to a registered grammar (built-in or via
|
|
170
|
+
* `registerCodeLanguage()`). Repeated calls on the same root return the same
|
|
171
|
+
* uninstaller.
|
|
81
172
|
*
|
|
82
173
|
* @param {Document|Element} [root=document]
|
|
83
174
|
* The scope to scan. Defaults to the global document when available.
|
|
84
175
|
* @returns {() => void} an idempotent uninstaller that removes the synced
|
|
85
|
-
* gutters and listeners it added.
|
|
176
|
+
* gutters, overlays, and listeners it added.
|
|
86
177
|
*/
|
|
87
178
|
export function installCodeEditor(root = (typeof document !== 'undefined' ? document : null)) {
|
|
88
179
|
if (!root) return () => {};
|
|
@@ -119,3 +210,5 @@ export function installCodeEditor(root = (typeof document !== 'undefined' ? docu
|
|
|
119
210
|
root[INSTALL_KEY] = uninstall;
|
|
120
211
|
return uninstall;
|
|
121
212
|
}
|
|
213
|
+
|
|
214
|
+
export { registerCodeLanguage } from './code-syntax.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register a tokenizer for `installCodeEditor()`'s live highlight overlay,
|
|
3
|
+
* keyed by the value of a field's `data-lang`. Registering a built-in name
|
|
4
|
+
* (`sql`, `json`, `yaml`, `html`, …) overrides it. Names are case-insensitive.
|
|
5
|
+
*
|
|
6
|
+
* The tokenizer must return tokens whose `text` parts reconstruct the input
|
|
7
|
+
* exactly; if they don't, the overlay safely declines to highlight that buffer
|
|
8
|
+
* rather than desync from the textarea.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} name e.g. `"tql-sql"`.
|
|
11
|
+
* @param {(text: string) => Array<{tok: string, text: string}>} tokenizer
|
|
12
|
+
* @returns {() => void} an uninstaller that removes this registration
|
|
13
|
+
* (restoring any built-in of the same name).
|
|
14
|
+
*/
|
|
15
|
+
export function registerCodeLanguage(name: string, tokenizer: (text: string) => Array<{
|
|
16
|
+
tok: string;
|
|
17
|
+
text: string;
|
|
18
|
+
}>): () => void;
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the tokenizer for a `data-lang` value: a consumer registration wins,
|
|
21
|
+
* then a built-in grammar, else `null` (no highlighting → plain textarea).
|
|
22
|
+
*
|
|
23
|
+
* @param {string} [name]
|
|
24
|
+
* @returns {((text: string) => Array<{tok: string, text: string}>) | null}
|
|
25
|
+
*/
|
|
26
|
+
export function resolveCodeLanguage(name?: string): ((text: string) => Array<{
|
|
27
|
+
tok: string;
|
|
28
|
+
text: string;
|
|
29
|
+
}>) | null;
|
|
30
|
+
/**
|
|
31
|
+
* Tokenize `text` as `lang`, returning `null` when there is no grammar, the
|
|
32
|
+
* tokenizer throws, or its tokens fail to reconstruct the source exactly. A
|
|
33
|
+
* `null` return tells the overlay to stay out of the way.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} lang
|
|
36
|
+
* @param {string} text
|
|
37
|
+
* @returns {Array<{tok: string, text: string}> | null}
|
|
38
|
+
*/
|
|
39
|
+
export function tokenizeCode(lang: string, text: string): Array<{
|
|
40
|
+
tok: string;
|
|
41
|
+
text: string;
|
|
42
|
+
}> | null;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// @hypermedia-components/core — code tokenizers for the live editable
|
|
2
|
+
// highlight overlay (issue #264).
|
|
3
|
+
//
|
|
4
|
+
// A tokenizer is `(text) => Array<{ tok, text }>`. The `text` parts,
|
|
5
|
+
// concatenated in order, must reconstruct the input exactly; `tok` is one of
|
|
6
|
+
// the hc-code `data-tok` values
|
|
7
|
+
//
|
|
8
|
+
// keyword | string | number | comment | operator | identifier
|
|
9
|
+
// property | tag | attribute | meta
|
|
10
|
+
//
|
|
11
|
+
// or a falsy value (`''` / `null`) for plain, uncoloured text. `installCodeEditor()`
|
|
12
|
+
// renders each token as a `<span class="hc-code__tok" data-tok="…">` (or a bare
|
|
13
|
+
// text node) into the overlay, coloured from the same `--hc-code-tok-*` palette
|
|
14
|
+
// as the server-tokenized read-only path (#261), so the editor matches the
|
|
15
|
+
// read-only / diff surfaces.
|
|
16
|
+
//
|
|
17
|
+
// Built-in grammars (`sql`, `json`, `yaml`, `html`) cover common cases. Register
|
|
18
|
+
// your own with `registerCodeLanguage(name, tokenizer)` — a dialect tokenizer
|
|
19
|
+
// can classify constructs a generic grammar can't (e.g. TesseraQL's 2-way SQL
|
|
20
|
+
// directives `/*%if … */` as `meta`). Everything here is CSP-safe: pure JS,
|
|
21
|
+
// no `eval` / `new Function`, no network.
|
|
22
|
+
|
|
23
|
+
// Consumer registrations live on a globalThis-keyed singleton so every inlined
|
|
24
|
+
// copy of this module (hc.js, hc.behaviors.js each bundle one) reads and writes
|
|
25
|
+
// the same registry — the same reason the i18n catalog is a singleton (#216).
|
|
26
|
+
// Built-ins are resolved as a fallback below, so registering a built-in name
|
|
27
|
+
// overrides it without mutating shared state.
|
|
28
|
+
const STATE_KEY = Symbol.for('hypermedia-components.code-languages');
|
|
29
|
+
const registry = globalThis[STATE_KEY] || (globalThis[STATE_KEY] = new Map());
|
|
30
|
+
|
|
31
|
+
const norm = (name) => String(name).toLowerCase();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run an ordered list of sticky-regex rules over `text`, accumulating
|
|
35
|
+
* unmatched characters as plain tokens. Each rule is `{ tok, re }` where `re`
|
|
36
|
+
* carries the `y` (sticky) flag; the first rule that matches at the current
|
|
37
|
+
* position wins. Reconstructs the input exactly.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} text
|
|
40
|
+
* @param {Array<{tok: string, re: RegExp}>} rules
|
|
41
|
+
* @returns {Array<{tok: string, text: string}>}
|
|
42
|
+
*/
|
|
43
|
+
function scan(text, rules) {
|
|
44
|
+
const out = [];
|
|
45
|
+
let plain = '';
|
|
46
|
+
const flush = () => {
|
|
47
|
+
if (plain) {
|
|
48
|
+
out.push({ tok: '', text: plain });
|
|
49
|
+
plain = '';
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
let i = 0;
|
|
53
|
+
const n = text.length;
|
|
54
|
+
while (i < n) {
|
|
55
|
+
let matched = false;
|
|
56
|
+
for (let r = 0; r < rules.length; r += 1) {
|
|
57
|
+
const { tok, re } = rules[r];
|
|
58
|
+
re.lastIndex = i;
|
|
59
|
+
const m = re.exec(text);
|
|
60
|
+
if (m && m.index === i && m[0].length > 0) {
|
|
61
|
+
flush();
|
|
62
|
+
out.push({ tok, text: m[0] });
|
|
63
|
+
i += m[0].length;
|
|
64
|
+
matched = true;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!matched) {
|
|
69
|
+
plain += text[i];
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
flush();
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- SQL ------------------------------------------------------------------
|
|
78
|
+
// A generic SQL grammar. 2-way-SQL block comments (`/* … */`) read as plain
|
|
79
|
+
// comments here; a dialect that wants its directives highlighted as `meta`
|
|
80
|
+
// registers its own tokenizer.
|
|
81
|
+
const SQL_KEYWORDS =
|
|
82
|
+
/(?:select|from|where|insert|into|values|update|set|delete|create|table|alter|drop|join|inner|left|right|outer|full|cross|on|group|by|order|having|limit|offset|union|all|distinct|as|and|or|not|in|is|null|like|between|exists|case|when|then|else|end|asc|desc|primary|key|foreign|references|default|index|view|with|returning|using|cast|coalesce|count|sum|avg|min|max|true|false)\b/iy;
|
|
83
|
+
|
|
84
|
+
function tokenizeSql(text) {
|
|
85
|
+
return scan(text, [
|
|
86
|
+
{ tok: 'comment', re: /--[^\n]*/y },
|
|
87
|
+
{ tok: 'comment', re: /\/\*[\s\S]*?\*\//y },
|
|
88
|
+
{ tok: 'string', re: /'(?:[^']|'')*'/y },
|
|
89
|
+
{ tok: 'number', re: /\b\d+(?:\.\d+)?\b/y },
|
|
90
|
+
{ tok: 'keyword', re: SQL_KEYWORDS },
|
|
91
|
+
{ tok: 'identifier', re: /[A-Za-z_][\w$]*/y },
|
|
92
|
+
{ tok: 'operator', re: /[-+*/%=<>!,;.()|&^~?@:[\]{}]+/y },
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- JSON -----------------------------------------------------------------
|
|
97
|
+
function tokenizeJson(text) {
|
|
98
|
+
return scan(text, [
|
|
99
|
+
// A string immediately before a colon is an object key → `property`.
|
|
100
|
+
{ tok: 'property', re: /"(?:[^"\\]|\\.)*"(?=\s*:)/y },
|
|
101
|
+
{ tok: 'string', re: /"(?:[^"\\]|\\.)*"/y },
|
|
102
|
+
{ tok: 'number', re: /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y },
|
|
103
|
+
{ tok: 'keyword', re: /\b(?:true|false|null)\b/y },
|
|
104
|
+
{ tok: 'operator', re: /[{}[\]:,]/y },
|
|
105
|
+
]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- YAML -----------------------------------------------------------------
|
|
109
|
+
function tokenizeYaml(text) {
|
|
110
|
+
return scan(text, [
|
|
111
|
+
{ tok: 'comment', re: /#[^\n]*/y },
|
|
112
|
+
{ tok: 'string', re: /'(?:[^']|'')*'/y },
|
|
113
|
+
{ tok: 'string', re: /"(?:[^"\\]|\\.)*"/y },
|
|
114
|
+
// Unquoted mapping key: a scalar followed by a colon + space / end-of-line.
|
|
115
|
+
{ tok: 'property', re: /[A-Za-z_][\w.\- ]*?(?=:(?:\s|$))/y },
|
|
116
|
+
{ tok: 'number', re: /\b-?\d+(?:\.\d+)?\b/y },
|
|
117
|
+
{ tok: 'keyword', re: /\b(?:true|false|null|yes|no|on|off)\b/iy },
|
|
118
|
+
{ tok: 'operator', re: /[:?,[\]{}]|(?:^|\s)-(?=\s)/y },
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- HTML -----------------------------------------------------------------
|
|
123
|
+
// Stateful: tag/attribute classification depends on being inside a `<…>`.
|
|
124
|
+
function tokenizeHtml(text) {
|
|
125
|
+
const out = [];
|
|
126
|
+
let plain = '';
|
|
127
|
+
const flush = () => {
|
|
128
|
+
if (plain) {
|
|
129
|
+
out.push({ tok: '', text: plain });
|
|
130
|
+
plain = '';
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const push = (tok, t) => {
|
|
134
|
+
if (t) {
|
|
135
|
+
flush();
|
|
136
|
+
out.push({ tok, text: t });
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
let i = 0;
|
|
140
|
+
const n = text.length;
|
|
141
|
+
const reComment = /<!--[\s\S]*?-->/y;
|
|
142
|
+
const reDoctype = /<![^>]*>/y;
|
|
143
|
+
const reTagOpen = /<\/?[A-Za-z][\w:-]*/y;
|
|
144
|
+
const reAttrName = /[^\s=/>]+/y;
|
|
145
|
+
const reValDq = /"[^"]*"/y;
|
|
146
|
+
const reValSq = /'[^']*'/y;
|
|
147
|
+
while (i < n) {
|
|
148
|
+
if (text[i] === '<') {
|
|
149
|
+
reComment.lastIndex = i;
|
|
150
|
+
let m = reComment.exec(text);
|
|
151
|
+
if (m && m.index === i) {
|
|
152
|
+
push('comment', m[0]);
|
|
153
|
+
i += m[0].length;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
reDoctype.lastIndex = i;
|
|
157
|
+
m = reDoctype.exec(text);
|
|
158
|
+
if (m && m.index === i) {
|
|
159
|
+
push('meta', m[0]);
|
|
160
|
+
i += m[0].length;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
reTagOpen.lastIndex = i;
|
|
164
|
+
m = reTagOpen.exec(text);
|
|
165
|
+
if (m && m.index === i) {
|
|
166
|
+
push('tag', m[0]);
|
|
167
|
+
i += m[0].length;
|
|
168
|
+
let expectValue = false;
|
|
169
|
+
while (i < n && text[i] !== '>') {
|
|
170
|
+
const c = text[i];
|
|
171
|
+
if (/\s/.test(c)) {
|
|
172
|
+
plain += c;
|
|
173
|
+
i += 1;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (c === '/') {
|
|
177
|
+
push('tag', '/');
|
|
178
|
+
i += 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (c === '=') {
|
|
182
|
+
push('operator', '=');
|
|
183
|
+
i += 1;
|
|
184
|
+
expectValue = true;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (c === '"') {
|
|
188
|
+
reValDq.lastIndex = i;
|
|
189
|
+
const v = reValDq.exec(text);
|
|
190
|
+
if (v && v.index === i) {
|
|
191
|
+
push('string', v[0]);
|
|
192
|
+
i += v[0].length;
|
|
193
|
+
expectValue = false;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (c === "'") {
|
|
198
|
+
reValSq.lastIndex = i;
|
|
199
|
+
const v = reValSq.exec(text);
|
|
200
|
+
if (v && v.index === i) {
|
|
201
|
+
push('string', v[0]);
|
|
202
|
+
i += v[0].length;
|
|
203
|
+
expectValue = false;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
reAttrName.lastIndex = i;
|
|
208
|
+
const a = reAttrName.exec(text);
|
|
209
|
+
if (a && a.index === i && a[0].length) {
|
|
210
|
+
push(expectValue ? 'string' : 'attribute', a[0]);
|
|
211
|
+
i += a[0].length;
|
|
212
|
+
expectValue = false;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
plain += c;
|
|
216
|
+
i += 1;
|
|
217
|
+
}
|
|
218
|
+
if (i < n && text[i] === '>') {
|
|
219
|
+
push('tag', '>');
|
|
220
|
+
i += 1;
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
plain += text[i];
|
|
226
|
+
i += 1;
|
|
227
|
+
}
|
|
228
|
+
flush();
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const BUILTINS = {
|
|
233
|
+
sql: tokenizeSql,
|
|
234
|
+
json: tokenizeJson,
|
|
235
|
+
yaml: tokenizeYaml,
|
|
236
|
+
yml: tokenizeYaml,
|
|
237
|
+
html: tokenizeHtml,
|
|
238
|
+
xml: tokenizeHtml,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Register a tokenizer for `installCodeEditor()`'s live highlight overlay,
|
|
243
|
+
* keyed by the value of a field's `data-lang`. Registering a built-in name
|
|
244
|
+
* (`sql`, `json`, `yaml`, `html`, …) overrides it. Names are case-insensitive.
|
|
245
|
+
*
|
|
246
|
+
* The tokenizer must return tokens whose `text` parts reconstruct the input
|
|
247
|
+
* exactly; if they don't, the overlay safely declines to highlight that buffer
|
|
248
|
+
* rather than desync from the textarea.
|
|
249
|
+
*
|
|
250
|
+
* @param {string} name e.g. `"tql-sql"`.
|
|
251
|
+
* @param {(text: string) => Array<{tok: string, text: string}>} tokenizer
|
|
252
|
+
* @returns {() => void} an uninstaller that removes this registration
|
|
253
|
+
* (restoring any built-in of the same name).
|
|
254
|
+
*/
|
|
255
|
+
export function registerCodeLanguage(name, tokenizer) {
|
|
256
|
+
if (typeof name !== 'string' || !name) {
|
|
257
|
+
throw new TypeError('registerCodeLanguage(name, tokenizer): name must be a non-empty string');
|
|
258
|
+
}
|
|
259
|
+
if (typeof tokenizer !== 'function') {
|
|
260
|
+
throw new TypeError('registerCodeLanguage(name, tokenizer): tokenizer must be a function');
|
|
261
|
+
}
|
|
262
|
+
const key = norm(name);
|
|
263
|
+
registry.set(key, tokenizer);
|
|
264
|
+
return () => {
|
|
265
|
+
if (registry.get(key) === tokenizer) registry.delete(key);
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Resolve the tokenizer for a `data-lang` value: a consumer registration wins,
|
|
271
|
+
* then a built-in grammar, else `null` (no highlighting → plain textarea).
|
|
272
|
+
*
|
|
273
|
+
* @param {string} [name]
|
|
274
|
+
* @returns {((text: string) => Array<{tok: string, text: string}>) | null}
|
|
275
|
+
*/
|
|
276
|
+
export function resolveCodeLanguage(name) {
|
|
277
|
+
if (!name) return null;
|
|
278
|
+
const key = norm(name);
|
|
279
|
+
return registry.get(key) || BUILTINS[key] || null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Tokenize `text` as `lang`, returning `null` when there is no grammar, the
|
|
284
|
+
* tokenizer throws, or its tokens fail to reconstruct the source exactly. A
|
|
285
|
+
* `null` return tells the overlay to stay out of the way.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} lang
|
|
288
|
+
* @param {string} text
|
|
289
|
+
* @returns {Array<{tok: string, text: string}> | null}
|
|
290
|
+
*/
|
|
291
|
+
export function tokenizeCode(lang, text) {
|
|
292
|
+
const fn = resolveCodeLanguage(lang);
|
|
293
|
+
if (!fn) return null;
|
|
294
|
+
let tokens;
|
|
295
|
+
try {
|
|
296
|
+
tokens = fn(text);
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
if (!Array.isArray(tokens)) return null;
|
|
301
|
+
let rebuilt = '';
|
|
302
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
303
|
+
const t = tokens[i];
|
|
304
|
+
if (!t || typeof t.text !== 'string') return null;
|
|
305
|
+
rebuilt += t.text;
|
|
306
|
+
}
|
|
307
|
+
return rebuilt === text ? tokens : null;
|
|
308
|
+
}
|
package/dist/hc-code.css
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Read-only syntax highlighting is server-tokenized (#261): the server emits
|
|
9
9
|
* `<span class="hc-code__tok" data-tok="…">` spans, coloured from the
|
|
10
|
-
* `--hc-code-tok-*` palette — no client tokenizer
|
|
11
|
-
*
|
|
10
|
+
* `--hc-code-tok-*` palette — no client tokenizer. The editable field gets the
|
|
11
|
+
* same palette via a live overlay (#264): `installCodeEditor()` renders the
|
|
12
|
+
* very same `hc-code__tok` spans into a synced `.hc-code__highlight` layer
|
|
13
|
+
* behind the textarea when `data-lang` resolves to a grammar. See "Syntax
|
|
14
|
+
* tokens" and "Live highlight overlay" below.
|
|
12
15
|
*
|
|
13
16
|
* 1. Plain block — apply to a <pre>:
|
|
14
17
|
*
|
|
@@ -276,10 +279,64 @@
|
|
|
276
279
|
outline: none;
|
|
277
280
|
}
|
|
278
281
|
|
|
282
|
+
/* --- Live highlight overlay (#264) ------------------------------------- */
|
|
283
|
+
|
|
284
|
+
/* A decorative, aria-hidden layer the behavior creates behind the textarea
|
|
285
|
+
* when `data-lang` resolves to a registered grammar. It mirrors the
|
|
286
|
+
* textarea's box and metrics exactly (same padding, font, line-height,
|
|
287
|
+
* `white-space: pre`); the behavior re-tokenizes on input and matches the
|
|
288
|
+
* textarea's scrollTop/scrollLeft. The textarea sits on top with transparent
|
|
289
|
+
* text but a visible caret, so the coloured spans below show through. No
|
|
290
|
+
* grammar (or no JS) → no overlay element → the textarea keeps its own text
|
|
291
|
+
* colour: the field stays a plain editable surface (no regression to #255). */
|
|
292
|
+
.hc-code__highlight {
|
|
293
|
+
position: absolute;
|
|
294
|
+
inset: 0;
|
|
295
|
+
z-index: 0;
|
|
296
|
+
box-sizing: border-box;
|
|
297
|
+
margin: 0;
|
|
298
|
+
padding-block: var(--hc-code-padding-block);
|
|
299
|
+
padding-inline: var(--hc-code-padding-inline);
|
|
300
|
+
overflow: hidden;
|
|
301
|
+
color: var(--hc-code-fg);
|
|
302
|
+
white-space: pre;
|
|
303
|
+
pointer-events: none;
|
|
304
|
+
user-select: none;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* Match the gutter offset so the overlay text aligns with the textarea. */
|
|
308
|
+
.hc-code[data-editable][data-gutter="line-numbers"] .hc-code__highlight {
|
|
309
|
+
padding-inline-start: var(--hc-code-gutter-width);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* When the overlay is live, the textarea glyphs go transparent (the caret
|
|
313
|
+
* stays visible) and the textarea rides above the overlay so the caret and
|
|
314
|
+
* selection are never occluded; the gutter sits above both. Driven by the
|
|
315
|
+
* overlay element's presence, so it only applies once the behavior attaches.
|
|
316
|
+
*
|
|
317
|
+
* Glyphs are hidden with `-webkit-text-fill-color`, not `color`: the visible
|
|
318
|
+
* text the user reads is the overlay (coloured from the validated
|
|
319
|
+
* `--hc-code-tok-*` palette), while the textarea keeps its real `color` so
|
|
320
|
+
* the caret resolves to the code foreground and contrast tooling still sees
|
|
321
|
+
* a legible control. */
|
|
322
|
+
.hc-code[data-editable]:has(.hc-code__highlight) .hc-code__input {
|
|
323
|
+
position: relative;
|
|
324
|
+
z-index: 1;
|
|
325
|
+
caret-color: var(--hc-code-fg);
|
|
326
|
+
-webkit-text-fill-color: transparent;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.hc-code[data-editable]:has(.hc-code__highlight) .hc-code__gutter {
|
|
330
|
+
z-index: 2;
|
|
331
|
+
}
|
|
332
|
+
|
|
279
333
|
/* --- Syntax tokens (#261) ---------------------------------------------- */
|
|
280
334
|
|
|
281
|
-
/*
|
|
282
|
-
*
|
|
335
|
+
/* Token spans — server-emitted in read-only modes, or rendered by the live
|
|
336
|
+
* overlay into `.hc-code__highlight`. Inside <pre><code>, an hc-code__line,
|
|
337
|
+
* or the overlay. `data-tok` is a generic, language-agnostic vocabulary:
|
|
338
|
+
* keyword / string / number / comment / operator / identifier plus the
|
|
339
|
+
* structured-markup set property / tag / attribute, and `meta` as the
|
|
283
340
|
* catch-all for language-specific constructs (e.g. 2-way SQL directives).
|
|
284
341
|
* An unknown or absent `data-tok` inherits the plain code colour. Token
|
|
285
342
|
* colour wins over a context-line's muted text; line tints (data-state)
|
|
@@ -312,4 +369,19 @@
|
|
|
312
369
|
.hc-code__tok[data-tok="meta"] {
|
|
313
370
|
color: var(--hc-code-tok-meta);
|
|
314
371
|
}
|
|
372
|
+
|
|
373
|
+
/* Structured-markup tokens: object/mapping keys (JSON, YAML), and HTML/XML
|
|
374
|
+
* tag and attribute names. Used by both the server-tokenized read-only path
|
|
375
|
+
* and the live editable overlay. */
|
|
376
|
+
.hc-code__tok[data-tok="property"] {
|
|
377
|
+
color: var(--hc-code-tok-property);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.hc-code__tok[data-tok="tag"] {
|
|
381
|
+
color: var(--hc-code-tok-tag);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.hc-code__tok[data-tok="attribute"] {
|
|
385
|
+
color: var(--hc-code-tok-attribute);
|
|
386
|
+
}
|
|
315
387
|
}
|
package/dist/hc.behaviors.d.ts
CHANGED