@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/README.md +232 -0
- package/codie.css +7 -0
- package/codie.js +252 -0
- package/constants.js +228 -0
- package/editable.js +196 -0
- package/foldable.js +185 -0
- package/format.js +195 -0
- package/highlight-css.css +14 -0
- package/highlight-html.css +41 -0
- package/highlight-js.css +25 -0
- package/highlight-json.css +21 -0
- package/highlight.js +423 -0
- package/keyboard.js +61 -0
- package/numberRows.js +51 -0
- package/package.json +29 -0
- package/structure.css +194 -0
- package/theme.css +102 -0
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}"><<\\/span><span class="${CLASS.TAG}">([^<]+)<\\/span>`
|
|
36
|
+
)
|
|
37
|
+
const closeTagPattern = new RegExp(
|
|
38
|
+
`<span class="${CLASS.PUNCTUATION}"><\\/<\\/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}"></</span>` +
|
|
91
|
+
`<span class="${CLASS.TAG}">${tagName}</span>` +
|
|
92
|
+
`<span class="${CLASS.PUNCTUATION}">></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, '&')
|
|
159
|
+
.replace(/</g, '<')
|
|
160
|
+
.replace(/>/g, '>')
|
|
161
|
+
.replace(/"/g, '"')
|
|
162
|
+
|
|
163
|
+
// Unescape HTML entities
|
|
164
|
+
export const unescapeHTML = str => str
|
|
165
|
+
.replace(/</g, '<')
|
|
166
|
+
.replace(/>/g, '>')
|
|
167
|
+
.replace(/"/g, '"')
|
|
168
|
+
.replace(/&/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
|
+
}
|
package/highlight-js.css
ADDED
|
@@ -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
|
+
}
|