diff --git a/l4d2web/l4d2web/static/vendor/README.md b/l4d2web/l4d2web/static/vendor/README.md new file mode 100644 index 0000000..d1e4790 --- /dev/null +++ b/l4d2web/l4d2web/static/vendor/README.md @@ -0,0 +1,22 @@ +# Vendored static assets + +All third-party JS/CSS shipped under `/static/vendor/` is committed +verbatim from the upstream releases below. The strict CSP +(`default-src 'self'`) means we cannot load these from CDNs. + +| File | Upstream | Version | SHA256 | +|---|---|---|---| +| `prism.js` | jsdelivr concat: prism-core.min.js + prism-clike.min.js + prism-bash.min.js | v1.29.0 | `636b6ce9db1eddd5b60992bb34e3fbc1c3364bce7c312f798f20a16011d7681c` | +| `prism.css` | https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css | v1.29.0 | `928e23e6b9fcef82c5f1d1f05b6f7fc5a6e187c60195e59fbf16fc9d071ee057` | +| `codejar.js` | https://cdn.jsdelivr.net/npm/codejar@4.0.0/dist/codejar.js + ESM-strip + browser-global shim | v4.0.0 | `c13c2df70a0712acb6440ff5a19ec0afe46d3e811fcb6c4fecd1fde73ae94486` | + +## Regenerating + +- **Prism:** Re-run the three-component concat in Task 1 Step 1 of + `docs/superpowers/plans/2026-05-16-textarea-code-editor.md` with + an updated `VER`, then re-download the theme CSS. +- **CodeJar:** Re-download from jsdelivr per the same plan's Task 1 + Step 2 (`dist/codejar.js`, not the bare `codejar.js` which does not + exist in v4.x), strip ESM exports, re-append the `window.CodeJar` shim. + +Bump the version + SHA columns in this table after any update. diff --git a/l4d2web/l4d2web/static/vendor/codejar.js b/l4d2web/l4d2web/static/vendor/codejar.js new file mode 100644 index 0000000..82611f7 --- /dev/null +++ b/l4d2web/l4d2web/static/vendor/codejar.js @@ -0,0 +1,489 @@ +const globalWindow = window; +function CodeJar(editor, highlight, opt = {}) { + const options = { + tab: '\t', + indentOn: /[({\[]$/, + moveToNewLine: /^[)}\]]/, + spellcheck: false, + catchTab: true, + preserveIdent: true, + addClosing: true, + history: true, + window: globalWindow, + ...opt + }; + const window = options.window; + const document = window.document; + let listeners = []; + let history = []; + let at = -1; + let focus = false; + let callback; + let prev; // code content prior keydown event + editor.setAttribute('contenteditable', 'plaintext-only'); + editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false'); + editor.style.outline = 'none'; + editor.style.overflowWrap = 'break-word'; + editor.style.overflowY = 'auto'; + editor.style.whiteSpace = 'pre-wrap'; + let isLegacy = false; // true if plaintext-only is not supported + highlight(editor); + if (editor.contentEditable !== 'plaintext-only') + isLegacy = true; + if (isLegacy) + editor.setAttribute('contenteditable', 'true'); + const debounceHighlight = debounce(() => { + const pos = save(); + highlight(editor, pos); + restore(pos); + }, 30); + let recording = false; + const shouldRecord = (event) => { + return !isUndo(event) && !isRedo(event) + && event.key !== 'Meta' + && event.key !== 'Control' + && event.key !== 'Alt' + && !event.key.startsWith('Arrow'); + }; + const debounceRecordHistory = debounce((event) => { + if (shouldRecord(event)) { + recordHistory(); + recording = false; + } + }, 300); + const on = (type, fn) => { + listeners.push([type, fn]); + editor.addEventListener(type, fn); + }; + on('keydown', event => { + if (event.defaultPrevented) + return; + prev = toString(); + if (options.preserveIdent) + handleNewLine(event); + else + legacyNewLineFix(event); + if (options.catchTab) + handleTabCharacters(event); + if (options.addClosing) + handleSelfClosingCharacters(event); + if (options.history) { + handleUndoRedo(event); + if (shouldRecord(event) && !recording) { + recordHistory(); + recording = true; + } + } + if (isLegacy && !isCopy(event)) + restore(save()); + }); + on('keyup', event => { + if (event.defaultPrevented) + return; + if (event.isComposing) + return; + if (prev !== toString()) + debounceHighlight(); + debounceRecordHistory(event); + if (callback) + callback(toString()); + }); + on('focus', _event => { + focus = true; + }); + on('blur', _event => { + focus = false; + }); + on('paste', event => { + recordHistory(); + handlePaste(event); + recordHistory(); + if (callback) + callback(toString()); + }); + on('cut', event => { + recordHistory(); + handleCut(event); + recordHistory(); + if (callback) + callback(toString()); + }); + function save() { + const s = getSelection(); + const pos = { start: 0, end: 0, dir: undefined }; + let { anchorNode, anchorOffset, focusNode, focusOffset } = s; + if (!anchorNode || !focusNode) + throw 'error1'; + // If the anchor and focus are the editor element, return either a full + // highlight or a start/end cursor position depending on the selection + if (anchorNode === editor && focusNode === editor) { + pos.start = (anchorOffset > 0 && editor.textContent) ? editor.textContent.length : 0; + pos.end = (focusOffset > 0 && editor.textContent) ? editor.textContent.length : 0; + pos.dir = (focusOffset >= anchorOffset) ? '->' : '<-'; + return pos; + } + // Selection anchor and focus are expected to be text nodes, + // so normalize them. + if (anchorNode.nodeType === Node.ELEMENT_NODE) { + const node = document.createTextNode(''); + anchorNode.insertBefore(node, anchorNode.childNodes[anchorOffset]); + anchorNode = node; + anchorOffset = 0; + } + if (focusNode.nodeType === Node.ELEMENT_NODE) { + const node = document.createTextNode(''); + focusNode.insertBefore(node, focusNode.childNodes[focusOffset]); + focusNode = node; + focusOffset = 0; + } + visit(editor, el => { + if (el === anchorNode && el === focusNode) { + pos.start += anchorOffset; + pos.end += focusOffset; + pos.dir = anchorOffset <= focusOffset ? '->' : '<-'; + return 'stop'; + } + if (el === anchorNode) { + pos.start += anchorOffset; + if (!pos.dir) { + pos.dir = '->'; + } + else { + return 'stop'; + } + } + else if (el === focusNode) { + pos.end += focusOffset; + if (!pos.dir) { + pos.dir = '<-'; + } + else { + return 'stop'; + } + } + if (el.nodeType === Node.TEXT_NODE) { + if (pos.dir != '->') + pos.start += el.nodeValue.length; + if (pos.dir != '<-') + pos.end += el.nodeValue.length; + } + }); + // collapse empty text nodes + editor.normalize(); + return pos; + } + function restore(pos) { + const s = getSelection(); + let startNode, startOffset = 0; + let endNode, endOffset = 0; + if (!pos.dir) + pos.dir = '->'; + if (pos.start < 0) + pos.start = 0; + if (pos.end < 0) + pos.end = 0; + // Flip start and end if the direction reversed + if (pos.dir == '<-') { + const { start, end } = pos; + pos.start = end; + pos.end = start; + } + let current = 0; + visit(editor, el => { + if (el.nodeType !== Node.TEXT_NODE) + return; + const len = (el.nodeValue || '').length; + if (current + len > pos.start) { + if (!startNode) { + startNode = el; + startOffset = pos.start - current; + } + if (current + len > pos.end) { + endNode = el; + endOffset = pos.end - current; + return 'stop'; + } + } + current += len; + }); + if (!startNode) + startNode = editor, startOffset = editor.childNodes.length; + if (!endNode) + endNode = editor, endOffset = editor.childNodes.length; + // Flip back the selection + if (pos.dir == '<-') { + [startNode, startOffset, endNode, endOffset] = [endNode, endOffset, startNode, startOffset]; + } + s.setBaseAndExtent(startNode, startOffset, endNode, endOffset); + } + function beforeCursor() { + const s = getSelection(); + const r0 = s.getRangeAt(0); + const r = document.createRange(); + r.selectNodeContents(editor); + r.setEnd(r0.startContainer, r0.startOffset); + return r.toString(); + } + function afterCursor() { + const s = getSelection(); + const r0 = s.getRangeAt(0); + const r = document.createRange(); + r.selectNodeContents(editor); + r.setStart(r0.endContainer, r0.endOffset); + return r.toString(); + } + function handleNewLine(event) { + if (event.key === 'Enter') { + const before = beforeCursor(); + const after = afterCursor(); + let [padding] = findPadding(before); + let newLinePadding = padding; + // If last symbol is "{" ident new line + if (options.indentOn.test(before)) { + newLinePadding += options.tab; + } + // Preserve padding + if (newLinePadding.length > 0) { + preventDefault(event); + event.stopPropagation(); + insert('\n' + newLinePadding); + } + else { + legacyNewLineFix(event); + } + // Place adjacent "}" on next line + if (newLinePadding !== padding && options.moveToNewLine.test(after)) { + const pos = save(); + insert('\n' + padding); + restore(pos); + } + } + } + function legacyNewLineFix(event) { + // Firefox does not support plaintext-only mode + // and puts

on Enter. Let's help. + if (isLegacy && event.key === 'Enter') { + preventDefault(event); + event.stopPropagation(); + if (afterCursor() == '') { + insert('\n '); + const pos = save(); + pos.start = --pos.end; + restore(pos); + } + else { + insert('\n'); + } + } + } + function handleSelfClosingCharacters(event) { + const open = `([{'"`; + const close = `)]}'"`; + if (open.includes(event.key)) { + preventDefault(event); + const pos = save(); + const wrapText = pos.start == pos.end ? '' : getSelection().toString(); + const text = event.key + wrapText + close[open.indexOf(event.key)]; + insert(text); + pos.start++; + pos.end++; + restore(pos); + } + } + function handleTabCharacters(event) { + if (event.key === 'Tab') { + preventDefault(event); + if (event.shiftKey) { + const before = beforeCursor(); + let [padding, start,] = findPadding(before); + if (padding.length > 0) { + const pos = save(); + // Remove full length tab or just remaining padding + const len = Math.min(options.tab.length, padding.length); + restore({ start, end: start + len }); + document.execCommand('delete'); + pos.start -= len; + pos.end -= len; + restore(pos); + } + } + else { + insert(options.tab); + } + } + } + function handleUndoRedo(event) { + if (isUndo(event)) { + preventDefault(event); + at--; + const record = history[at]; + if (record) { + editor.innerHTML = record.html; + restore(record.pos); + } + if (at < 0) + at = 0; + } + if (isRedo(event)) { + preventDefault(event); + at++; + const record = history[at]; + if (record) { + editor.innerHTML = record.html; + restore(record.pos); + } + if (at >= history.length) + at--; + } + } + function recordHistory() { + if (!focus) + return; + const html = editor.innerHTML; + const pos = save(); + const lastRecord = history[at]; + if (lastRecord) { + if (lastRecord.html === html + && lastRecord.pos.start === pos.start + && lastRecord.pos.end === pos.end) + return; + } + at++; + history[at] = { html, pos }; + history.splice(at + 1); + const maxHistory = 300; + if (at > maxHistory) { + at = maxHistory; + history.splice(0, 1); + } + } + function handlePaste(event) { + preventDefault(event); + const text = (event.originalEvent || event) + .clipboardData + .getData('text/plain') + .replace(/\r\n?/g, '\n'); + const pos = save(); + insert(text); + highlight(editor); + restore({ + start: Math.min(pos.start, pos.end) + text.length, + end: Math.min(pos.start, pos.end) + text.length, + dir: '<-', + }); + } + function handleCut(event) { + const pos = save(); + const selection = getSelection(); + const originalEvent = event.originalEvent ?? event; + originalEvent.clipboardData.setData("text/plain", selection.toString()); + document.execCommand('delete'); + highlight(editor); + restore({ + start: pos.start, + end: pos.start, + dir: '->', + }); + preventDefault(event); + } + function visit(editor, visitor) { + const queue = []; + if (editor.firstChild) + queue.push(editor.firstChild); + let el = queue.pop(); + while (el) { + if (visitor(el) === 'stop') + break; + if (el.nextSibling) + queue.push(el.nextSibling); + if (el.firstChild) + queue.push(el.firstChild); + el = queue.pop(); + } + } + function isCtrl(event) { + return event.metaKey || event.ctrlKey; + } + function isUndo(event) { + return isCtrl(event) && !event.shiftKey && getKeyCode(event) === 'Z'; + } + function isRedo(event) { + return isCtrl(event) && event.shiftKey && getKeyCode(event) === 'Z'; + } + function isCopy(event) { + return isCtrl(event) && getKeyCode(event) === 'C'; + } + function getKeyCode(event) { + let key = event.key || event.keyCode || event.which; + if (!key) + return undefined; + return (typeof key === 'string' ? key : String.fromCharCode(key)).toUpperCase(); + } + function insert(text) { + text = text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + document.execCommand('insertHTML', false, text); + } + function debounce(cb, wait) { + let timeout = 0; + return (...args) => { + clearTimeout(timeout); + timeout = window.setTimeout(() => cb(...args), wait); + }; + } + function findPadding(text) { + // Find beginning of previous line. + let i = text.length - 1; + while (i >= 0 && text[i] !== '\n') + i--; + i++; + // Find padding of the line. + let j = i; + while (j < text.length && /[ \t]/.test(text[j])) + j++; + return [text.substring(i, j) || '', i, j]; + } + function toString() { + return editor.textContent || ''; + } + function preventDefault(event) { + event.preventDefault(); + } + function getSelection() { + if (editor.parentNode?.nodeType == Node.DOCUMENT_FRAGMENT_NODE) { + return editor.parentNode.getSelection(); + } + return window.getSelection(); + } + return { + updateOptions(newOptions) { + Object.assign(options, newOptions); + }, + updateCode(code) { + editor.textContent = code; + highlight(editor); + if (callback) + callback(code); + }, + onUpdate(cb) { + callback = cb; + }, + toString, + save, + restore, + recordHistory, + destroy() { + for (let [type, fn] of listeners) { + editor.removeEventListener(type, fn); + } + }, + }; +} + +// Browser global shim: surface CodeJar on window so non-module +//