@ape-egg/codie 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -18,11 +18,11 @@ highlight + format +editable +live output, inspect,
18
18
 
19
19
  ### Core Features
20
20
 
21
- - **Syntax Highlighting**: HTML, CSS, and JavaScript with language-aware highlighting
21
+ - **Syntax Highlighting**: HTML, CSS, JavaScript, JSON, shell and Python, detected from the code itself
22
22
  - **Editable Mode**: Transform static code into a live editor with textarea overlay
23
23
  - **Line Numbers**: Optional numbered rows for code reference
24
24
  - **Its Own Element**: `<codie>` is the block; `<template codie>` holds HTML the browser must not parse
25
- - **Language Modes**: Granular control via attributes (`highlight-html`, `highlight-css`, `highlight-js`)
25
+ - **Language Modes**: Pin a language via attributes (`highlight-html`, `highlight-css`, `highlight-js`, `highlight-json`)
26
26
  - **Dark/Light Themes**: Toggle with `[dark]` attribute
27
27
  - **Runtime State Toggle**: Change features dynamically via `codieRef.editable`, `codieRef.numberedRows`
28
28
 
@@ -98,16 +98,19 @@ editor.onEdit = ({ formatted, raw }) => {
98
98
  ### Language-Specific Highlighting
99
99
 
100
100
  ```html
101
- <!-- Only highlight HTML -->
101
+ <!-- HTML, with its <script>, <style> and on* handlers -->
102
102
  <template codie highlight-html>...</template>
103
103
 
104
- <!-- Only highlight CSS -->
104
+ <!-- CSS: stylesheets or bare declaration lists -->
105
105
  <codie highlight-css>...</codie>
106
106
 
107
- <!-- Only highlight JavaScript -->
107
+ <!-- JavaScript -->
108
108
  <codie highlight-js>...</codie>
109
109
 
110
- <!-- Highlight all languages (default) -->
110
+ <!-- JSON -->
111
+ <codie highlight-json>...</codie>
112
+
113
+ <!-- Detected from the code: HTML, CSS, JavaScript, JSON, shell, Python or plain text (default) -->
111
114
  <codie>...</codie>
112
115
  ```
113
116
 
@@ -174,9 +177,11 @@ A `<codie>` element is an instance by its tag. The attributes below go on it, or
174
177
 
175
178
  - `codie` - Marks an element that is not `<codie>` as an instance, `<template codie>` above all
176
179
  - `dehydrate` - Skip initialization (for documentation)
177
- - `highlight-html` - Enable HTML highlighting only
178
- - `highlight-css` - Enable CSS highlighting only
179
- - `highlight-js` - Enable JavaScript highlighting only
180
+ - `highlight-html` - Highlight as HTML, including embedded `<script>`, `<style>` and `on*` handlers
181
+ - `highlight-css` - Highlight as CSS
182
+ - `highlight-js` - Highlight as JavaScript
183
+ - `highlight-json` - Highlight as JSON
184
+ - none of the above - Detect the language from the code (HTML, CSS, JavaScript, JSON, shell, Python or plain text)
180
185
  - `dark` - Apply dark theme
181
186
 
182
187
  ### Runtime Attributes (applied by codie)
@@ -224,7 +229,7 @@ editor.onEdit = ({ formatted }) => {
224
229
 
225
230
  ### Implemented
226
231
 
227
- - ✓ Syntax highlighting (HTML, CSS, JS)
232
+ - ✓ Syntax highlighting (HTML, CSS, JS, JSON, shell, Python) with language detection
228
233
  - ✓ Editable mode with textarea overlay
229
234
  - ✓ Line numbers
230
235
  - ✓ Tab handling (insert spaces, multi-line indent, outdent)
package/codie.js CHANGED
@@ -1,13 +1,12 @@
1
1
  // Codie - Modular Code Display & Editing
2
2
  import { ATTR, CLASS } from './constants.js';
3
3
  import {
4
+ highlight,
5
+ detectLanguage,
4
6
  highlightHTML,
5
7
  highlightJS,
6
8
  highlightCSS,
7
- highlightJSON,
8
9
  highlightJSRaw,
9
- highlightCSSOnly,
10
- highlightJSOnly,
11
10
  } from './highlight.js';
12
11
  import {
13
12
  escapeHTML,
@@ -21,59 +20,19 @@ import { initNumberRows, updateNumberRows } from './numberRows.js';
21
20
  import { initEditable, destroyEditable } from './editable.js';
22
21
  import { initFoldable, destroyFoldable } from './foldable.js';
23
22
 
24
- // Detect which languages to highlight from element attributes
25
- const getHighlightModes = (el) => {
26
- const hasHTML = el.hasAttribute(ATTR.HIGHLIGHT_HTML);
27
- const hasCSS = el.hasAttribute(ATTR.HIGHLIGHT_CSS);
28
- const hasJS = el.hasAttribute(ATTR.HIGHLIGHT_JS);
29
- const hasJSON = el.hasAttribute(ATTR.HIGHLIGHT_JSON);
30
- const explicit = hasHTML || hasCSS || hasJS || hasJSON;
31
-
32
- return {
33
- html: !explicit || hasHTML,
34
- css: !explicit || hasCSS,
35
- js: !explicit || hasJS,
36
- json: !explicit || hasJSON,
37
- };
23
+ const LANGUAGE_ATTRS = {
24
+ html: ATTR.HIGHLIGHT_HTML,
25
+ json: ATTR.HIGHLIGHT_JSON,
26
+ css: ATTR.HIGHLIGHT_CSS,
27
+ js: ATTR.HIGHLIGHT_JS,
38
28
  };
39
29
 
40
- // Highlight code content based on enabled modes
41
- const highlight = (code, modes, options = {}) => {
42
- const escaped = escapeHTML(code);
43
-
44
- // Detect JSON by content: must start with { or [ AND contain quoted key patterns
45
- const looksLikeJSON = /^\s*[{\[]/.test(code) && /"[^"]*"\s*:/.test(code);
46
-
47
- // JSON takes priority over HTML when content looks like JSON
48
- if (modes.json && looksLikeJSON) {
49
- return highlightJSON(escaped);
50
- }
51
-
52
- if (modes.html && !looksLikeJSON) {
53
- return highlightHTML(escaped, {
54
- ...options,
55
- highlightJS: modes.js,
56
- highlightCSS: modes.css,
57
- });
58
- }
59
-
60
- // Standalone mode (no HTML): use direct highlighters, not the *Only variants
61
- if (!modes.html) {
62
- if (modes.json) return highlightJSON(escaped);
63
- if (modes.css) return highlightCSS(escaped);
64
- if (modes.js) return highlightJSRaw(code);
65
- return escaped;
66
- }
67
-
68
- let result = escaped;
69
- if (modes.js) result = highlightJSOnly(result);
70
- if (modes.css) result = highlightCSSOnly(result);
71
- return result;
72
- };
30
+ const languageOf = (el) =>
31
+ Object.keys(LANGUAGE_ATTRS).find((language) => el.hasAttribute(LANGUAGE_ATTRS[language]));
73
32
 
74
33
  // Toggle a feature on the instance
75
34
  const toggleFeature = (instance, feature, enabled) => {
76
- const { el, display, modes } = instance;
35
+ const { el, display, language, _code } = instance;
77
36
 
78
37
  switch (feature) {
79
38
  case 'numberedRows':
@@ -99,7 +58,7 @@ const toggleFeature = (instance, feature, enabled) => {
99
58
 
100
59
  case 'foldable':
101
60
  if (enabled && !el.hasAttribute(ATTR.FOLDABLE)) {
102
- initFoldable(el, display, modes);
61
+ initFoldable(el, display, language ?? detectLanguage(_code));
103
62
  el.setAttribute(ATTR.FOLDABLE, '');
104
63
  } else if (!enabled && el.hasAttribute(ATTR.FOLDABLE)) {
105
64
  destroyFoldable(el, display);
@@ -135,7 +94,7 @@ const codie = (elOrConfig = {}) => {
135
94
  return null;
136
95
  }
137
96
 
138
- const modes = getHighlightModes(el);
97
+ const language = languageOf(el);
139
98
  let raw = el.tagName === 'TEMPLATE' ? el.innerHTML : el.textContent;
140
99
 
141
100
  // Decode HTML entities from template innerHTML
@@ -149,7 +108,7 @@ const codie = (elOrConfig = {}) => {
149
108
  // Create display layer
150
109
  const display = document.createElement('pre');
151
110
  display.className = `${CLASS.CODE_LAYER} ${CLASS.DISPLAY}`;
152
- display.innerHTML = highlight(code, modes, {
111
+ display.innerHTML = highlight(code, language, {
153
112
  preserveWhitespace: !config.foldable,
154
113
  });
155
114
 
@@ -161,7 +120,7 @@ const codie = (elOrConfig = {}) => {
161
120
  const internal = {
162
121
  el,
163
122
  display,
164
- modes,
123
+ language,
165
124
  _code: code,
166
125
  _config: { ...config },
167
126
  textarea: null,
@@ -188,7 +147,7 @@ const codie = (elOrConfig = {}) => {
188
147
  // Decode HTML entities when setting code (same as initialization)
189
148
  const decoded = unescapeHTML(value);
190
149
  target._code = decoded;
191
- target.display.innerHTML = highlight(decoded, target.modes, {
150
+ target.display.innerHTML = highlight(decoded, target.language, {
192
151
  preserveWhitespace: !target._foldable,
193
152
  });
194
153
  if (target.lineNumbers) {
package/constants.js CHANGED
@@ -227,3 +227,17 @@ export const JS_IMPORT_KEYWORDS = new Set(['import', 'export', 'from'])
227
227
 
228
228
  // JavaScript bracket characters
229
229
  export const JS_BRACKETS = new Set(['{', '}', '(', ')', '[', ']'])
230
+
231
+ export const PY_KEYWORDS = new Set([
232
+ 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del',
233
+ 'elif', 'else', 'except', 'finally', 'for', 'global', 'if', 'in', 'is', 'lambda',
234
+ 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'try', 'while', 'with', 'yield',
235
+ 'None', 'True', 'False', 'self',
236
+ ])
237
+
238
+ export const PY_IMPORT_KEYWORDS = new Set(['import', 'from'])
239
+
240
+ export const SHELL_KEYWORDS = new Set([
241
+ 'if', 'then', 'else', 'elif', 'fi', 'for', 'in', 'do', 'done', 'case', 'esac',
242
+ 'while', 'until', 'select', 'function',
243
+ ])
package/editable.js CHANGED
@@ -1,42 +1,10 @@
1
1
  // Codie editable textarea layer
2
2
  import { CLASS } from './constants.js'
3
- import { highlightHTML, highlightJS, highlightCSS, highlightJSON, highlightJSRaw, highlightCSSOnly, highlightJSOnly } from './highlight.js'
4
- import { escapeHTML, formatDocument, normalizeInlineWhitespace, cleanupBooleanAttrs } from './format.js'
3
+ import { highlight } from './highlight.js'
4
+ import { formatDocument, normalizeInlineWhitespace, cleanupBooleanAttrs } from './format.js'
5
5
  import { updateNumberRows } from './numberRows.js'
6
6
  import keyboard from './keyboard.js'
7
7
 
8
- // Highlight code based on modes — same dispatch as codie.js, so an edit
9
- // re-renders through the same highlighter the initial display used
10
- const highlight = (code, modes) => {
11
- const escaped = escapeHTML(code)
12
-
13
- const looksLikeJSON = /^\s*[{\[]/.test(code) && /"[^"]*"\s*:/.test(code)
14
-
15
- if (modes.json && looksLikeJSON) {
16
- return highlightJSON(escaped)
17
- }
18
-
19
- if (modes.html && !looksLikeJSON) {
20
- return highlightHTML(escaped, {
21
- preserveWhitespace: true,
22
- highlightJS: modes.js,
23
- highlightCSS: modes.css,
24
- })
25
- }
26
-
27
- if (!modes.html) {
28
- if (modes.json) return highlightJSON(escaped)
29
- if (modes.css) return highlightCSS(escaped)
30
- if (modes.js) return highlightJSRaw(code)
31
- return escaped
32
- }
33
-
34
- let result = escaped
35
- if (modes.js) result = highlightJSOnly(result)
36
- if (modes.css) result = highlightCSSOnly(result)
37
- return result
38
- }
39
-
40
8
  // Initialize editable textarea
41
9
  export const initEditable = (el, instance, config) => {
42
10
  const textarea = document.createElement('textarea')
@@ -59,7 +27,7 @@ export const initEditable = (el, instance, config) => {
59
27
  const scrollLeft = el.scrollLeft
60
28
 
61
29
  // Update display
62
- instance.display.innerHTML = highlight(newCode, instance.modes)
30
+ instance.display.innerHTML = highlight(newCode, instance.language, { preserveWhitespace: true })
63
31
 
64
32
  // Restore scroll
65
33
  el.scrollTop = scrollTop
package/foldable.js CHANGED
@@ -124,9 +124,9 @@ const wrapWithFoldControls = (html, defaultOpenTags = ['html', 'body', 'head'])
124
124
  }
125
125
 
126
126
  // Initialize foldable on an element
127
- export const initFoldable = (el, display, modes) => {
127
+ export const initFoldable = (el, display, language) => {
128
128
  // Only fold HTML content
129
- if (!modes.html) return
129
+ if (language !== 'html') return
130
130
 
131
131
  const html = display.innerHTML
132
132
  display.innerHTML = wrapWithFoldControls(html)
package/highlight.js CHANGED
@@ -1,13 +1,31 @@
1
1
  // Codie syntax highlighting engine
2
- import { CLASS, JS_KEYWORDS, JS_IMPORT_KEYWORDS, JS_BRACKETS } from './constants.js';
3
-
4
- // HTML entity escaping
5
- const escapeHTML = (str) =>
6
- str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2
+ import {
3
+ CLASS,
4
+ JS_KEYWORDS,
5
+ JS_IMPORT_KEYWORDS,
6
+ JS_BRACKETS,
7
+ PY_KEYWORDS,
8
+ PY_IMPORT_KEYWORDS,
9
+ SHELL_KEYWORDS,
10
+ } from './constants.js';
11
+ import { escapeHTML } from './format.js';
7
12
 
8
13
  // Wrap in span with class
9
14
  const span = (cls, content) => `<span class="${cls}">${content}</span>`;
10
15
 
16
+ const JS = { keywords: JS_KEYWORDS, imports: JS_IMPORT_KEYWORDS };
17
+ const PYTHON = { keywords: PY_KEYWORDS, imports: PY_IMPORT_KEYWORDS };
18
+ const CALL = /\s*\(/y;
19
+
20
+ const wordClass = (word, code, end, { keywords, imports }) => {
21
+ if (imports.has(word)) return CLASS.JS_IMPORT;
22
+ if (keywords.has(word)) return CLASS.JS_KEYWORD;
23
+ CALL.lastIndex = end;
24
+ return CALL.test(code) ? CLASS.JS_FUNCTION : CLASS.JS_IDENT;
25
+ };
26
+
27
+ const bracketClass = (punct) => (JS_BRACKETS.has(punct) ? CLASS.JS_IMPORT : CLASS.PUNCTUATION);
28
+
11
29
  // Highlight @[expression] - vibe brackets + variable names
12
30
  const highlightBindings = (text) =>
13
31
  text.replace(/@\[([^\]]+)\]/g, (_, expr) => {
@@ -21,13 +39,6 @@ const highlightBindings = (text) =>
21
39
  return `${span(CLASS.VIBE_COLOR, '@[')}${highlighted}${span(CLASS.VIBE_COLOR, ']')}`;
22
40
  });
23
41
 
24
- // Highlight $.variable - vibe state access
25
- const highlightStateAccess = (text) =>
26
- text.replace(
27
- /\$\.([a-zA-Z_][a-zA-Z0-9_]*)/g,
28
- `${span(CLASS.VIBE_COLOR, '$.')}${span(CLASS.VIBE_VARIABLE, '$1')}`,
29
- );
30
-
31
42
  // Highlight variable names in Vibe comments
32
43
  const highlightCommentVariables = (content) => {
33
44
  let result = content.replace(
@@ -44,7 +55,7 @@ const highlightCommentVariables = (content) => {
44
55
  export const highlightJS = (code, options = {}) => {
45
56
  const tokens = [];
46
57
  const tokenRegex =
47
- /(@\[([^\]]+)\])|(&quot;(?:[^&]|&(?!quot;))*?&quot;)|('(?:[^'\\]|\\.)*')|(`(?:[^`\\]|\\.)*`)|((?<=^|\s)(?:\/\/|#)[^\n]*)|(\/\*[\s\S]*?\*\/)|(\$\.([a-zA-Z_][a-zA-Z0-9_]*))|(\b\d[\d_]*(?:\.[\d_]+)?\b)|(\b[a-zA-Z_][a-zA-Z0-9_]*\b)|([^\s])/g;
58
+ /(@\[([^\]]+)\])|(&quot;(?:[^&]|&(?!quot;))*?&quot;)|('(?:[^'\\]|\\.)*')|(`(?:[^`\\]|\\.)*`)|((?<=^|\s)(?:\/\/|#)[^\n]*)|(\/\*[\s\S]*?\*\/)|(\$\.([a-zA-Z_][a-zA-Z0-9_]*))|(\b\d[\d_]*(?:\.[\d_]+)?\b)|((?<![\w$])[a-zA-Z_$][\w$]*)|([^\s])/g;
48
59
  let match,
49
60
  lastIndex = 0;
50
61
 
@@ -90,15 +101,9 @@ export const highlightJS = (code, options = {}) => {
90
101
  } else if (number) {
91
102
  tokens.push(span(CLASS.JS_NUMBER, full));
92
103
  } else if (word) {
93
- if (JS_IMPORT_KEYWORDS.has(word)) tokens.push(span(CLASS.JS_IMPORT, full));
94
- else if (JS_KEYWORDS.has(word)) tokens.push(span(CLASS.JS_KEYWORD, full));
95
- else if (code.slice(tokenRegex.lastIndex).match(/^\s*\(/))
96
- tokens.push(span(CLASS.JS_FUNCTION, full));
97
- else tokens.push(span(CLASS.JS_IDENT, full));
104
+ tokens.push(span(wordClass(word, code, tokenRegex.lastIndex, JS), full));
98
105
  } else if (punct) {
99
- tokens.push(
100
- JS_BRACKETS.has(punct) ? span(CLASS.JS_IMPORT, full) : span(CLASS.PUNCTUATION, full),
101
- );
106
+ tokens.push(span(bracketClass(punct), full));
102
107
  } else {
103
108
  tokens.push(full);
104
109
  }
@@ -115,7 +120,7 @@ export const highlightJSRaw = (code) => {
115
120
  // A command line: a bare lowercase word then arguments, on one full row — no JS
116
121
  // keyword lead, no assignment/call/statement characters, no trailing continuation \
117
122
  const tokenRegex =
118
- /(^[ \t]*(?!(?:import|export|const|let|var|return|await|async|function|if|else|for|while|new|typeof|throw)\b)[a-z][\w.-]*[ \t]+[^\n=(){};]*[^\n\\=(){};])$|(<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[^>]*)?>)|("(?:[^"\\]|\\.)*")|('(?:[^'\\]|\\.)*')|(`(?:[^`\\]|\\.)*`)|((?<=^|\s)(?:\/\/|#)[^\n]*)|(\/\*[\s\S]*?\*\/)|(\$\.([a-zA-Z_][a-zA-Z0-9_]*))|(\b\d[\d_]*(?:\.[\d_]+)?\b)|(\b[a-zA-Z_][a-zA-Z0-9_]*\b)|([^\s])/gm;
123
+ /(^[ \t]*(?!(?:import|export|const|let|var|return|await|async|function|if|else|for|while|new|typeof|throw)\b)[a-z][\w.-]*[ \t]+[^\n=(){};]*[^\n\\=(){};])$|(<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[^>]*)?>)|("(?:[^"\\]|\\.)*")|('(?:[^'\\]|\\.)*')|(`(?:[^`\\]|\\.)*`)|((?<=^|\s)(?:\/\/|#)[^\n]*)|(\/\*[\s\S]*?\*\/)|(\$\.([a-zA-Z_][a-zA-Z0-9_]*))|(\b\d[\d_]*(?:\.[\d_]+)?\b)|((?<![\w$])[a-zA-Z_$][\w$]*)|([^\s])/gm;
119
124
  let match,
120
125
  lastIndex = 0;
121
126
 
@@ -179,15 +184,9 @@ export const highlightJSRaw = (code) => {
179
184
  } else if (number) {
180
185
  tokens.push(span(CLASS.JS_NUMBER, escaped));
181
186
  } else if (word) {
182
- if (JS_IMPORT_KEYWORDS.has(word)) tokens.push(span(CLASS.JS_IMPORT, escaped));
183
- else if (JS_KEYWORDS.has(word)) tokens.push(span(CLASS.JS_KEYWORD, escaped));
184
- else if (code.slice(tokenRegex.lastIndex).match(/^\s*\(/))
185
- tokens.push(span(CLASS.JS_FUNCTION, escaped));
186
- else tokens.push(span(CLASS.JS_IDENT, escaped));
187
+ tokens.push(span(wordClass(word, code, tokenRegex.lastIndex, JS), escaped));
187
188
  } else if (punct) {
188
- tokens.push(
189
- JS_BRACKETS.has(punct) ? span(CLASS.JS_IMPORT, escaped) : span(CLASS.PUNCTUATION, escaped),
190
- );
189
+ tokens.push(span(bracketClass(punct), escaped));
191
190
  } else {
192
191
  tokens.push(escaped);
193
192
  }
@@ -201,6 +200,9 @@ export const highlightJSRaw = (code) => {
201
200
  // CSS highlighter - walks brace depth so nested rules (@supports, @media, and
202
201
  // & selectors) tokenize at every level, not just the innermost block
203
202
  export const highlightCSS = (code, options = {}) => {
203
+ const customProperties = (text) =>
204
+ text.replace(/--[\w-]+/g, (name) => span(CLASS.CSS_PROPERTY, name));
205
+
204
206
  const declarations = (text) => {
205
207
  const propRegex = /([\w-]+)(\s*:\s*)([^;]+)(;?)/g;
206
208
  let match,
@@ -208,7 +210,7 @@ export const highlightCSS = (code, options = {}) => {
208
210
  result = '';
209
211
 
210
212
  while ((match = propRegex.exec(text)) !== null) {
211
- if (match.index > last) result += text.slice(last, match.index);
213
+ if (match.index > last) result += customProperties(text.slice(last, match.index));
212
214
  const [, prop, colon, value, semi] = match;
213
215
  result += span(CLASS.CSS_PROPERTY, prop) + span(CLASS.PUNCTUATION, colon);
214
216
  const highlighted = value.replace(/@\[([^\]]+)\]/g, (_, expr) => {
@@ -221,7 +223,7 @@ export const highlightCSS = (code, options = {}) => {
221
223
  if (semi) result += span(CLASS.PUNCTUATION, semi);
222
224
  last = propRegex.lastIndex;
223
225
  }
224
- if (last < text.length) result += text.slice(last);
226
+ if (last < text.length) result += customProperties(text.slice(last));
225
227
  return result;
226
228
  };
227
229
 
@@ -244,8 +246,7 @@ export const highlightCSS = (code, options = {}) => {
244
246
  return depth ? -1 : i - 1;
245
247
  };
246
248
 
247
- const block = (text, inRule) => {
248
- const flush = (chunk) => (inRule ? declarations(chunk) : chunk);
249
+ const block = (text) => {
249
250
  let result = '',
250
251
  i = 0,
251
252
  pending = 0;
@@ -254,7 +255,7 @@ export const highlightCSS = (code, options = {}) => {
254
255
  if (text.startsWith('/*', i)) {
255
256
  const end = text.indexOf('*/', i + 2);
256
257
  const stop = end === -1 ? text.length : end + 2;
257
- result += flush(text.slice(pending, i)) + span(CLASS.COMMENT, text.slice(i, stop));
258
+ result += declarations(text.slice(pending, i)) + span(CLASS.COMMENT, text.slice(i, stop));
258
259
  i = pending = stop;
259
260
  continue;
260
261
  }
@@ -272,15 +273,14 @@ export const highlightCSS = (code, options = {}) => {
272
273
 
273
274
  if (text[i] === '{') {
274
275
  const close = closingBrace(text, i);
275
- const prelude = text.slice(pending, i);
276
- const ws = prelude.match(/^(\s*)/)[1];
277
- const name = prelude.trim();
276
+ const [, ws, name, gap] = text.slice(pending, i).match(/^(\s*)([\s\S]*?)(\s*)$/);
278
277
 
279
278
  result +=
280
279
  ws +
281
- (name ? span(CLASS.CSS_SELECTOR, name) + ' ' : '') +
280
+ (name ? span(CLASS.CSS_SELECTOR, name) : '') +
281
+ gap +
282
282
  span(CLASS.PUNCTUATION, '{') +
283
- block(text.slice(i + 1, close === -1 ? text.length : close), true) +
283
+ block(text.slice(i + 1, close === -1 ? text.length : close)) +
284
284
  (close === -1 ? '' : span(CLASS.PUNCTUATION, '}'));
285
285
 
286
286
  i = pending = close === -1 ? text.length : close + 1;
@@ -290,10 +290,10 @@ export const highlightCSS = (code, options = {}) => {
290
290
  i++;
291
291
  }
292
292
 
293
- return result + flush(text.slice(pending));
293
+ return result + declarations(text.slice(pending));
294
294
  };
295
295
 
296
- return block(code, false);
296
+ return block(code);
297
297
  };
298
298
 
299
299
  // JSON highlighter (receives HTML-escaped code)
@@ -341,34 +341,22 @@ export const highlightJSON = (code) => {
341
341
  return tokens.join('');
342
342
  };
343
343
 
344
- // CSS-only highlighter - only highlights content inside <style> tags
345
- export const highlightCSSOnly = (html, options = {}) => {
346
- // Find and highlight only <style> tag contents
347
- return html.replace(
348
- /(&lt;style[^&]*&gt;)([\s\S]*?)(&lt;\/style&gt;)/gi,
349
- (_, open, content, close) => open + highlightCSS(content) + close,
350
- );
351
- };
352
-
353
- // JS-only highlighter - only highlights content inside <script> tags
354
- export const highlightJSOnly = (html, options = {}) => {
355
- // Find and highlight only <script> tag contents
356
- return html.replace(
357
- /(&lt;script[^&]*&gt;)([\s\S]*?)(&lt;\/script&gt;)/gi,
358
- (_, open, content, close) => open + highlightJS(content) + close,
359
- );
360
- };
361
-
362
344
  // Bare CSS rules sitting outside any markup, handed to highlightCSS one whole
363
345
  // brace-balanced block at a time. Markup inside a candidate disqualifies it —
364
346
  // that is HTML, already highlighted.
365
347
  const bareCSS = (text) => {
366
348
  let result = '',
367
349
  i = 0,
368
- done = 0;
350
+ done = 0,
351
+ nesting = 0;
369
352
 
370
353
  while (i < text.length) {
371
- if (text[i] !== '{') {
354
+ if (text[i] === '<') {
355
+ nesting += text.startsWith('</', i) ? -1 : 1;
356
+ i = text.indexOf('>', i) + 1;
357
+ continue;
358
+ }
359
+ if (text[i] !== '{' || nesting) {
372
360
  i++;
373
361
  continue;
374
362
  }
@@ -408,10 +396,7 @@ const bareCSS = (text) => {
408
396
  };
409
397
 
410
398
  // HTML highlighter - main entry point
411
- export const highlightHTML = (
412
- html,
413
- { preserveWhitespace = false, highlightJS: doJS = true, highlightCSS: doCSS = true } = {},
414
- ) => {
399
+ export const highlightHTML = (html, { preserveWhitespace = false } = {}) => {
415
400
  let highlighted = html;
416
401
 
417
402
  // Format Vibe comments if not preserving whitespace
@@ -428,7 +413,7 @@ export const highlightHTML = (
428
413
 
429
414
  // HTML tags with attributes (attribute names can be @[...] name bindings)
430
415
  highlighted = highlighted.replace(
431
- /(&lt;\/?)([a-zA-Z0-9\-]+)((?:\s+(?:[a-zA-Z0-9\-:]+|@\[[^\]]+\])(?:=(?:&quot;(?:(?!&quot;).)*?&quot;|'[^']*'|[^\s&gt;]+))?)*\s*)(\/?\s*&gt;)/gs,
416
+ /(&lt;[\/!]?)([a-zA-Z][a-zA-Z0-9\-]*)((?:\s+(?:[a-zA-Z0-9\-:]+|@\[[^\]]+\])(?:=(?:&quot;(?:(?!&quot;).)*?&quot;|'[^']*'|[^\s&gt;]+))?)*\s*)(\/?\s*&gt;)/gs,
432
417
  (fullMatch, open, tag, attrs, close) => {
433
418
  let result = span(CLASS.PUNCTUATION, open) + span(CLASS.TAG, tag);
434
419
 
@@ -454,7 +439,7 @@ export const highlightHTML = (
454
439
 
455
440
  if (quote) result += span(CLASS.PUNCTUATION, quote);
456
441
  if (name.startsWith('on')) {
457
- result += span(CLASS.VIBE_COLOR, highlightStateAccess(value));
442
+ result += highlightJS(value);
458
443
  } else {
459
444
  result += span(CLASS.ATTR_VALUE, highlightBindings(value));
460
445
  }
@@ -473,48 +458,44 @@ export const highlightHTML = (
473
458
  },
474
459
  );
475
460
 
476
- // JavaScript in <script> tags (if enabled)
477
- if (doJS) {
478
- highlighted = highlighted.replace(
479
- new RegExp(
480
- `(<span class="${CLASS.PUNCTUATION}">&lt;<\\/span><span class="${CLASS.TAG}">script<\\/span>[\\s\\S]*?<span class="${CLASS.PUNCTUATION}">&gt;<\\/span>)([\\s\\S]*?)(<span class="${CLASS.PUNCTUATION}">&lt;\\/<\\/span><span class="${CLASS.TAG}">script<\\/span>)`,
481
- 'gi',
482
- ),
483
- (match, open, content, close) =>
484
- content.includes(`class="${CLASS.JS_KEYWORD}"`)
485
- ? match
486
- : open + highlightJS(content) + close,
487
- );
461
+ // JavaScript in <script> tags
462
+ highlighted = highlighted.replace(
463
+ new RegExp(
464
+ `(<span class="${CLASS.PUNCTUATION}">&lt;<\\/span><span class="${CLASS.TAG}">script<\\/span>[\\s\\S]*?<span class="${CLASS.PUNCTUATION}">&gt;<\\/span>)([\\s\\S]*?)(<span class="${CLASS.PUNCTUATION}">&lt;\\/<\\/span><span class="${CLASS.TAG}">script<\\/span>)`,
465
+ 'gi',
466
+ ),
467
+ (match, open, content, close) =>
468
+ content.includes(`class="${CLASS.JS_KEYWORD}"`)
469
+ ? match
470
+ : open + highlightJS(content) + close,
471
+ );
488
472
 
489
- // JavaScript in <code lang="js">
490
- highlighted = highlighted.replace(
491
- new RegExp(
492
- `(<span class="${CLASS.PUNCTUATION}">&lt;<\\/span><span class="${CLASS.TAG}">code<\\/span>\\s*<span class="${CLASS.ATTR_NAME}">lang<\\/span><span class="${CLASS.PUNCTUATION}">=<\\/span><span class="${CLASS.PUNCTUATION}">&quot;<\\/span><span class="${CLASS.ATTR_VALUE}">js<\\/span><span class="${CLASS.PUNCTUATION}">&quot;<\\/span><span class="${CLASS.PUNCTUATION}">&gt;<\\/span>)([\\s\\S]*?)(<span class="${CLASS.PUNCTUATION}">&lt;\\/<\\/span><span class="${CLASS.TAG}">code<\\/span>)`,
493
- 'gi',
494
- ),
495
- (match, open, content, close) =>
496
- content.includes(`class="${CLASS.JS_KEYWORD}"`)
497
- ? match
498
- : open + highlightJS(content) + close,
499
- );
500
- }
473
+ // JavaScript in <code lang="js">
474
+ highlighted = highlighted.replace(
475
+ new RegExp(
476
+ `(<span class="${CLASS.PUNCTUATION}">&lt;<\\/span><span class="${CLASS.TAG}">code<\\/span>\\s*<span class="${CLASS.ATTR_NAME}">lang<\\/span><span class="${CLASS.PUNCTUATION}">=<\\/span><span class="${CLASS.PUNCTUATION}">&quot;<\\/span><span class="${CLASS.ATTR_VALUE}">js<\\/span><span class="${CLASS.PUNCTUATION}">&quot;<\\/span><span class="${CLASS.PUNCTUATION}">&gt;<\\/span>)([\\s\\S]*?)(<span class="${CLASS.PUNCTUATION}">&lt;\\/<\\/span><span class="${CLASS.TAG}">code<\\/span>)`,
477
+ 'gi',
478
+ ),
479
+ (match, open, content, close) =>
480
+ content.includes(`class="${CLASS.JS_KEYWORD}"`)
481
+ ? match
482
+ : open + highlightJS(content) + close,
483
+ );
501
484
 
502
- // CSS in <style> tags (if enabled)
503
- if (doCSS) {
504
- highlighted = highlighted.replace(
505
- new RegExp(
506
- `(<span class="${CLASS.PUNCTUATION}">&lt;<\\/span><span class="${CLASS.TAG}">style<\\/span>[\\s\\S]*?<span class="${CLASS.PUNCTUATION}">&gt;<\\/span>)([\\s\\S]*?)(<span class="${CLASS.PUNCTUATION}">&lt;\\/<\\/span><span class="${CLASS.TAG}">style<\\/span>)`,
507
- 'gi',
508
- ),
509
- (match, open, content, close) =>
510
- content.includes(`class="${CLASS.CSS_SELECTOR}"`)
511
- ? match
512
- : open + highlightCSS(content) + close,
513
- );
485
+ // CSS in <style> tags
486
+ highlighted = highlighted.replace(
487
+ new RegExp(
488
+ `(<span class="${CLASS.PUNCTUATION}">&lt;<\\/span><span class="${CLASS.TAG}">style<\\/span>[\\s\\S]*?<span class="${CLASS.PUNCTUATION}">&gt;<\\/span>)([\\s\\S]*?)(<span class="${CLASS.PUNCTUATION}">&lt;\\/<\\/span><span class="${CLASS.TAG}">style<\\/span>)`,
489
+ 'gi',
490
+ ),
491
+ (match, open, content, close) =>
492
+ content.includes(`class="${CLASS.CSS_SELECTOR}"`)
493
+ ? match
494
+ : open + highlightCSS(content) + close,
495
+ );
514
496
 
515
- // CSS rules outside <style> tags (bare CSS blocks in mixed HTML+CSS snippets)
516
- highlighted = bareCSS(highlighted);
517
- }
497
+ // CSS rules outside <style> tags (bare CSS blocks in mixed HTML+CSS snippets)
498
+ highlighted = bareCSS(highlighted);
518
499
 
519
500
  // Remaining @[...] patterns
520
501
  highlighted = highlighted.replace(
@@ -530,3 +511,181 @@ export const highlightHTML = (
530
511
 
531
512
  return highlighted;
532
513
  };
514
+
515
+ export const highlightPython = (code) =>
516
+ code.replace(
517
+ /(\b[rRbBuUfF]{1,2})?("""[\s\S]*?"""|'''[\s\S]*?'''|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')|(#[^\n]*)|(\b\d[\d_]*(?:\.[\d_]*)?(?:[eE][+-]?\d+)?\b)|(\b[a-zA-Z_]\w*\b)|([^\s\w])/g,
518
+ (full, prefix, string, comment, number, word, punct, offset) => {
519
+ const escaped = escapeHTML(full);
520
+ if (string) return span(CLASS.JS_STRING, escaped);
521
+ if (comment) return span(CLASS.COMMENT, escaped);
522
+ if (number) return span(CLASS.JS_NUMBER, escaped);
523
+ if (word) return span(wordClass(word, code, offset + full.length, PYTHON), escaped);
524
+ return span(bracketClass(punct), escaped);
525
+ },
526
+ );
527
+
528
+ export const highlightShell = (code) => {
529
+ let i = 0;
530
+
531
+ const take = (regex) => {
532
+ regex.lastIndex = i;
533
+ const match = regex.exec(code);
534
+ if (match) i = regex.lastIndex;
535
+ return match?.[0];
536
+ };
537
+
538
+ const expansion = () => {
539
+ if (code.startsWith('$(', i)) {
540
+ i += 2;
541
+ const inner = commands(')');
542
+ return span(CLASS.PUNCTUATION, '$(') + inner + (take(/\)/y) ? span(CLASS.PUNCTUATION, ')') : '');
543
+ }
544
+ const variable = take(/\$(?:\{[^}\n]*\}|[A-Za-z_]\w*|[\d@*#?$!-])/y);
545
+ return variable && span(CLASS.JS_IDENT, escapeHTML(variable));
546
+ };
547
+
548
+ const doubleQuoted = () => {
549
+ let result = '',
550
+ text = code[i++];
551
+ const flush = () => {
552
+ if (text) result += span(CLASS.JS_STRING, escapeHTML(text));
553
+ text = '';
554
+ };
555
+
556
+ while (i < code.length && code[i] !== '"') {
557
+ const piece = code[i] === '$' && expansion();
558
+ if (piece) {
559
+ flush();
560
+ result += piece;
561
+ } else if (code[i] === '\\') {
562
+ text += code.slice(i, i + 2);
563
+ i += 2;
564
+ } else {
565
+ text += code[i++];
566
+ }
567
+ }
568
+ text += take(/"/y) ?? '';
569
+ flush();
570
+ return result;
571
+ };
572
+
573
+ const commands = (stop) => {
574
+ let result = '',
575
+ command = true,
576
+ header = null,
577
+ pattern = false;
578
+
579
+ while (i < code.length && !(code[i] === stop && !pattern)) {
580
+ const char = code[i];
581
+ const wordStart = !i || /[\s;&|()`]/.test(code[i - 1]);
582
+ let redirect;
583
+
584
+ if (char === '\n') {
585
+ result += code[i++];
586
+ command = !pattern;
587
+ } else if (/\s/.test(char)) {
588
+ result += take(/[^\S\n]+/y);
589
+ } else if (char === '\\') {
590
+ result += escapeHTML(code.slice(i, i + 2));
591
+ i += 2;
592
+ } else if (char === '#' && wordStart) {
593
+ result += span(CLASS.COMMENT, escapeHTML(take(/[^\n]*/y)));
594
+ } else if (char === "'") {
595
+ result += span(CLASS.JS_STRING, escapeHTML(take(/'[^']*'?/y)));
596
+ if (wordStart) command = false;
597
+ } else if (char === '"') {
598
+ result += doubleQuoted();
599
+ if (wordStart) command = false;
600
+ } else if (char === '$') {
601
+ result += expansion() || code[i++];
602
+ if (wordStart) command = false;
603
+ } else if (char === '`') {
604
+ i++;
605
+ result += span(CLASS.PUNCTUATION, '`') + commands('`');
606
+ if (take(/`/y)) result += span(CLASS.PUNCTUATION, '`');
607
+ } else if (char === '(') {
608
+ i++;
609
+ result += span(CLASS.PUNCTUATION, '(') + commands(')');
610
+ if (take(/\)/y)) result += span(CLASS.PUNCTUATION, ')');
611
+ command = false;
612
+ } else if (char === ')') {
613
+ result += span(CLASS.PUNCTUATION, code[i++]);
614
+ command = pattern;
615
+ pattern = false;
616
+ } else if (/[;&|]/.test(char)) {
617
+ const operator = take(/;;|\|\||&&|[;&|]/y);
618
+ result += span(CLASS.PUNCTUATION, operator);
619
+ if (operator === ';;') pattern = true;
620
+ command = !pattern;
621
+ } else if ((redirect = take(/\d*(?:>>|[<>]&\d*-?|>|<)/y))) {
622
+ result += span(CLASS.PUNCTUATION, escapeHTML(redirect));
623
+ } else {
624
+ const word = take(/[^\s'"$`;&|()<>\\]+/y);
625
+ if (pattern && word === 'esac') {
626
+ result += span(CLASS.JS_KEYWORD, word);
627
+ pattern = false;
628
+ command = false;
629
+ } else if (!wordStart || pattern) {
630
+ result += escapeHTML(word);
631
+ } else if (header && word === 'in') {
632
+ result += span(CLASS.JS_KEYWORD, word);
633
+ pattern = header === 'case';
634
+ header = null;
635
+ } else if (['[', '[[', ']', ']]', '{', '}', '!'].includes(word)) {
636
+ result += span(CLASS.PUNCTUATION, word);
637
+ command = ['{', '}', '!'].includes(word) || (!word.startsWith('[') && command);
638
+ } else if (!command) {
639
+ result += escapeHTML(word);
640
+ } else if (SHELL_KEYWORDS.has(word)) {
641
+ result += span(CLASS.JS_KEYWORD, word);
642
+ header = ['for', 'case', 'select'].includes(word) ? word : null;
643
+ command = !header && !['fi', 'done', 'esac'].includes(word);
644
+ } else if (/^[A-Za-z_]\w*=/.test(word)) {
645
+ const [name, value] = word.split(/=(.*)/s);
646
+ result += span(CLASS.JS_IDENT, name) + span(CLASS.PUNCTUATION, '=') + escapeHTML(value);
647
+ } else {
648
+ result += span(CLASS.JS_FUNCTION, escapeHTML(word));
649
+ command = false;
650
+ }
651
+ }
652
+ }
653
+
654
+ return result;
655
+ };
656
+
657
+ return commands();
658
+ };
659
+
660
+ const LANGUAGE_SIGNALS = {
661
+ python:
662
+ /^\s*(?:from\s+[\w.]+\s+import\s|import\s+[\w.]+(?:\s+as\s+\w+)?(?:\s*,\s*[\w.]+)*\s*$|def\s+\w+\s*\(|class\s+\w+.*:\s*$|(?:if|elif|while|for|with|except)\b.*:\s*(?:#.*)?$|(?:else|try|finally):|print\(|raise\s)/,
663
+ shell:
664
+ /^(?!.*:\s*$)(?!\s*(?:from|import)\s)(?!\s*[\w.[\]]+\s*[-+*/]?=\s)\s*(?:[a-z][\w.-]*(?=[^\n]*\s(?:-{1,2}\w|[\w@~.-]*[./][\w./-]|\$|\||&&|\d?>|["']|\\$))(?:\s+\S+)+|[A-Za-z_]\w*=(?:[^\s,]*|"[^"]*"|'[^']*'|\$\([^)]*\)?)(?:;|\s*$|\s+[a-z])|(?:if|then|fi|for|do|done|case|esac|while|echo|printf|export)\b|\[\s|\((?:cd|[a-z]+\s))/,
665
+ css: /^\s*(?:-{0,2}[a-zA-Z][\w-]*\s*:[^;{]*;|--[\w-]+\s*(?:,|$)|(?:[^{};()=[\]'"]|\[[^\]]*\]|:[\w-]+\([^)]*\))+\{(?:[^{}]*\})?\s*(?:\/\*.*)?$)/,
666
+ js: /^\s*(?:(?:const|let|var)\s+[\w{[$]|(?:async\s+)?function\b|import\b.*\bfrom\s*['"]|import\s*['"{*]|export\s|return\b.*;|\/\/|(?:window|document|console)\.|\$\.\w)|=>|[^;];\s*$/,
667
+ html: /^\s*<[!/a-zA-Z]|<\/[a-zA-Z][\w-]*>|@\[/,
668
+ };
669
+
670
+ export const detectLanguage = (code) => {
671
+ if (/^\s*(?:[{[][\s\S]*"[^"\n]*"\s*:|"[^"\n]*"\s*:)/.test(code)) return 'json';
672
+ if (/^\s*<[!/a-zA-Z]/.test(code)) return 'html';
673
+
674
+ const lines = code.split('\n');
675
+ return Object.entries(LANGUAGE_SIGNALS)
676
+ .map(([language, signal]) => [language, lines.filter((line) => signal.test(line)).length])
677
+ .reduce((best, entry) => (entry[1] > best[1] ? entry : best), ['text', 0])[0];
678
+ };
679
+
680
+ const HIGHLIGHTERS = {
681
+ html: (code, options) => highlightHTML(escapeHTML(code), options),
682
+ json: (code) => highlightJSON(escapeHTML(code)),
683
+ css: (code) => highlightCSS(escapeHTML(code)),
684
+ js: highlightJSRaw,
685
+ python: highlightPython,
686
+ shell: highlightShell,
687
+ text: escapeHTML,
688
+ };
689
+
690
+ export const highlight = (code, language, options) =>
691
+ HIGHLIGHTERS[language ?? detectLanguage(code)](code, options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ape-egg/codie",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Modular code display and editing package",
5
5
  "type": "module",
6
6
  "main": "codie.js",
package/theme.css CHANGED
@@ -50,12 +50,12 @@
50
50
  --codie-highlight: oklch(0.7 0.1 250 / 0.2);
51
51
  }
52
52
 
53
- /* Dark theme - VSCode Dark+ */
53
+ /* Dark theme - VSCode Dark+ tokens on Stylecheat's dark surface */
54
54
  .dark :where(codie, [codie]),
55
55
  [dark] :where(codie, [codie]),
56
56
  :where(codie, [codie])[dark] {
57
- --codie-bg: #1e1e1e;
58
- --codie-text: #d4d4d4;
57
+ --codie-bg: #15181d;
58
+ --codie-text: #dfe6f0;
59
59
  --codie-caret: #fff;
60
60
  --codie-selection: oklch(0.5 0.1 250 / 0.4);
61
61
 
@@ -63,7 +63,7 @@
63
63
  --codie-tag: #569cd6;
64
64
  --codie-attr-name: #9cdcfe;
65
65
  --codie-attr-value: #ce9178;
66
- --codie-punct: #808080;
66
+ --codie-punct: #8a94a6;
67
67
  --codie-comment: #6a9955;
68
68
 
69
69
  /* CSS tokens */
@@ -90,11 +90,11 @@
90
90
  --codie-vibe: #db2777;
91
91
 
92
92
  /* Line numbers */
93
- --codie-line-number: oklch(0.45 0 0);
94
- --codie-line-number-active: oklch(0.75 0 0);
93
+ --codie-line-number: oklch(0.45 0.02 264);
94
+ --codie-line-number-active: oklch(0.75 0.02 264);
95
95
 
96
96
  /* Folding */
97
- --codie-fold-icon: oklch(0.45 0 0);
97
+ --codie-fold-icon: oklch(0.45 0.02 264);
98
98
  --codie-fold-hover: oklch(0.4 0.1 250 / 0.3);
99
99
 
100
100
  /* Line highlight */