diff --git a/static/index.html b/static/index.html index 29c39fe..9f02734 100644 --- a/static/index.html +++ b/static/index.html @@ -5,9 +5,24 @@ Claude Code Chat Browser - - - + + + + diff --git a/static/js/app.js b/static/js/app.js index 262e80e..c3edcd8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,5 +1,33 @@ // Claude Code Chat Browser — Main JS +// Highlight.js theme stylesheets, keyed by theme name. Both `href` and +// `integrity` MUST be assigned together when swapping at runtime — +// changing `href` while leaving a stale `integrity` would make the +// browser refuse the new stylesheet and break the UI (issue #19). +// Hashes verified against cdnjs's SRI API. The corresponding static +// tag in static/index.html carries crossorigin="anonymous" which +// persists across runtime href swaps. +const HLJS_THEME_SHEETS = { + dark: { + href: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css', + integrity: 'sha512-mtXspRdOWHCYp+f4c7CkWGYPPRAhq9X+xCvJMUBVAb6pqA4U8pxhT3RWT3LP3bKbiolYL2CkL1bSKZZO4eeTew==', + }, + light: { + href: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css', + integrity: 'sha512-0aPQyyeZrWj9sCA46UlmWgKOP0mUipLQ6OZXu8l4IcAmD2u31EPEy9VcIMvl7SoAaKe8bLXZhYoMaE/in+gcgA==', + }, +}; + +function applyHljsTheme(themeName) { + const link = document.getElementById('hljs-theme'); + if (!link) return; + const sheet = HLJS_THEME_SHEETS[themeName] || HLJS_THEME_SHEETS.dark; + // Set integrity FIRST, then href — the browser reads the current + // integrity at fetch time, and href change is what triggers the fetch. + link.integrity = sheet.integrity; + link.href = sheet.href; +} + function showToast(message, type = 'info') { const icons = { success: '\u2713', error: '\u2717', info: '\u2139' }; const toast = document.createElement('div'); @@ -122,14 +150,8 @@ function setHamburgerVisible(visible) { function setWorkspaceMode(active) { // No container class change needed — workspace lives inside the standard container document.body.classList.toggle('workspace-mode', active); - // Switch highlight.js theme - const hljsLink = document.getElementById('hljs-theme'); - if (hljsLink) { - const theme = localStorage.getItem('theme') || 'dark'; - hljsLink.href = theme === 'dark' - ? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css' - : 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css'; - } + // Switch highlight.js theme — helper updates href + integrity together (issue #19). + applyHljsTheme(localStorage.getItem('theme') || 'dark'); } let _navInProgress = false; @@ -836,12 +858,7 @@ function applyTheme(theme) { moon.style.display = theme === 'dark' ? 'block' : 'none'; sun.style.display = theme === 'light' ? 'block' : 'none'; } - const hljsLink = document.getElementById('hljs-theme'); - if (hljsLink) { - hljsLink.href = theme === 'dark' - ? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css' - : 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css'; - } + applyHljsTheme(theme); // href + integrity swapped together (issue #19) } function toggleTheme() { diff --git a/tests/test_hljs_theme_consistency.py b/tests/test_hljs_theme_consistency.py new file mode 100644 index 0000000..6f55b70 --- /dev/null +++ b/tests/test_hljs_theme_consistency.py @@ -0,0 +1,65 @@ +"""Regression test for highlight.js theme URL+hash drift between +static/index.html (initial load) and static/js/app.js (runtime swap). + +Both files carry the dark-theme stylesheet URL and its SRI hash. On a +highlight.js version bump both must update together — if they drift, either +the initial load breaks (HTML stale, mismatched hash) or the runtime theme +swap breaks (JS stale). This test fails fast when they diverge so the +"MUST also swap the integrity attribute" comments in both files become a +checked invariant rather than a hope. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +INDEX_HTML = REPO_ROOT / "static" / "index.html" +APP_JS = REPO_ROOT / "static" / "js" / "app.js" + + +def _link_attr(html: str, link_id: str, attr: str) -> str: + """Return the value of `attr` on the tag with id=`link_id`.""" + tag_re = re.compile( + r']*\bid\s*=\s*"' + re.escape(link_id) + r'"[^>]*>', + re.DOTALL, + ) + m = tag_re.search(html) + assert m, f'No found in index.html' + attr_m = re.search(re.escape(attr) + r'\s*=\s*"([^"]*)"', m.group(0)) + assert attr_m, f' has no {attr!r} attribute' + return attr_m.group(1) + + +def _js_theme_entry(js: str, theme: str) -> dict: + """Return {'href': ..., 'integrity': ...} from HLJS_THEME_SHEETS..""" + block = re.search(re.escape(theme) + r"\s*:\s*\{([^}]*)\}", js, re.DOTALL) + assert block, f"HLJS_THEME_SHEETS.{theme} entry not found in app.js" + body = block.group(1) + out = {} + for key in ("href", "integrity"): + m = re.search(key + r"\s*:\s*['\"]([^'\"]+)['\"]", body) + assert m, f"HLJS_THEME_SHEETS.{theme} has no {key!r} key" + out[key] = m.group(1) + return out + + +def test_dark_theme_url_and_hash_match_between_html_and_js(): + html = INDEX_HTML.read_text(encoding="utf-8") + js = APP_JS.read_text(encoding="utf-8") + + html_href = _link_attr(html, "hljs-theme", "href") + html_integrity = _link_attr(html, "hljs-theme", "integrity") + js_dark = _js_theme_entry(js, "dark") + + assert html_href == js_dark["href"], ( + "highlight.js theme URL drifted between index.html and app.js — " + f"html={html_href!r}, app.js HLJS_THEME_SHEETS.dark={js_dark['href']!r}. " + "On a version bump both must update together (issue #19)." + ) + assert html_integrity == js_dark["integrity"], ( + "highlight.js theme SRI hash drifted between index.html and app.js — " + f"html={html_integrity!r}, " + f"app.js HLJS_THEME_SHEETS.dark={js_dark['integrity']!r}." + )