Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
},
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
21 changes: 21 additions & 0 deletions src/dockle/sphinx/themes/dockle/static/doxygen-settings.js
Original file line number Diff line number Diff line change
@@ -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;
};
})();
33 changes: 33 additions & 0 deletions src/dockle/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
141 changes: 141 additions & 0 deletions tests-js/doxygen-settings.test.mjs
Original file line number Diff line number Diff line change
@@ -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 }));
});
58 changes: 58 additions & 0 deletions tests-js/fixtures/doxygen-cookie.js
Original file line number Diff line number Diff line change
@@ -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);
}
},
}
58 changes: 58 additions & 0 deletions tests/test_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<html><head></head><body></body></html>", 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(
"<html><head></head><body></body></html>", 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)
Expand Down
Loading