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