@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/editable.js ADDED
@@ -0,0 +1,196 @@
1
+ // Codie editable textarea layer
2
+ import { CLASS } from './constants.js'
3
+ import { highlightHTML, highlightJS, highlightCSS, highlightCSSOnly, highlightJSOnly } from './highlight.js'
4
+ import { escapeHTML, formatDocument, normalizeInlineWhitespace, cleanupBooleanAttrs } from './format.js'
5
+ import { updateNumberRows } from './numberRows.js'
6
+ import keyboard from './keyboard.js'
7
+
8
+ // Highlight code based on modes
9
+ const highlight = (code, modes) => {
10
+ const escaped = escapeHTML(code)
11
+
12
+ if (modes.html) {
13
+ return highlightHTML(escaped, {
14
+ preserveWhitespace: true,
15
+ highlightJS: modes.js,
16
+ highlightCSS: modes.css,
17
+ })
18
+ }
19
+
20
+ let result = escaped
21
+ if (modes.js) result = highlightJSOnly(result)
22
+ if (modes.css) result = highlightCSSOnly(result)
23
+ return result
24
+ }
25
+
26
+ // Initialize editable textarea
27
+ export const initEditable = (el, instance, config) => {
28
+ const textarea = document.createElement('textarea')
29
+ textarea.className = `${CLASS.CODE_LAYER} ${CLASS.TEXTAREA}`
30
+ textarea.value = instance._code
31
+ textarea.spellcheck = false
32
+ textarea.autocomplete = 'off'
33
+ textarea.autocapitalize = 'off'
34
+
35
+ // Insert textarea before display (so display renders on top)
36
+ el.insertBefore(textarea, instance.display)
37
+
38
+ // Handle input changes
39
+ const handleInput = () => {
40
+ const newCode = textarea.value
41
+ instance._code = newCode
42
+
43
+ // Preserve scroll position
44
+ const scrollTop = el.scrollTop
45
+ const scrollLeft = el.scrollLeft
46
+
47
+ // Update display
48
+ instance.display.innerHTML = highlight(newCode, instance.modes)
49
+
50
+ // Restore scroll
51
+ el.scrollTop = scrollTop
52
+ el.scrollLeft = scrollLeft
53
+
54
+ // Update line numbers if present
55
+ if (instance.lineNumbers) {
56
+ updateNumberRows(instance.lineNumbers, newCode)
57
+ }
58
+
59
+ // Call onEdit callback (access through proxy to get user-set callbacks)
60
+ const proxy = instance._proxy || instance
61
+ if (proxy.onEdit) {
62
+ try {
63
+ proxy.onEdit({ formatted: normalizeInlineWhitespace(cleanupBooleanAttrs(newCode)), raw: newCode })
64
+ } catch (e) {
65
+ if (proxy.onError) proxy.onError({ message: e.message })
66
+ }
67
+ }
68
+ }
69
+
70
+ // Handle Tab key for indentation
71
+ const handleKeydown = e => {
72
+ if (e.key === 'Tab') {
73
+ e.preventDefault()
74
+ const start = textarea.selectionStart
75
+ const end = textarea.selectionEnd
76
+ const value = textarea.value
77
+
78
+ if (e.shiftKey) {
79
+ // Outdent: remove 2 spaces from start of selected lines
80
+ const beforeStart = value.lastIndexOf('\n', start - 1) + 1
81
+ const lines = value.substring(beforeStart, end).split('\n')
82
+ const outdented = lines.map(line => line.startsWith(' ') ? line.slice(2) : line)
83
+ const newValue = value.substring(0, beforeStart) + outdented.join('\n') + value.substring(end)
84
+ const removed = lines.join('\n').length - outdented.join('\n').length
85
+
86
+ textarea.value = newValue
87
+ textarea.selectionStart = Math.max(beforeStart, start - (lines[0].startsWith(' ') ? 2 : 0))
88
+ textarea.selectionEnd = end - removed
89
+ } else if (start !== end) {
90
+ // Multi-line selection: indent all selected lines
91
+ const beforeStart = value.lastIndexOf('\n', start - 1) + 1
92
+ const lines = value.substring(beforeStart, end).split('\n')
93
+ const indented = lines.map(line => ' ' + line)
94
+ const newValue = value.substring(0, beforeStart) + indented.join('\n') + value.substring(end)
95
+
96
+ textarea.value = newValue
97
+ textarea.selectionStart = start + 2
98
+ textarea.selectionEnd = end + (lines.length * 2)
99
+ } else {
100
+ // Single cursor: insert 2 spaces
101
+ textarea.value = value.substring(0, start) + ' ' + value.substring(end)
102
+ textarea.selectionStart = textarea.selectionEnd = start + 2
103
+ }
104
+
105
+ handleInput()
106
+ }
107
+ }
108
+
109
+ // Resize textarea for Safari fallback (no field-sizing support)
110
+ const resizeTextarea = () => {
111
+ const scrollTop = el.scrollTop
112
+ const scrollLeft = el.scrollLeft
113
+ textarea.style.height = 'auto'
114
+ textarea.style.height = textarea.scrollHeight + 'px'
115
+ el.scrollTop = scrollTop
116
+ el.scrollLeft = scrollLeft
117
+ }
118
+
119
+ textarea.addEventListener('input', handleInput)
120
+ textarea.addEventListener('input', resizeTextarea)
121
+ textarea.addEventListener('keydown', handleKeydown)
122
+
123
+ // Register keyboard shortcuts
124
+ const cleanupKeyboard = keyboard(textarea, {
125
+ 'Cmd+s': (e) => {
126
+ e.preventDefault()
127
+
128
+ const originalCode = textarea.value
129
+ const cursorPos = textarea.selectionStart
130
+
131
+ // Find which line and column the cursor is on
132
+ const beforeCursor = originalCode.substring(0, cursorPos)
133
+ const linesBefore = beforeCursor.split('\n')
134
+ const lineIndex = linesBefore.length - 1
135
+ const columnIndex = linesBefore[linesBefore.length - 1].length
136
+
137
+ // Format the code
138
+ const formatted = formatDocument(originalCode)
139
+
140
+ // Find the same line in formatted code
141
+ const formattedLines = formatted.split('\n')
142
+ const targetLine = Math.min(lineIndex, formattedLines.length - 1)
143
+
144
+ // Calculate new cursor position
145
+ let newCursorPos = 0
146
+ for (let i = 0; i < targetLine; i++) {
147
+ newCursorPos += formattedLines[i].length + 1 // +1 for \n
148
+ }
149
+
150
+ // Add column position (respecting new indentation)
151
+ const targetLineContent = formattedLines[targetLine] || ''
152
+ const trimmedOriginalLine = (originalCode.split('\n')[lineIndex] || '').trim()
153
+ const trimmedTargetLine = targetLineContent.trim()
154
+
155
+ // If the line content is the same (just indentation changed), preserve relative position
156
+ if (trimmedOriginalLine === trimmedTargetLine) {
157
+ const indent = targetLineContent.length - targetLineContent.trimStart().length
158
+ const relativeColumn = columnIndex - (linesBefore[linesBefore.length - 1].length - linesBefore[linesBefore.length - 1].trimStart().length)
159
+ newCursorPos += indent + Math.max(0, relativeColumn)
160
+ } else {
161
+ // Line content changed, place cursor at end of line
162
+ newCursorPos += targetLineContent.length
163
+ }
164
+
165
+ // Update textarea and restore cursor
166
+ textarea.value = formatted
167
+ textarea.selectionStart = textarea.selectionEnd = Math.min(newCursorPos, formatted.length)
168
+ handleInput()
169
+ resizeTextarea()
170
+ }
171
+ })
172
+
173
+ // Store handlers for cleanup
174
+ textarea._handlers = { handleInput, handleKeydown, resizeTextarea, cleanupKeyboard }
175
+
176
+ // Initial resize - wait for layout to complete
177
+ requestAnimationFrame(() => resizeTextarea())
178
+
179
+ return textarea
180
+ }
181
+
182
+ // Destroy editable textarea
183
+ export const destroyEditable = instance => {
184
+ if (!instance.textarea) return
185
+
186
+ const { handleInput, handleKeydown, resizeTextarea, cleanupKeyboard } = instance.textarea._handlers
187
+ instance.textarea.removeEventListener('input', handleInput)
188
+ instance.textarea.removeEventListener('input', resizeTextarea)
189
+ instance.textarea.removeEventListener('keydown', handleKeydown)
190
+
191
+ // Clean up keyboard shortcuts
192
+ if (cleanupKeyboard) cleanupKeyboard()
193
+
194
+ instance.textarea.remove()
195
+ instance.textarea = null
196
+ }
package/foldable.js ADDED
@@ -0,0 +1,185 @@
1
+ // Codie foldable HTML tags
2
+ import { CLASS, VOID_ELEMENTS } from './constants.js'
3
+
4
+ // Global fold toggle (attached to window for onclick handlers)
5
+ const toggleFold = (foldId, event) => {
6
+ const wrapper = document.querySelector(`[data-fold-id="${foldId}"]`)
7
+ if (!wrapper) return
8
+
9
+ wrapper.classList.toggle(CLASS.FOLD_OPEN)
10
+ const isOpen = wrapper.classList.contains(CLASS.FOLD_OPEN)
11
+
12
+ // Alt+click toggles all descendants
13
+ if (event?.altKey) {
14
+ wrapper.querySelectorAll(`.${CLASS.FOLD_WRAPPER}`).forEach(el => {
15
+ el.classList[isOpen ? 'add' : 'remove'](CLASS.FOLD_OPEN)
16
+ })
17
+ }
18
+ }
19
+
20
+ // Attach to window
21
+ if (typeof window !== 'undefined') {
22
+ window.__codieFoldToggle = toggleFold
23
+ }
24
+
25
+ // Wrap HTML content with fold controls
26
+ const wrapWithFoldControls = (html, defaultOpenTags = ['html', 'body', 'head']) => {
27
+ const lines = html.split('\n')
28
+ const result = []
29
+ const noNewlineBefore = new Set()
30
+ const elementStack = []
31
+ let foldCounter = 0
32
+
33
+ // Regex to match highlighted tag patterns
34
+ const openTagPattern = new RegExp(
35
+ `<span class="${CLASS.PUNCTUATION}">&lt;<\\/span><span class="${CLASS.TAG}">([^<]+)<\\/span>`
36
+ )
37
+ const closeTagPattern = new RegExp(
38
+ `<span class="${CLASS.PUNCTUATION}">&lt;\\/<\\/span><span class="${CLASS.TAG}">([^<]+)<\\/span>`
39
+ )
40
+
41
+ lines.forEach((line, index) => {
42
+ const openMatch = line.match(openTagPattern)
43
+ const closeMatch = line.match(closeTagPattern)
44
+
45
+ // Opening tag (without closing tag on same line)
46
+ if (openMatch && !closeMatch) {
47
+ const tagName = openMatch[1].toLowerCase()
48
+
49
+ // Skip void elements
50
+ if (VOID_ELEMENTS.has(tagName)) {
51
+ result.push(line)
52
+ return
53
+ }
54
+
55
+ const isRoot = elementStack.length === 0
56
+ const shouldOpen = isRoot || defaultOpenTags.includes(tagName)
57
+
58
+ // Propagate open state to parents
59
+ if (shouldOpen) {
60
+ elementStack.forEach(parent => {
61
+ parent.shouldOpen = true
62
+ if (parent.lineIndex !== undefined) {
63
+ result[parent.lineIndex] = result[parent.lineIndex].replace(
64
+ `class="${CLASS.FOLD_WRAPPER}"`,
65
+ `class="${CLASS.FOLD_WRAPPER} ${CLASS.FOLD_OPEN}"`
66
+ )
67
+ }
68
+ })
69
+ }
70
+
71
+ elementStack.push({
72
+ tagName,
73
+ lineIndex: result.length,
74
+ shouldOpen,
75
+ })
76
+
77
+ const foldId = `codie-fold-${foldCounter++}`
78
+ const openClass = shouldOpen ? ` ${CLASS.FOLD_OPEN}` : ''
79
+ const rootAttr = isRoot ? ' data-root="true"' : ''
80
+
81
+ result.push(
82
+ `<div class="${CLASS.FOLD_WRAPPER}${openClass}" data-tag="${tagName}" data-fold-id="${foldId}"${rootAttr}>` +
83
+ `<div class="${CLASS.FOLD_HEADER}" onclick="__codieFoldToggle('${foldId}', event)" role="button" tabindex="0">` +
84
+ `<button class="${CLASS.FOLD_TOGGLE}" aria-hidden="true">` +
85
+ `<icon style="--icon: var(--icon-cross);"></icon>` +
86
+ `</button>` +
87
+ `<span class="${CLASS.FOLD_LINE}">${line}</span>` +
88
+ `<span class="${CLASS.FOLD_PREVIEW}"> ` +
89
+ `<span class="ellipsis">...</span> ` +
90
+ `<span class="${CLASS.PUNCTUATION}">&lt;/</span>` +
91
+ `<span class="${CLASS.TAG}">${tagName}</span>` +
92
+ `<span class="${CLASS.PUNCTUATION}">&gt;</span>` +
93
+ `</span>` +
94
+ `</div>` +
95
+ `<div class="${CLASS.FOLD_CONTENT}"><div class="${CLASS.FOLD_CONTENT}-inner">`
96
+ )
97
+
98
+ noNewlineBefore.add(result.length)
99
+ }
100
+ // Closing tag
101
+ else if (closeMatch) {
102
+ const tagName = closeMatch[1].toLowerCase()
103
+
104
+ // Find matching opening tag
105
+ if (elementStack.length > 0 && elementStack[elementStack.length - 1].tagName === tagName) {
106
+ result.push(`<span>${line}</span>`)
107
+ noNewlineBefore.add(result.length)
108
+ result.push(`</div></div></div>`)
109
+ noNewlineBefore.add(result.length)
110
+ elementStack.pop()
111
+ } else {
112
+ result.push(line)
113
+ }
114
+ }
115
+ // Regular line
116
+ else {
117
+ result.push(line)
118
+ }
119
+ })
120
+
121
+ return result
122
+ .map((line, idx) => idx === 0 || noNewlineBefore.has(idx) ? line : '\n' + line)
123
+ .join('')
124
+ }
125
+
126
+ // Initialize foldable on an element
127
+ export const initFoldable = (el, display, modes) => {
128
+ // Only fold HTML content
129
+ if (!modes.html) return
130
+
131
+ const html = display.innerHTML
132
+ display.innerHTML = wrapWithFoldControls(html)
133
+
134
+ // Add fold-line class to align content
135
+ display.querySelectorAll(`.${CLASS.FOLD_LINE}`).forEach(line => {
136
+ line.style.marginLeft = '-22px' // Pull back to align
137
+ })
138
+ }
139
+
140
+ // Destroy foldable - restore plain highlighted content
141
+ export const destroyFoldable = (el, display) => {
142
+ // Extract text from fold wrappers and reconstruct
143
+ const extractContent = node => {
144
+ let result = ''
145
+ node.childNodes.forEach(child => {
146
+ if (child.nodeType === Node.TEXT_NODE) {
147
+ result += child.textContent
148
+ } else if (child.classList?.contains(CLASS.FOLD_WRAPPER)) {
149
+ // Get fold-line content
150
+ const foldLine = child.querySelector(`.${CLASS.FOLD_LINE}`)
151
+ if (foldLine) result += foldLine.innerHTML
152
+ // Get fold-content
153
+ const foldContent = child.querySelector(`.${CLASS.FOLD_CONTENT}`)
154
+ if (foldContent) result += '\n' + extractContent(foldContent)
155
+ } else if (child.classList?.contains(CLASS.FOLD_PREVIEW)) {
156
+ // Skip preview
157
+ } else if (child.classList?.contains(CLASS.FOLD_HEADER)) {
158
+ // Skip header
159
+ } else if (child.classList?.contains(CLASS.FOLD_TOGGLE)) {
160
+ // Skip toggle
161
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
162
+ result += child.outerHTML || extractContent(child)
163
+ }
164
+ })
165
+ return result
166
+ }
167
+
168
+ // For now, just remove foldable attribute - full restoration would require re-highlighting
169
+ // This is a simplified version; a full implementation would cache the original HTML
170
+ }
171
+
172
+ // Expand/collapse specific fold
173
+ export const setFoldState = (el, foldId, open) => {
174
+ const wrapper = el.querySelector(`[data-fold-id="${foldId}"]`)
175
+ if (wrapper) {
176
+ wrapper.classList[open ? 'add' : 'remove'](CLASS.FOLD_OPEN)
177
+ }
178
+ }
179
+
180
+ // Expand/collapse all folds
181
+ export const setAllFoldStates = (el, open) => {
182
+ el.querySelectorAll(`.${CLASS.FOLD_WRAPPER}`).forEach(wrapper => {
183
+ wrapper.classList[open ? 'add' : 'remove'](CLASS.FOLD_OPEN)
184
+ })
185
+ }
package/format.js ADDED
@@ -0,0 +1,195 @@
1
+ // Codie code formatting
2
+ import { VOID_ELEMENTS, VALUE_ATTRS } from './constants.js'
3
+
4
+ // Remove common leading whitespace from all lines
5
+ export const dedent = str => {
6
+ const lines = str.replace(/^\s*\n/, '').replace(/\n\s*$/, '').split('\n')
7
+ const nonEmpty = lines.filter(l => l.trim())
8
+ if (!nonEmpty.length) return str.trim()
9
+ const indent = Math.min(...nonEmpty.map(l => l.match(/^\s*/)[0].length))
10
+ return lines.map(l => l.slice(indent)).join('\n')
11
+ }
12
+
13
+ // Clean up boolean attributes for non-value attributes:
14
+ // attr="" → attr, attr="true" → attr, attr="false" → (removed)
15
+ export const cleanupBooleanAttrs = html =>
16
+ html
17
+ .replace(/ ([\w-]+)="(?:true|)"/g, (match, attr) =>
18
+ VALUE_ATTRS.has(attr) ? match : ` ${attr}`
19
+ )
20
+ .replace(/ ([\w-]+)="false"/g, (match, attr) =>
21
+ VALUE_ATTRS.has(attr) ? match : ''
22
+ )
23
+
24
+ // Auto-indent JS based on braces and HTML tags
25
+ export const formatJS = code => {
26
+ const lines = code.split('\n').map(l => l.trim())
27
+ if (lines.every(l => !l)) return code.trim()
28
+
29
+ let result = '', indent = 0
30
+
31
+ for (const line of lines) {
32
+ if (!line) { result += '\n'; continue }
33
+
34
+ // Decrease indent before closing HTML tag or brace
35
+ if (line.match(/^<\//) || line.match(/^[}\]]/)) {
36
+ indent = Math.max(0, indent - 2)
37
+ }
38
+
39
+ result += ' '.repeat(indent) + line + '\n'
40
+
41
+ // Opening HTML tag (not self-closing, no closing on same line)
42
+ if (line.match(/<[a-z][^>\/]*>/i) && !line.match(/\/>/) && !line.match(/<\/[a-z]+>/i)) {
43
+ indent += 2
44
+ }
45
+
46
+ // Opening brace/bracket at end
47
+ if (line.match(/[{(\[]$/)) {
48
+ indent += 2
49
+ }
50
+ }
51
+
52
+ return result.trim()
53
+ }
54
+
55
+ // Format full HTML document with proper indentation
56
+ export const formatDocument = html => {
57
+ // Strip empty values from boolean attributes, but keep value attributes as-is
58
+ html = html.replace(/\s([\w-]+)=""/g, (match, attr) => {
59
+ const isValueAttr = VALUE_ATTRS.has(attr) || attr.startsWith('data-') || attr.startsWith('aria-') || attr.startsWith('on');
60
+ return isValueAttr ? match : ` ${attr}`;
61
+ })
62
+
63
+ // Preserve <pre> content
64
+ const preserved = []
65
+ html = html.replace(/(<pre[^>]*>)([\s\S]*?)(<\/pre>)/gi, (_, open, content, close) => {
66
+ preserved.push(content)
67
+ return open + `\x00PRE${preserved.length - 1}\x00` + close
68
+ })
69
+
70
+ // Preserve HTML comments (including vibe comments with operators like >=)
71
+ const comments = []
72
+ html = html.replace(/(<!--[\s\S]*?-->)/g, (comment) => {
73
+ comments.push(comment)
74
+ return `\x00COMMENT${comments.length - 1}\x00`
75
+ })
76
+
77
+ // Collapse whitespace between tags, but preserve intentional empty lines (2+ newlines)
78
+ html = html.replace(/>\s+</g, (match) => {
79
+ const newlineCount = (match.match(/\n/g) || []).length
80
+ return newlineCount >= 2 ? '>\n\n<' : '><'
81
+ })
82
+
83
+ html = html.replace(/\x00PRE(\d+)\x00/g, (_, idx) => preserved[parseInt(idx)])
84
+
85
+ let result = '', indent = 0
86
+ const tagStack = []
87
+ const tokens = html.split(/(<[^>]+>|\x00COMMENT\d+\x00)/g).filter(t => t)
88
+
89
+ for (let i = 0; i < tokens.length; i++) {
90
+ const token = tokens[i]
91
+
92
+ if (token.startsWith('\x00COMMENT')) {
93
+ // Restore comment and add to result
94
+ const commentIndex = parseInt(token.match(/\x00COMMENT(\d+)\x00/)[1])
95
+ const comment = comments[commentIndex]
96
+ result += ' '.repeat(indent) + comment + '\n'
97
+ } else if (token.startsWith('</')) {
98
+ const tagName = token.match(/<\/([a-zA-Z0-9\-]+)/)?.[1]?.toLowerCase()
99
+ // Find matching opening tag and restore its indent
100
+ for (let j = tagStack.length - 1; j >= 0; j--) {
101
+ if (tagStack[j].tagName === tagName) {
102
+ indent = tagStack[j].indent
103
+ tagStack.splice(j, 1)
104
+ break
105
+ }
106
+ }
107
+ result += ' '.repeat(indent) + token + '\n'
108
+ } else if (token.match(/<[^>]+\/>/)) {
109
+ // Self-closing tag
110
+ result += ' '.repeat(indent) + token + '\n'
111
+ } else if (token.startsWith('<')) {
112
+ const tagName = token.match(/<([a-zA-Z0-9\-]+)/)?.[1]
113
+ const tagNameLower = tagName?.toLowerCase()
114
+
115
+ // Check for empty element (<tag></tag>)
116
+ const next = tokens[i + 1]
117
+ if (next && next === `</${tagName}>`) {
118
+ result += ' '.repeat(indent) + token + next + '\n'
119
+ i++
120
+ } else {
121
+ result += ' '.repeat(indent) + token + '\n'
122
+
123
+ // Non-void elements increase indent
124
+ if (!VOID_ELEMENTS.has(tagNameLower)) {
125
+ tagStack.push({ tagName: tagNameLower, indent })
126
+ indent += 2
127
+ }
128
+ }
129
+ } else {
130
+ // Text content or preserved empty lines
131
+ const hasMultipleNewlines = token.includes('\n\n')
132
+ const text = token.trim()
133
+
134
+ if (hasMultipleNewlines && !text) {
135
+ // Preserve intentional empty lines between elements
136
+ result += '\n'
137
+ } else if (text) {
138
+ const lines = text.split('\n').map(l => l.trim())
139
+ let cssIndent = 0
140
+ lines.forEach(line => {
141
+ if (!line) {
142
+ result += '\n'
143
+ } else {
144
+ if (line.startsWith('}')) cssIndent = Math.max(0, cssIndent - 2)
145
+ result += ' '.repeat(indent + cssIndent) + line + '\n'
146
+ if (line.endsWith('{')) cssIndent += 2
147
+ }
148
+ })
149
+ }
150
+ }
151
+ }
152
+
153
+ return result.trim()
154
+ }
155
+
156
+ // Escape HTML entities
157
+ export const escapeHTML = str => str
158
+ .replace(/&/g, '&amp;')
159
+ .replace(/</g, '&lt;')
160
+ .replace(/>/g, '&gt;')
161
+ .replace(/"/g, '&quot;')
162
+
163
+ // Unescape HTML entities
164
+ export const unescapeHTML = str => str
165
+ .replace(/&lt;/g, '<')
166
+ .replace(/&gt;/g, '>')
167
+ .replace(/&quot;/g, '"')
168
+ .replace(/&amp;/g, '&')
169
+
170
+ // Normalize inline element whitespace to prevent extra spaces in rendered text
171
+ export const normalizeInlineWhitespace = html => {
172
+ // Preserve <template> content (especially codie templates)
173
+ const preserved = [];
174
+ html = html.replace(/(<template[^>]*>)([\s\S]*?)(<\/template>)/gi, (_, open, content, close) => {
175
+ preserved.push(content);
176
+ return open + `\x00TEMPLATE${preserved.length - 1}\x00` + close;
177
+ });
178
+
179
+ const inlineTags = 'code|span|a|b|i|em|strong|kbd|mark|small|sub|sup|s|u|var|abbr|cite|q|time|dfn|samp|data';
180
+ let normalized = html;
181
+ // Remove whitespace after opening inline tags
182
+ normalized = normalized.replace(new RegExp(`(<(?:${inlineTags})[^>]*?>)\\s+`, 'gi'), '$1');
183
+ // Remove whitespace before closing inline tags
184
+ normalized = normalized.replace(new RegExp(`\\s+(</(?:${inlineTags})>)`, 'gi'), '$1');
185
+ // Remove whitespace between closing inline tags and punctuation
186
+ normalized = normalized.replace(
187
+ new RegExp(`(</(?:${inlineTags})>)\\s+([.,;:!?'")\\]])`, 'gi'),
188
+ '$1$2',
189
+ );
190
+
191
+ // Restore <template> content
192
+ normalized = normalized.replace(/\x00TEMPLATE(\d+)\x00/g, (_, idx) => preserved[parseInt(idx)]);
193
+
194
+ return normalized;
195
+ }
@@ -0,0 +1,14 @@
1
+ /* Codie CSS syntax highlighting */
2
+
3
+ [codie] .codie-css-selector {
4
+ color: var(--codie-css-selector);
5
+ font-weight: 600;
6
+ }
7
+
8
+ [codie] .codie-css-property {
9
+ color: var(--codie-css-property);
10
+ }
11
+
12
+ [codie] .codie-css-value {
13
+ color: var(--codie-css-value);
14
+ }
@@ -0,0 +1,41 @@
1
+ /* Codie HTML syntax highlighting */
2
+
3
+ [codie] .codie-tag {
4
+ color: var(--codie-tag);
5
+ font-weight: 600;
6
+ }
7
+
8
+ [codie] .codie-attr-name {
9
+ color: var(--codie-attr-name);
10
+ }
11
+
12
+ [codie] .codie-attr-value {
13
+ color: var(--codie-attr-value);
14
+ }
15
+
16
+ [codie] .codie-punct {
17
+ color: var(--codie-punct);
18
+ }
19
+
20
+ [codie] .codie-comment {
21
+ color: var(--codie-comment);
22
+ }
23
+
24
+ [codie] .codie-text {
25
+ color: var(--codie-text);
26
+ }
27
+
28
+ /* Vibe-specific tokens */
29
+ [codie] .codie-vibe {
30
+ color: var(--codie-vibe);
31
+ }
32
+
33
+ [codie] .codie-vibe-var {
34
+ color: var(--codie-vibe-var);
35
+ transition: color 150ms ease-out;
36
+ }
37
+
38
+ [codie]:hover .codie-vibe-var,
39
+ [codie].codie-has-selection .codie-vibe-var {
40
+ color: oklch(from currentColor calc(l + 0.25) c h);
41
+ }
@@ -0,0 +1,25 @@
1
+ /* Codie JavaScript syntax highlighting */
2
+
3
+ [codie] .codie-js-keyword {
4
+ color: var(--codie-js-keyword);
5
+ }
6
+
7
+ [codie] .codie-js-import {
8
+ color: var(--codie-js-import);
9
+ }
10
+
11
+ [codie] .codie-js-string {
12
+ color: var(--codie-js-string);
13
+ }
14
+
15
+ [codie] .codie-js-function {
16
+ color: var(--codie-js-function);
17
+ }
18
+
19
+ [codie] .codie-js-number {
20
+ color: var(--codie-js-number);
21
+ }
22
+
23
+ [codie] .codie-js-ident {
24
+ color: var(--codie-js-ident);
25
+ }
@@ -0,0 +1,21 @@
1
+ /* Codie JSON syntax highlighting */
2
+
3
+ [codie] .codie-json-key {
4
+ color: var(--codie-json-key);
5
+ }
6
+
7
+ [codie] .codie-json-string {
8
+ color: var(--codie-json-string);
9
+ }
10
+
11
+ [codie] .codie-json-number {
12
+ color: var(--codie-json-number);
13
+ }
14
+
15
+ [codie] .codie-json-boolean {
16
+ color: var(--codie-json-boolean);
17
+ }
18
+
19
+ [codie] .codie-json-null {
20
+ color: var(--codie-json-null);
21
+ }