diff --git a/eslint.config.mjs b/eslint.config.mjs index 760f4d2..e1923a3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,7 @@ export default [ 'coverage/**', 'node_modules/**', 'src/dockle/sphinx/themes/dockle/static/lucide.min.js', + 'tests-js/fixtures/doxygen-cookie.js', ], }, js.configs.recommended, @@ -38,7 +39,10 @@ export default [ }, }, { - files: ['src/dockle/sphinx/themes/dockle/static/dockle.js'], + files: [ + 'src/dockle/sphinx/themes/dockle/static/dockle.js', + 'src/dockle/sphinx/themes/dockle/static/doxygen-settings.js', + ], languageOptions: { globals: globals.browser, }, diff --git a/package.json b/package.json index e52ae93..e838b61 100644 --- a/package.json +++ b/package.json @@ -55,8 +55,8 @@ "stage:examples": "node scripts/stage-examples.mjs", "sync:lucide": "node scripts/sync-lucide.mjs", "test": "npm run test:unit && npm run test:lint", - "test:lint": "eslint publish.js eslint.config.mjs bin scripts src/dockle/jsdoc_template src/dockle/sphinx/themes/dockle/static/dockle.js tests-js", - "test:unit": "c8 --all --include=publish.js --include=bin/*.cjs --include=src/dockle/jsdoc_template/*.js --reporter=json --reporter=text node --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=junit.xml tests-js/jsdoc-template.test.mjs", + "test:lint": "eslint publish.js eslint.config.mjs bin scripts src/dockle/jsdoc_template src/dockle/sphinx/themes/dockle/static/dockle.js src/dockle/sphinx/themes/dockle/static/doxygen-settings.js tests-js", + "test:unit": "c8 --all --include=publish.js --include=bin/*.cjs --include=src/dockle/jsdoc_template/*.js --include=src/dockle/sphinx/themes/dockle/static/doxygen-settings.js --reporter=json --reporter=text node --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=junit.xml tests-js/*.test.mjs", "test:package": "npm pack --dry-run" }, "dependencies": { diff --git a/src/dockle/sphinx/themes/dockle/static/doxygen-settings.js b/src/dockle/sphinx/themes/dockle/static/doxygen-settings.js new file mode 100644 index 0000000..36d8ca6 --- /dev/null +++ b/src/dockle/sphinx/themes/dockle/static/doxygen-settings.js @@ -0,0 +1,21 @@ +/* global Cookie, dockleDoxygenNavigationPages */ + +(() => { + if (typeof Cookie === 'undefined') return; + + // cookie.js resolves to the same documentation root from every nested page. + const documentationRoot = new URL('.', document.currentScript.src); + Cookie.cookie_namespace = `dockle_doxygen_${encodeURIComponent(documentationRoot.pathname)}_`; + + const navigationPages = new Set(dockleDoxygenNavigationPages); + const readSetting = Cookie.readSetting; + Cookie.readSetting = function (setting, defaultValue) { + const value = readSetting.call(this, setting, defaultValue); + if (setting === 'navpath' && value && !navigationPages.has(value.split('#')[0])) { + // Doxygen's homepage fallback would otherwise reuse this invalid URL. + this.eraseSetting(setting); + return defaultValue; + } + return value; + }; +})(); diff --git a/src/dockle/theme.py b/src/dockle/theme.py index 5aea8b4..51b86e3 100644 --- a/src/dockle/theme.py +++ b/src/dockle/theme.py @@ -103,6 +103,11 @@ _DOCKLE_URL = "https://github.com/LizardByte/dockle" _CSS_ASSET = "dockle.css" _SCRIPT_ASSET = "dockle.js" +_DOXYGEN_SETTINGS_ASSET = "doxygen-settings.js" +_DOXYGEN_SETTINGS_MARKER = "\n/* dockle-doxygen-settings */\n" +_DOXYGEN_NAVIGATION_KEY = re.compile( + r'^\s*("(?:[^"\\]|\\.)*")\s*:', re.MULTILINE +) _LUCIDE_ASSET = "lucide.min.js" _HIGHLIGHT_ASSET = "highlight.min.js" _INDEX_FILE = "index.html" @@ -509,6 +514,8 @@ def apply_theme( f"{framework} did not generate any HTML files in {output}" ) + if framework == "doxygen": + _configure_doxygen_settings(output) asset_dir = _write_theme_assets(output, stylesheet) logo_asset = _copy_logo(logo, asset_dir) favicon_asset = _copy_favicon(favicon, asset_dir) @@ -586,6 +593,32 @@ def apply_theme( return themed +def _configure_doxygen_settings(output: Path) -> None: + """Scope settings and reject missing navtree pages before initialization.""" + + cookie_file = output / "cookie.js" + if not cookie_file.is_file(): + return + navigation_pages = sorted({ + json.loads(match.group(1)).split("#", 1)[0] + for index_file in output.glob("navtreeindex*.js") + for match in _DOXYGEN_NAVIGATION_KEY.finditer( + index_file.read_text(encoding="utf-8") + ) + }) + runtime = _theme_asset(_DOXYGEN_SETTINGS_ASSET).read_text(encoding="utf-8") + configuration = ( + f"const dockleDoxygenNavigationPages = {json.dumps(navigation_pages)};\n" + ) + original = cookie_file.read_text(encoding="utf-8") + source = original.partition(_DOXYGEN_SETTINGS_MARKER)[0].rstrip() + if not source.endswith(";"): + source += ";" + updated = source + _DOXYGEN_SETTINGS_MARKER + configuration + runtime + if updated != original: + cookie_file.write_text(updated, encoding="utf-8") + + def _write_theme_assets(output: Path, stylesheet: str) -> Path: asset_dir = output / "_dockle" asset_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests-js/doxygen-settings.test.mjs b/tests-js/doxygen-settings.test.mjs new file mode 100644 index 0000000..8baf9b7 --- /dev/null +++ b/tests-js/doxygen-settings.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { createContext, runInContext } from 'node:vm'; + +const runtimePath = fileURLToPath(new URL( + '../src/dockle/sphinx/themes/dockle/static/doxygen-settings.js', import.meta.url, +)); +const runtime = await readFile(runtimePath, 'utf8'); +const fixturePath = fileURLToPath(new URL('./fixtures/doxygen-cookie.js', import.meta.url)); +const cookieSource = await readFile(fixturePath, 'utf8'); + +function createStorage() { + const values = new Map(); + return { + values, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + }; +} + +function createBrowser(chromium) { + const cookies = new Map(); + return { + cookies, + localStorage: createStorage(), + sessionStorage: createStorage(), + window: chromium ? { chrome: {} } : {}, + cookieDocument: { + get cookie() { + return [...cookies].map(([key, value]) => `${key}=${value}`).join('; '); + }, + set cookie(assignment) { + const [pair] = assignment.split(';'); + const separator = pair.indexOf('='); + const key = pair.slice(0, separator); + const value = pair.slice(separator + 1); + if (!value && /;\s*expires=/i.test(assignment)) cookies.delete(key); + else cookies.set(key, value); + }, + }, + }; +} + +function loadSettings(browser, root, pages = ['index.html', 'usage.html', 'd0/d00/source.html']) { + const document = Object.create(browser.cookieDocument); + document.currentScript = { src: `https://docs.example.test${root}cookie.js` }; + const context = createContext({ ...browser, document, URL, dockleDoxygenNavigationPages: pages }); + runInContext(cookieSource, context, { filename: fixturePath }); + runInContext(runtime, context, { filename: runtimePath }); + return runInContext('Cookie', context); +} + +for (const [browserName, chromium] of [['Firefox', false], ['Chromium', true]]) { + test(`${browserName} ignores legacy state and isolates projects and versions`, () => { + const browser = createBrowser(chromium); + browser.cookies.set('doxygen_navpath', 'foreign.html'); + browser.sessionStorage.setItem('doxygen_navpath', 'foreign.html'); + const first = loadSettings(browser, '/projects/first/master/'); + const second = loadSettings(browser, '/projects/second/master/'); + const release = loadSettings(browser, '/projects/first/v1/'); + + assert.equal(first.readSetting('navpath', ''), ''); + first.writeSetting('navpath', 'usage.html#details', 0); + assert.equal(first.readSetting('navpath', ''), 'usage.html#details'); + assert.equal(second.readSetting('navpath', ''), ''); + assert.equal(release.readSetting('navpath', ''), ''); + assert.notEqual(first.cookie_namespace, second.cookie_namespace); + assert.notEqual(first.cookie_namespace, release.cookie_namespace); + assert.equal( + first.cookie_namespace, + 'dockle_doxygen_%2Fprojects%2Ffirst%2Fmaster%2F_', + ); + }); + + test(`${browserName} preserves valid pages and rejects removed or unindexed pages`, () => { + const browser = createBrowser(chromium); + const settings = loadSettings(browser, '/projects/first/master/'); + for (const page of ['index.html', 'usage.html#removed-anchor', 'd0/d00/source.html#l123']) { + settings.writeSetting('navpath', page, 0); + assert.equal(settings.readSetting('navpath', ''), page); + } + for (const page of ['removed.html', 'foreign.html#section', 'dockle-search.html']) { + settings.writeSetting('navpath', page, 0); + assert.equal(settings.readSetting('navpath', 'index.html'), 'index.html'); + assert.equal(settings.readSetting('navpath', ''), ''); + assert.equal(browser.cookies.has(`${settings.cookie_namespace}navpath`), false); + assert.equal(browser.sessionStorage.values.has(`${settings.cookie_namespace}navpath`), false); + } + }); + + test(`${browserName} drops a saved page removed by a rebuild`, () => { + const browser = createBrowser(chromium); + const before = loadSettings(browser, '/projects/first/master/', ['index.html', 'removed.html']); + before.writeSetting('navpath', 'removed.html#details', 0); + assert.equal(before.readSetting('navpath', ''), 'removed.html#details'); + const after = loadSettings(browser, '/projects/first/master/', ['index.html']); + assert.equal(after.readSetting('navpath', ''), ''); + }); + + test(`${browserName} keeps ordinary settings and later valid sidebar writes`, () => { + const settings = loadSettings(createBrowser(chromium), '/projects/first/master/'); + assert.equal(settings.readSetting('navpath', ''), ''); + settings.writeSetting('width', '300'); + assert.equal(settings.readSetting('width', '250'), '300'); + settings.writeSetting('navpath', 'removed.html', 0); + assert.equal(settings.readSetting('navpath', ''), ''); + settings.writeSetting('navpath', 'usage.html', 0); + assert.equal(settings.readSetting('navpath', ''), 'usage.html'); + }); +} + +test('Chromium rejects invalid state from both persistent and session storage', () => { + const browser = createBrowser(true); + const settings = loadSettings(browser, '/projects/first/master/'); + const key = `${settings.cookie_namespace}navpath`; + browser.localStorage.setItem(key, 'removed.html'); + browser.sessionStorage.setItem(key, 'foreign.html'); + assert.equal(settings.readSetting('navpath', ''), ''); + assert.equal(settings.readSetting('navpath', ''), ''); + assert.equal(browser.localStorage.values.has(key), false); + assert.equal(browser.sessionStorage.values.has(key), false); +}); + +test('documentation roots with spaces or Unicode remain distinct', () => { + const browser = createBrowser(false); + const first = loadSettings(browser, '/projects/first%20name/master/'); + const second = loadSettings(browser, '/projects/first-name/master/'); + const unicode = loadSettings(browser, '/projects/caf%C3%A9/master/'); + first.writeSetting('navpath', 'usage.html', 0); + unicode.writeSetting('navpath', 'index.html', 0); + assert.equal(first.readSetting('navpath', ''), 'usage.html'); + assert.equal(second.readSetting('navpath', ''), ''); + assert.equal(unicode.readSetting('navpath', ''), 'index.html'); +}); + +test('Doxygen versions without the Cookie helper are left untouched', () => { + assert.doesNotThrow(() => runInContext(runtime, createContext({}), { filename: runtimePath })); +}); diff --git a/tests-js/fixtures/doxygen-cookie.js b/tests-js/fixtures/doxygen-cookie.js new file mode 100644 index 0000000..53ad21d --- /dev/null +++ b/tests-js/fixtures/doxygen-cookie.js @@ -0,0 +1,58 @@ +/*! + Cookie helper functions + Copyright (c) 2023 Dimitri van Heesch + Released under MIT license. +*/ +let Cookie = { + cookie_namespace: 'doxygen_', + + readSetting(cookie,defVal) { + if (window.chrome) { + const val = localStorage.getItem(this.cookie_namespace+cookie) || + sessionStorage.getItem(this.cookie_namespace+cookie); + if (val) return val; + } else { + let myCookie = this.cookie_namespace+cookie+"="; + if (document.cookie) { + const index = document.cookie.indexOf(myCookie); + if (index != -1) { + const valStart = index + myCookie.length; + let valEnd = document.cookie.indexOf(";", valStart); + if (valEnd == -1) { + valEnd = document.cookie.length; + } + return document.cookie.substring(valStart, valEnd); + } + } + } + return defVal; + }, + + writeSetting(cookie,val,days=10*365) { // default days='forever', 0=session cookie, -1=delete + if (window.chrome) { + if (days==0) { + sessionStorage.setItem(this.cookie_namespace+cookie,val); + } else { + localStorage.setItem(this.cookie_namespace+cookie,val); + } + } else { + let date = new Date(); + date.setTime(date.getTime()+(days*24*60*60*1000)); + const expiration = days!=0 ? "expires="+date.toGMTString()+";" : ""; + document.cookie = this.cookie_namespace + cookie + "=" + + val + "; SameSite=Lax;" + expiration + "path=/"; + } + }, + + eraseSetting(cookie) { + if (window.chrome) { + if (localStorage.getItem(this.cookie_namespace+cookie)) { + localStorage.removeItem(this.cookie_namespace+cookie); + } else if (sessionStorage.getItem(this.cookie_namespace+cookie)) { + sessionStorage.removeItem(this.cookie_namespace+cookie); + } + } else { + this.writeSetting(cookie,'',-1); + } + }, +} diff --git a/tests/test_theme.py b/tests/test_theme.py index 85e82b0..a26b6aa 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -476,6 +476,64 @@ def test_apply_theme_is_idempotent(self) -> None: self.assertEqual(first, 1) self.assertEqual(second, 0) + def test_doxygen_settings_use_all_navtree_shards_and_refresh_idempotently(self) -> None: + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) + (output / "index.html").write_text( + "", encoding="utf-8" + ) + cookie_file = output / "cookie.js" + cookie_file.write_text("let Cookie = {}", encoding="utf-8") + first_index = output / "navtreeindex0.js" + first_index.write_text( + 'var NAVTREEINDEX0 =\n{\n"index.html":[],\n' + '"usage.html#details":[0],\n"usage.html#other":[0]\n};', + encoding="utf-8", + ) + second_index = output / "navtreeindex1.js" + second_index.write_text( + 'var NAVTREEINDEX1 =\n{\n"d0/d00/source.html#l1":[1],\n' + + json.dumps('café"page.html') + ':[2]\n};', + encoding="utf-8", + ) + + apply_theme(output, "doxygen", "body {}") + patched = cookie_file.read_text(encoding="utf-8") + expected_pages = json.dumps([ + 'café"page.html', "d0/d00/source.html", "index.html", "usage.html" + ]) + self.assertTrue(patched.startswith("let Cookie = {};\n")) + self.assertIn(f"const dockleDoxygenNavigationPages = {expected_pages};", patched) + self.assertNotIn("/* dockle-doxygen-navigation-pages */", patched) + self.assertEqual(apply_theme(output, "doxygen", "body {}"), 0) + self.assertEqual(cookie_file.read_text(encoding="utf-8"), patched) + + first_index.write_text( + 'var NAVTREEINDEX0 =\n{\n"index.html":[]\n};', encoding="utf-8" + ) + second_index.unlink() + apply_theme(output, "doxygen", "body {}") + refreshed = cookie_file.read_text(encoding="utf-8") + self.assertIn('const dockleDoxygenNavigationPages = ["index.html"];', refreshed) + self.assertNotIn('"usage.html"', refreshed) + self.assertEqual(refreshed.count("/* dockle-doxygen-settings */"), 1) + self.assertEqual(refreshed.count("Cookie.readSetting ="), 1) + + def test_other_frameworks_preserve_cookie_scripts(self) -> None: + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) + (output / "index.html").write_text( + "", encoding="utf-8" + ) + cookie_file = output / "cookie.js" + cookie_file.write_text("unrelated cookie script", encoding="utf-8") + + apply_theme(output, "jsdoc", "body {}") + + self.assertEqual( + cookie_file.read_text(encoding="utf-8"), "unrelated cookie script" + ) + def test_apply_theme_rejects_malformed_html(self) -> None: with tempfile.TemporaryDirectory() as directory: output = Path(directory)