@ape-egg/codie 0.1.0

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/highlight.js ADDED
@@ -0,0 +1,423 @@
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;');
7
+
8
+ // Wrap in span with class
9
+ const span = (cls, content) => `<span class="${cls}">${content}</span>`;
10
+
11
+ // Highlight @[expression] - vibe brackets + variable names
12
+ const highlightBindings = (text) =>
13
+ text.replace(/@\[([^\]]+)\]/g, (_, expr) => {
14
+ const highlighted = expr.replace(
15
+ /(&quot;[^&]*(?:&(?!quot;)[^&]*)*&quot;)|('[^']*')|("[^"]*")|(\b[a-zA-Z_][a-zA-Z0-9_]*\b)/g,
16
+ (m, escaped, single, double, ident) => {
17
+ if (escaped || single || double) return m;
18
+ return span(CLASS.VIBE_VARIABLE, ident);
19
+ },
20
+ );
21
+ return `${span(CLASS.VIBE_COLOR, '@[')}${highlighted}${span(CLASS.VIBE_COLOR, ']')}`;
22
+ });
23
+
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
+ // Highlight variable names in Vibe comments
32
+ const highlightCommentVariables = (content) => {
33
+ let result = content.replace(
34
+ /^(\s*(?:if|each)\s+)([a-zA-Z_][a-zA-Z0-9_.]*)/,
35
+ (_, prefix, varName) => `${prefix}${span(CLASS.VIBE_VARIABLE, varName)}`,
36
+ );
37
+ return result.replace(
38
+ /(\s+as\s+)([a-zA-Z_][a-zA-Z0-9_]*)/,
39
+ (_, as, alias) => `${as}${span(CLASS.VIBE_VARIABLE, alias)}`,
40
+ );
41
+ };
42
+
43
+ // JavaScript highlighter for escaped HTML content
44
+ export const highlightJS = (code, options = {}) => {
45
+ const tokens = [];
46
+ const tokenRegex =
47
+ /(@\[([^\]]+)\])|(&quot;(?:[^&]|&(?!quot;))*?&quot;)|('(?:[^'\\]|\\.)*')|(`(?:[^`\\]|\\.)*`)|(\/\/[^\n]*)|(\/\*[\s\S]*?\*\/)|(\$\.([a-zA-Z_][a-zA-Z0-9_]*))|(\b\d+\.?\d*\b)|(\b[a-zA-Z_][a-zA-Z0-9_]*\b)|([^\s])/g;
48
+ let match,
49
+ lastIndex = 0;
50
+
51
+ while ((match = tokenRegex.exec(code)) !== null) {
52
+ if (match.index > lastIndex) tokens.push(code.slice(lastIndex, match.index));
53
+
54
+ const [
55
+ full,
56
+ vibeBinding,
57
+ vibeExpr,
58
+ dblStr,
59
+ sglStr,
60
+ tmplStr,
61
+ lineComment,
62
+ blockComment,
63
+ stateAccess,
64
+ stateVar,
65
+ number,
66
+ word,
67
+ punct,
68
+ ] = match;
69
+
70
+ if (vibeBinding) {
71
+ const highlighted = vibeExpr.replace(
72
+ /('[^']*')|("[^"]*")|(\b[a-zA-Z_][a-zA-Z0-9_]*\b)/g,
73
+ (m, s1, s2, ident) => (s1 || s2 ? m : span(CLASS.VIBE_VARIABLE, ident)),
74
+ );
75
+ tokens.push(`${span(CLASS.VIBE_COLOR, '@[')}${highlighted}${span(CLASS.VIBE_COLOR, ']')}`);
76
+ } else if (dblStr || sglStr || tmplStr) {
77
+ const str = dblStr || sglStr || tmplStr;
78
+ const highlighted = str.replace(/@\[([^\]]+)\]/g, (_, expr) => {
79
+ const h = expr.replace(
80
+ /('[^']*')|("[^"]*")|(\b[a-zA-Z_][a-zA-Z0-9_]*\b)/g,
81
+ (m, s1, s2, ident) => (s1 || s2 ? m : span(CLASS.VIBE_VARIABLE, ident)),
82
+ );
83
+ return `${span(CLASS.VIBE_COLOR, '@[')}${h}${span(CLASS.VIBE_COLOR, ']')}`;
84
+ });
85
+ tokens.push(span(CLASS.JS_STRING, highlighted));
86
+ } else if (lineComment || blockComment) {
87
+ tokens.push(span(CLASS.COMMENT, full));
88
+ } else if (stateAccess) {
89
+ tokens.push(`${span(CLASS.VIBE_COLOR, '$.')}${span(CLASS.VIBE_VARIABLE, stateVar)}`);
90
+ } else if (number) {
91
+ tokens.push(span(CLASS.JS_NUMBER, full));
92
+ } 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));
98
+ } else if (punct) {
99
+ tokens.push(
100
+ JS_BRACKETS.has(punct) ? span(CLASS.JS_IMPORT, full) : span(CLASS.PUNCTUATION, full),
101
+ );
102
+ } else {
103
+ tokens.push(full);
104
+ }
105
+ lastIndex = tokenRegex.lastIndex;
106
+ }
107
+
108
+ if (lastIndex < code.length) tokens.push(code.slice(lastIndex));
109
+ return tokens.join('');
110
+ };
111
+
112
+ // JavaScript highlighter for raw (unescaped) content
113
+ export const highlightJSRaw = (code) => {
114
+ const tokens = [];
115
+ const tokenRegex =
116
+ /(<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[^>]*)?>)|("(?:[^"\\]|\\.)*")|('(?:[^'\\]|\\.)*')|(`(?:[^`\\]|\\.)*`)|(\/\/[^\n]*)|(\/\*[\s\S]*?\*\/)|(\$\.([a-zA-Z_][a-zA-Z0-9_]*))|(\b\d+\.?\d*\b)|(\b[a-zA-Z_][a-zA-Z0-9_]*\b)|([^\s])/g;
117
+ let match,
118
+ lastIndex = 0;
119
+
120
+ while ((match = tokenRegex.exec(code)) !== null) {
121
+ if (match.index > lastIndex) tokens.push(escapeHTML(code.slice(lastIndex, match.index)));
122
+
123
+ const [
124
+ full,
125
+ htmlTag,
126
+ dblStr,
127
+ sglStr,
128
+ tmplStr,
129
+ lineComment,
130
+ blockComment,
131
+ stateAccess,
132
+ stateVar,
133
+ number,
134
+ word,
135
+ punct,
136
+ ] = match;
137
+ const escaped = escapeHTML(full);
138
+
139
+ if (htmlTag) {
140
+ const tagMatch = full.match(/^(<\/?)([a-zA-Z][a-zA-Z0-9-]*)([\s\S]*?)(>)$/);
141
+ if (tagMatch) {
142
+ const [, open, tagName, attrs, close] = tagMatch;
143
+ let result =
144
+ span(CLASS.PUNCTUATION, escapeHTML(open)) + span(CLASS.TAG, escapeHTML(tagName));
145
+ if (attrs) {
146
+ result += attrs.replace(
147
+ /([a-zA-Z][a-zA-Z0-9-:]*)(=)?(["'])?([^"']*)?(\3)?/g,
148
+ (m, name, eq, q1, val, q2) => {
149
+ if (!name) return escapeHTML(m);
150
+ let r = span(CLASS.ATTR_NAME, escapeHTML(name));
151
+ if (eq) {
152
+ r += span(CLASS.PUNCTUATION, '=');
153
+ if (q1) r += span(CLASS.PUNCTUATION, escapeHTML(q1));
154
+ if (val) r += span(CLASS.ATTR_VALUE, escapeHTML(val));
155
+ if (q2) r += span(CLASS.PUNCTUATION, escapeHTML(q2));
156
+ }
157
+ return r;
158
+ },
159
+ );
160
+ }
161
+ result += span(CLASS.PUNCTUATION, escapeHTML(close));
162
+ tokens.push(result);
163
+ } else {
164
+ tokens.push(escaped);
165
+ }
166
+ } else if (dblStr || sglStr || tmplStr) {
167
+ tokens.push(span(CLASS.JS_STRING, escaped));
168
+ } else if (lineComment || blockComment) {
169
+ tokens.push(span(CLASS.COMMENT, escaped));
170
+ } else if (stateAccess) {
171
+ tokens.push(
172
+ `${span(CLASS.VIBE_COLOR, '$.')}${span(CLASS.VIBE_VARIABLE, escapeHTML(stateVar))}`,
173
+ );
174
+ } else if (number) {
175
+ tokens.push(span(CLASS.JS_NUMBER, escaped));
176
+ } else if (word) {
177
+ if (JS_IMPORT_KEYWORDS.has(word)) tokens.push(span(CLASS.JS_IMPORT, escaped));
178
+ else if (JS_KEYWORDS.has(word)) tokens.push(span(CLASS.JS_KEYWORD, escaped));
179
+ else if (code.slice(tokenRegex.lastIndex).match(/^\s*\(/))
180
+ tokens.push(span(CLASS.JS_FUNCTION, escaped));
181
+ else tokens.push(span(CLASS.JS_IDENT, escaped));
182
+ } else if (punct) {
183
+ tokens.push(
184
+ JS_BRACKETS.has(punct) ? span(CLASS.JS_IMPORT, escaped) : span(CLASS.PUNCTUATION, escaped),
185
+ );
186
+ } else {
187
+ tokens.push(escaped);
188
+ }
189
+ lastIndex = tokenRegex.lastIndex;
190
+ }
191
+
192
+ if (lastIndex < code.length) tokens.push(escapeHTML(code.slice(lastIndex)));
193
+ return tokens.join('');
194
+ };
195
+
196
+ // CSS highlighter
197
+ export const highlightCSS = (code, options = {}) => {
198
+ let result = '';
199
+ const ruleRegex = /(\/\*[\s\S]*?\*\/)|([^{}]+)(\{)([^}]*)(\})/g;
200
+ let match,
201
+ lastIndex = 0;
202
+
203
+ while ((match = ruleRegex.exec(code)) !== null) {
204
+ if (match.index > lastIndex) result += code.slice(lastIndex, match.index);
205
+
206
+ const [, comment, selector, open, props, close] = match;
207
+
208
+ if (comment) {
209
+ result += span(CLASS.COMMENT, comment);
210
+ } else {
211
+ const ws = selector.match(/^(\s*)/)[1];
212
+ result +=
213
+ ws + span(CLASS.CSS_SELECTOR, selector.trim()) + ' ' + span(CLASS.PUNCTUATION, open);
214
+
215
+ const propRegex = /([\w-]+)(\s*:\s*)([^;]+)(;?)/g;
216
+ let propMatch,
217
+ propLast = 0,
218
+ propsResult = '';
219
+
220
+ while ((propMatch = propRegex.exec(props)) !== null) {
221
+ if (propMatch.index > propLast) propsResult += props.slice(propLast, propMatch.index);
222
+ const [, prop, colon, value, semi] = propMatch;
223
+ propsResult += span(CLASS.CSS_PROPERTY, prop) + span(CLASS.PUNCTUATION, colon);
224
+ const highlighted = value.replace(/@\[([^\]]+)\]/g, (_, expr) => {
225
+ const h = expr.replace(/\b[a-zA-Z_][a-zA-Z0-9_]*\b/g, (v) =>
226
+ span(CLASS.VIBE_VARIABLE, v),
227
+ );
228
+ return `${span(CLASS.VIBE_COLOR, '@[')}${h}${span(CLASS.VIBE_COLOR, ']')}`;
229
+ });
230
+ propsResult += span(CLASS.CSS_VALUE, highlighted);
231
+ if (semi) propsResult += span(CLASS.PUNCTUATION, semi);
232
+ propLast = propRegex.lastIndex;
233
+ }
234
+ if (propLast < props.length) propsResult += props.slice(propLast);
235
+
236
+ result += propsResult + span(CLASS.PUNCTUATION, close);
237
+ }
238
+ lastIndex = ruleRegex.lastIndex;
239
+ }
240
+
241
+ if (lastIndex < code.length) result += code.slice(lastIndex);
242
+ return result;
243
+ };
244
+
245
+ // JSON highlighter (receives HTML-escaped code)
246
+ export const highlightJSON = (code) => {
247
+ const tokens = [];
248
+ // Match HTML-escaped strings: &quot;...&quot; instead of "..."
249
+ const tokenRegex =
250
+ /(\/\/[^\n]*)|(\/\*[\s\S]*?\*\/)|(&quot;(?:(?!&quot;)[^\\]|\\.)*?&quot;)(\s*:)|(&quot;(?:(?!&quot;)[^\\]|\\.)*?&quot;)|(true|false|null)\b|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)|([{}[\],:])/g;
251
+ let match,
252
+ lastIndex = 0;
253
+
254
+ while ((match = tokenRegex.exec(code)) !== null) {
255
+ if (match.index > lastIndex) tokens.push(code.slice(lastIndex, match.index));
256
+
257
+ const [full, lineComment, blockComment, keyString, colon, valueString, literal, number, punct] =
258
+ match;
259
+
260
+ if (lineComment || blockComment) {
261
+ tokens.push(span(CLASS.COMMENT, full));
262
+ } else if (keyString) {
263
+ // Property name (key before colon)
264
+ tokens.push(span(CLASS.JSON_KEY, keyString));
265
+ if (colon) tokens.push(span(CLASS.PUNCTUATION, colon.trim()));
266
+ } else if (valueString) {
267
+ // String value
268
+ tokens.push(span(CLASS.JSON_STRING, valueString));
269
+ } else if (literal) {
270
+ // true, false, null
271
+ if (literal === 'null') {
272
+ tokens.push(span(CLASS.JSON_NULL, literal));
273
+ } else {
274
+ tokens.push(span(CLASS.JSON_BOOLEAN, literal));
275
+ }
276
+ } else if (number) {
277
+ tokens.push(span(CLASS.JSON_NUMBER, number));
278
+ } else if (punct) {
279
+ tokens.push(span(CLASS.PUNCTUATION, punct));
280
+ } else {
281
+ tokens.push(full);
282
+ }
283
+ lastIndex = tokenRegex.lastIndex;
284
+ }
285
+
286
+ if (lastIndex < code.length) tokens.push(code.slice(lastIndex));
287
+ return tokens.join('');
288
+ };
289
+
290
+ // CSS-only highlighter - only highlights content inside <style> tags
291
+ export const highlightCSSOnly = (html, options = {}) => {
292
+ // Find and highlight only <style> tag contents
293
+ return html.replace(
294
+ /(&lt;style[^&]*&gt;)([\s\S]*?)(&lt;\/style&gt;)/gi,
295
+ (_, open, content, close) => open + highlightCSS(content) + close,
296
+ );
297
+ };
298
+
299
+ // JS-only highlighter - only highlights content inside <script> tags
300
+ export const highlightJSOnly = (html, options = {}) => {
301
+ // Find and highlight only <script> tag contents
302
+ return html.replace(
303
+ /(&lt;script[^&]*&gt;)([\s\S]*?)(&lt;\/script&gt;)/gi,
304
+ (_, open, content, close) => open + highlightJS(content) + close,
305
+ );
306
+ };
307
+
308
+ // HTML highlighter - main entry point
309
+ export const highlightHTML = (
310
+ html,
311
+ { preserveWhitespace = false, highlightJS: doJS = true, highlightCSS: doCSS = true } = {},
312
+ ) => {
313
+ let highlighted = html;
314
+
315
+ // Format Vibe comments if not preserving whitespace
316
+ if (!preserveWhitespace) {
317
+ highlighted = highlighted
318
+ .replace(/([^\s])[ \t]*(&lt;!--\s*(?:if|else|\/if|each|\/each)\b)/g, '$1\n $2')
319
+ .replace(/(&lt;!--\s*(?:if|else|each)\b[^-]*--&gt;)[ \t]*(\S)/g, '$1\n $2');
320
+ }
321
+
322
+ // HTML comments
323
+ highlighted = highlighted.replace(/&lt;!--([\s\S]*?)--&gt;/g, (_, content) =>
324
+ span(CLASS.COMMENT, `&lt;!--${highlightCommentVariables(content)}--&gt;`),
325
+ );
326
+
327
+ // HTML tags with attributes
328
+ highlighted = highlighted.replace(
329
+ /(&lt;\/?)([a-zA-Z0-9\-]+)((?:\s+[a-zA-Z0-9\-:]+(?:=(?:&quot;(?:(?!&quot;).)*?&quot;|'[^']*'|[^\s&gt;]+))?)*\s*)(\/?\s*&gt;)/gs,
330
+ (fullMatch, open, tag, attrs, close) => {
331
+ let result = span(CLASS.PUNCTUATION, open) + span(CLASS.TAG, tag);
332
+
333
+ if (attrs) {
334
+ if (attrs.trim()) {
335
+ const attrRegex =
336
+ /([a-zA-Z0-9\-:]+)(=(?:&quot;((?:(?!&quot;).)*?)&quot;|'([^']*)'|([^\s&gt;]+)))?/gs;
337
+ let attrMatch,
338
+ last = 0;
339
+
340
+ while ((attrMatch = attrRegex.exec(attrs)) !== null) {
341
+ result += attrs.substring(last, attrMatch.index) + span(CLASS.ATTR_NAME, attrMatch[1]);
342
+
343
+ if (attrMatch[2]) {
344
+ result += span(CLASS.PUNCTUATION, '=');
345
+ const name = attrMatch[1].toLowerCase();
346
+ const value = attrMatch[3] ?? attrMatch[4] ?? attrMatch[5] ?? '';
347
+ const quote = attrMatch[3] !== undefined ? '&quot;' : attrMatch[4] ? "'" : '';
348
+
349
+ if (quote) result += span(CLASS.PUNCTUATION, quote);
350
+ if (name.startsWith('on')) {
351
+ result += span(CLASS.VIBE_COLOR, highlightStateAccess(value));
352
+ } else {
353
+ result += span(CLASS.ATTR_VALUE, highlightBindings(value));
354
+ }
355
+ if (quote) result += span(CLASS.PUNCTUATION, quote);
356
+ }
357
+ last = attrRegex.lastIndex;
358
+ }
359
+ const remaining = attrs.substring(last);
360
+ if (remaining) result += remaining;
361
+ } else {
362
+ result += attrs;
363
+ }
364
+ }
365
+
366
+ return result + span(CLASS.PUNCTUATION, close.trim());
367
+ },
368
+ );
369
+
370
+ // JavaScript in <script> tags (if enabled)
371
+ if (doJS) {
372
+ highlighted = highlighted.replace(
373
+ new RegExp(
374
+ `(<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>)`,
375
+ 'gi',
376
+ ),
377
+ (match, open, content, close) =>
378
+ content.includes(`class="${CLASS.JS_KEYWORD}"`)
379
+ ? match
380
+ : open + highlightJS(content) + close,
381
+ );
382
+
383
+ // JavaScript in <code lang="js">
384
+ highlighted = highlighted.replace(
385
+ new RegExp(
386
+ `(<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>)`,
387
+ 'gi',
388
+ ),
389
+ (match, open, content, close) =>
390
+ content.includes(`class="${CLASS.JS_KEYWORD}"`)
391
+ ? match
392
+ : open + highlightJS(content) + close,
393
+ );
394
+ }
395
+
396
+ // CSS in <style> tags (if enabled)
397
+ if (doCSS) {
398
+ highlighted = highlighted.replace(
399
+ new RegExp(
400
+ `(<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>)`,
401
+ 'gi',
402
+ ),
403
+ (match, open, content, close) =>
404
+ content.includes(`class="${CLASS.CSS_SELECTOR}"`)
405
+ ? match
406
+ : open + highlightCSS(content) + close,
407
+ );
408
+ }
409
+
410
+ // Remaining @[...] patterns
411
+ highlighted = highlighted.replace(
412
+ new RegExp(`(?<!${CLASS.VIBE_COLOR}">)(?<!${CLASS.JS_STRING}">)@\\[([^\\]]+)\\]`, 'g'),
413
+ (_, expr) => {
414
+ const h = expr.replace(
415
+ /(&quot;[^&]*(?:&(?!quot;)[^&]*)*&quot;)|('[^']*')|("[^"]*")|(\b[a-zA-Z_][a-zA-Z0-9_]*\b)/g,
416
+ (m, e, s1, s2, ident) => (e || s1 || s2 ? m : span(CLASS.VIBE_VARIABLE, ident)),
417
+ );
418
+ return `${span(CLASS.VIBE_COLOR, '@[')}${h}${span(CLASS.VIBE_COLOR, ']')}`;
419
+ },
420
+ );
421
+
422
+ return highlighted;
423
+ };
package/keyboard.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * keyboard.js - Key command handler for Codie editor
3
+ *
4
+ * Simple API for registering keyboard shortcuts.
5
+ * Normalizes platform differences (Cmd on Mac, Ctrl elsewhere).
6
+ */
7
+
8
+ /**
9
+ * Normalize key combinations to a consistent string format
10
+ * @param {KeyboardEvent} e - The keyboard event
11
+ * @returns {string} - Normalized key combo like "Cmd+s" or "Cmd+Shift+f"
12
+ */
13
+ function normalizeKeyCombo(e) {
14
+ const parts = []
15
+
16
+ // Use "Cmd" for both Command (Mac) and Control (Windows/Linux) for consistency
17
+ if (e.metaKey || e.ctrlKey) parts.push('Cmd')
18
+ if (e.altKey) parts.push('Alt')
19
+ if (e.shiftKey) parts.push('Shift')
20
+
21
+ // Add the actual key (lowercase for consistency)
22
+ const key = e.key.toLowerCase()
23
+ if (key !== 'meta' && key !== 'control' && key !== 'alt' && key !== 'shift') {
24
+ parts.push(key)
25
+ }
26
+
27
+ return parts.join('+')
28
+ }
29
+
30
+ /**
31
+ * Attach keyboard shortcuts to a textarea
32
+ * @param {HTMLTextAreaElement} textarea - The textarea element to attach to
33
+ * @param {Object} commands - Map of key combos to handler functions
34
+ * @returns {Function} - Cleanup function to remove the listener
35
+ *
36
+ * @example
37
+ * keyboard(textarea, {
38
+ * 'Cmd+s': (e, textarea) => {
39
+ * e.preventDefault()
40
+ * formatCode(textarea)
41
+ * },
42
+ * 'Cmd+/': (e, textarea) => {
43
+ * toggleComment(textarea)
44
+ * }
45
+ * })
46
+ */
47
+ export default function keyboard(textarea, commands) {
48
+ const handler = (e) => {
49
+ const combo = normalizeKeyCombo(e)
50
+ const command = commands[combo]
51
+
52
+ if (command) {
53
+ command(e, textarea)
54
+ }
55
+ }
56
+
57
+ textarea.addEventListener('keydown', handler)
58
+
59
+ // Return cleanup function
60
+ return () => textarea.removeEventListener('keydown', handler)
61
+ }
package/numberRows.js ADDED
@@ -0,0 +1,51 @@
1
+ // Codie line numbers
2
+ import { CLASS } from './constants.js'
3
+
4
+ // Count lines in code
5
+ const countLines = code => code.split('\n').length
6
+
7
+ // Generate line numbers HTML
8
+ const generateNumbers = count => {
9
+ let html = ''
10
+ for (let i = 1; i <= count; i++) {
11
+ html += `<span class="${CLASS.LINE}">${i}</span>\n`
12
+ }
13
+ return html.trimEnd()
14
+ }
15
+
16
+ // Initialize line numbers for an element
17
+ export const initNumberRows = (el, code) => {
18
+ const lineCount = countLines(code)
19
+ const container = document.createElement('div')
20
+ container.className = CLASS.LINE_NUMBERS
21
+ container.innerHTML = generateNumbers(lineCount)
22
+
23
+ // Insert at beginning
24
+ el.insertBefore(container, el.firstChild)
25
+
26
+ return container
27
+ }
28
+
29
+ // Update line numbers when code changes
30
+ export const updateNumberRows = (container, code) => {
31
+ const newCount = countLines(code)
32
+ const currentCount = container.children.length
33
+
34
+ if (newCount === currentCount) return
35
+
36
+ container.innerHTML = generateNumbers(newCount)
37
+ }
38
+
39
+ // Highlight active line number
40
+ export const highlightLineNumber = (container, lineIndex) => {
41
+ // Clear previous highlight
42
+ container.querySelectorAll(`.${CLASS.LINE_HIGHLIGHT}`).forEach(el => {
43
+ el.classList.remove(CLASS.LINE_HIGHLIGHT)
44
+ })
45
+
46
+ // Add highlight to target line
47
+ const lines = container.querySelectorAll(`.${CLASS.LINE}`)
48
+ if (lines[lineIndex]) {
49
+ lines[lineIndex].classList.add(CLASS.LINE_HIGHLIGHT)
50
+ }
51
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@ape-egg/codie",
3
+ "version": "0.1.0",
4
+ "description": "Modular code display and editing package",
5
+ "type": "module",
6
+ "main": "codie.js",
7
+ "exports": {
8
+ ".": "./codie.js",
9
+ "./codie.css": "./codie.css",
10
+ "./highlight": "./highlight.js",
11
+ "./format": "./format.js",
12
+ "./constants": "./constants.js"
13
+ },
14
+ "files": [
15
+ "*.js",
16
+ "*.css"
17
+ ],
18
+ "keywords": [
19
+ "code",
20
+ "editor",
21
+ "syntax-highlighting",
22
+ "code-display"
23
+ ],
24
+ "author": "Kim Korte",
25
+ "license": "ISC",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }