From 9f101d9295c0e4302336a549e812dd8fa5f5d937 Mon Sep 17 00:00:00 2001 From: Richard Gibson Date: Mon, 20 Apr 2026 04:14:28 -0400 Subject: [PATCH 1/5] Make multipage a sticky preference Fixes #631 Set a localStorage "preferMultipage" entry to either "true" or "false" when toggling multipage, and check for it when loading any page to perform a prompt redirect if needed (e.g., before loading an entire single-page spec). --- .eslintrc.json | 2 +- js/menu.js | 28 +++------- js/multipage.js | 52 ++++++++++++++++--- src/Spec.ts | 12 ++--- .../generated-reference/assets-inline.html | 28 +++------- 5 files changed, 65 insertions(+), 57 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 0e0899c3..6bc89d13 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -59,7 +59,7 @@ }, "globals": { "multipageMap": "readonly", - "usesMultipage": "readonly", + "isMultipage": "readonly", "idToSection": "readonly", "sdoMap": "readonly", "biblio": "readonly", diff --git a/js/menu.js b/js/menu.js index da5e68c8..245ec59c 100644 --- a/js/menu.js +++ b/js/menu.js @@ -1069,11 +1069,11 @@ function sortByClauseNumber(clause1, clause2) { function makeLinkToId(id) { let hash = '#' + id; - if (typeof idToSection === 'undefined' || !idToSection[id]) { - return hash; + if (typeof isMultipage !== 'undefined' && isMultipage && idToSection[id]) { + let targetSec = idToSection[id]; + return (targetSec === 'index' ? './' : targetSec + '.html') + hash; } - let targetSec = idToSection[id]; - return (targetSec === 'index' ? './' : targetSec + '.html') + hash; + return hash; } function doShortcut(e) { @@ -1088,23 +1088,9 @@ function doShortcut(e) { if (e.altKey || e.ctrlKey || e.metaKey) { return; } - if (e.key === 'm' && usesMultipage) { - let pathParts = location.pathname.split('/'); - let hash = location.hash; - if (pathParts[pathParts.length - 2] === 'multipage') { - if (hash === '') { - let sectionName = pathParts[pathParts.length - 1]; - if (sectionName.endsWith('.html')) { - sectionName = sectionName.slice(0, -5); - } - if (idToSection['sec-' + sectionName] !== undefined) { - hash = '#sec-' + sectionName; - } - } - location = pathParts.slice(0, -2).join('/') + '/' + hash; - } else { - location = 'multipage/' + hash; - } + if (e.key === 'm') { + // handled by multipage.js `maybeToggleMultipage` + return; } else if (e.key === 'u') { document.documentElement.classList.toggle('show-uc-annotations'); } else if (e.key === 'e') { diff --git a/js/multipage.js b/js/multipage.js index a813ea76..6e86f1fe 100644 --- a/js/multipage.js +++ b/js/multipage.js @@ -7,13 +7,49 @@ for (let [section, ids] of Object.entries(multipageMap)) { } } } -if (location.hash) { - let targetSec = idToSection[location.hash.substring(1)]; - if (targetSec != null) { - let match = location.pathname.match(/([^/]+)\.html?$/); - if ((match != null && match[1] !== targetSec) || location.pathname.endsWith('/multipage/')) { - window.navigating = true; - location = (targetSec === 'index' ? './' : targetSec + '.html') + location.hash; - } +let pathParts = location.pathname.split('/'); +let isMultipage = pathParts[pathParts.length - 2] === 'multipage'; +let activeSec = isMultipage ? pathParts[pathParts.length - 1].replace(/\.html$/, '') : undefined; +let activeSecHash = + activeSec && idToSection['sec-' + activeSec] != null ? '#sec-' + activeSec : undefined; +let storage = typeof localStorage !== 'undefined' ? localStorage : Object.create(null); + +{ + let resolvedHash = location.hash || activeSecHash || ''; + let targetSec = resolvedHash ? idToSection[resolvedHash.substring(1)] : undefined; + let preferMultipage = storage.preferMultipage; + if (isMultipage && preferMultipage === 'false') { + window.navigating = true; + location = pathParts.slice(0, -2).join('/') + '/' + resolvedHash; + } else if ( + isMultipage + ? targetSec != null && (activeSec || 'index') !== targetSec + : preferMultipage === 'true' + ) { + window.navigating = true; + location = 'multipage/' + targetSec + '.html' + location.hash; } } +let maybeToggleMultipage = e => { + if (!(e.target instanceof HTMLElement)) { + return; + } + let target = e.target; + let name = target.nodeName.toLowerCase(); + if (name === 'textarea' || name === 'input' || name === 'select' || target.isContentEditable) { + return; + } + if (e.altKey || e.ctrlKey || e.metaKey || e.key !== 'm') { + return; + } + let hash = location.hash; + if (isMultipage) { + storage.preferMultipage = 'false'; + location = pathParts.slice(0, -2).join('/') + '/' + (hash || activeSecHash || ''); + } else { + storage.preferMultipage = 'true'; + let targetSec = hash ? idToSection[hash.substring(1)] : undefined; + location = 'multipage/' + (targetSec ? targetSec + '.html' : '') + hash; + } +}; +document.addEventListener('keypress', maybeToggleMultipage); diff --git a/src/Spec.ts b/src/Spec.ts index cf675b84..714c21f7 100644 --- a/src/Spec.ts +++ b/src/Spec.ts @@ -687,8 +687,7 @@ export default class Spec { this.doc.body.insertBefore(ele, this.doc.body.firstChild); } - const jsContents = - (await concatJs(sdoJs, tocJs)) + `\n;let usesMultipage = ${!!this.opts.multipage}`; + const jsContents = await concatJs(sdoJs, tocJs); const jsSha = sha(jsContents); await this.buildAssets(jsContents, jsSha); @@ -1010,8 +1009,6 @@ export default class Spec { htmlEle = src.substring(0, src.length - ''.length); } - const head = this.doc.head.cloneNode(true) as HTMLHeadElement; - const containedMap = JSON.stringify(Object.fromEntries(sectionToContainedIds)).replace( /[\\`$]/g, '\\$&', @@ -1035,10 +1032,13 @@ ${await utils.readFile(path.join(__dirname, '../js/multipage.js'))} path.relative(this.opts.outfile!, multipageLocationOnDisk) + '?cache=' + sha(multipageJsContents); - multipageScript.setAttribute('defer', ''); - head.insertBefore(multipageScript, head.querySelector('script')); + // fetch in parallel, but evaluate ASAP in case of redirect + multipageScript.setAttribute('async', ''); + this.doc.head.insertBefore(multipageScript, this.doc.head.querySelector('script')); } + const head = this.doc.head.cloneNode(true) as HTMLHeadElement; + for (const { name, eles } of sections) { this.log(`Generating section ${name}...`); const headClone = head.cloneNode(true) as HTMLHeadElement; diff --git a/test/baselines/generated-reference/assets-inline.html b/test/baselines/generated-reference/assets-inline.html index 15964d9b..6967ac0a 100644 --- a/test/baselines/generated-reference/assets-inline.html +++ b/test/baselines/generated-reference/assets-inline.html @@ -1153,11 +1153,11 @@ function makeLinkToId(id) { let hash = '#' + id; - if (typeof idToSection === 'undefined' || !idToSection[id]) { - return hash; + if (typeof isMultipage !== 'undefined' && isMultipage && idToSection[id]) { + let targetSec = idToSection[id]; + return (targetSec === 'index' ? './' : targetSec + '.html') + hash; } - let targetSec = idToSection[id]; - return (targetSec === 'index' ? './' : targetSec + '.html') + hash; + return hash; } function doShortcut(e) { @@ -1172,23 +1172,9 @@ if (e.altKey || e.ctrlKey || e.metaKey) { return; } - if (e.key === 'm' && usesMultipage) { - let pathParts = location.pathname.split('/'); - let hash = location.hash; - if (pathParts[pathParts.length - 2] === 'multipage') { - if (hash === '') { - let sectionName = pathParts[pathParts.length - 1]; - if (sectionName.endsWith('.html')) { - sectionName = sectionName.slice(0, -5); - } - if (idToSection['sec-' + sectionName] !== undefined) { - hash = '#sec-' + sectionName; - } - } - location = pathParts.slice(0, -2).join('/') + '/' + hash; - } else { - location = 'multipage/' + hash; - } + if (e.key === 'm') { + // handled by multipage.js `maybeToggleMultipage` + return; } else if (e.key === 'u') { document.documentElement.classList.toggle('show-uc-annotations'); } else if (e.key === 'e') { From 9907137b90e8d80d0ba506679302258945465979 Mon Sep 17 00:00:00 2001 From: Richard Gibson Date: Mon, 20 Apr 2026 04:14:28 -0400 Subject: [PATCH 2/5] Support updating "preferMultipage" by clicking ...for keyboard-free interactions (e.g., mobile) Example HTML: * `...` * `` --- js/multipage.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/js/multipage.js b/js/multipage.js index 6e86f1fe..cc3093f9 100644 --- a/js/multipage.js +++ b/js/multipage.js @@ -53,3 +53,27 @@ let maybeToggleMultipage = e => { } }; document.addEventListener('keypress', maybeToggleMultipage); + +let maybeSetMultipagePreference = e => { + if (!(e.target instanceof HTMLElement)) { + return; + } + let target = e.target; + let toggler = target.closest('[data-prefer-multipage]'); + if (!toggler || target.isContentEditable) { + return; + } + switch (target.nodeName.toLowerCase()) { + case 'textarea': + case 'select': + return; + case 'input': { + let isCheckable = target.type === 'checkbox' || target.type === 'radio'; + if (toggler !== target || !isCheckable || !target.checked) { + return; + } + } + } + storage.preferMultipage = toggler.dataset.preferMultipage; +}; +document.addEventListener('click', maybeSetMultipagePreference); From 87490952ec1675e614889e951e45e67b913e805f Mon Sep 17 00:00:00 2001 From: Richard Gibson Date: Tue, 21 Apr 2026 02:11:16 -0400 Subject: [PATCH 3/5] Only update the multipage preference when an explicit toggler is clicked ...identified by attribute "data-multipage-preference" with value "" or "single-page" or "multi-page" --- .eslintrc.json | 1 + css/elements.css | 7 + css/print.css | 4 + js/menu.js | 5 +- js/multipage.js | 123 +++++++++++------- spec/index.html | 6 + .../generated-reference/assets-inline.html | 16 ++- 7 files changed, 109 insertions(+), 53 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 6bc89d13..83b5cae5 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -61,6 +61,7 @@ "multipageMap": "readonly", "isMultipage": "readonly", "idToSection": "readonly", + "toggleMultipage": "readonly", "sdoMap": "readonly", "biblio": "readonly", "debounce": "writable", diff --git a/css/elements.css b/css/elements.css index 8e0c2a52..b213e7b0 100644 --- a/css/elements.css +++ b/css/elements.css @@ -1675,3 +1675,10 @@ li.menu-search-result-term::before { background: var(--figure-background); width: 500px; } + +/* Other dynamic elements */ +:root[data-multipage-preference=''] [data-multipage-preference=''], +:root[data-multipage-preference='single-page'] [data-multipage-preference='single-page'], +:root[data-multipage-preference='multi-page'] [data-multipage-preference='multi-page'] { + font-weight: bold; +} diff --git a/css/print.css b/css/print.css index 993ce73c..a4f6180b 100644 --- a/css/print.css +++ b/css/print.css @@ -1,3 +1,7 @@ +.no-print { + display: none !important; +} + @font-face { font-family: 'Arial Plus'; src: local('Arial'); diff --git a/js/menu.js b/js/menu.js index 245ec59c..4fcbf7d8 100644 --- a/js/menu.js +++ b/js/menu.js @@ -1088,9 +1088,8 @@ function doShortcut(e) { if (e.altKey || e.ctrlKey || e.metaKey) { return; } - if (e.key === 'm') { - // handled by multipage.js `maybeToggleMultipage` - return; + if (e.key === 'm' && typeof toggleMultipage !== 'undefined') { + toggleMultipage(); } else if (e.key === 'u') { document.documentElement.classList.toggle('show-uc-annotations'); } else if (e.key === 'e') { diff --git a/js/multipage.js b/js/multipage.js index cc3093f9..d47fa9c4 100644 --- a/js/multipage.js +++ b/js/multipage.js @@ -1,4 +1,6 @@ 'use strict'; + +// initialize globals let idToSection = Object.create(null); for (let [section, ids] of Object.entries(multipageMap)) { for (let id of ids) { @@ -13,67 +15,94 @@ let activeSec = isMultipage ? pathParts[pathParts.length - 1].replace(/\.html$/, let activeSecHash = activeSec && idToSection['sec-' + activeSec] != null ? '#sec-' + activeSec : undefined; let storage = typeof localStorage !== 'undefined' ? localStorage : Object.create(null); +let toggleMultipage = () => { + let hash = location.hash; + if (isMultipage) { + location = pathParts.slice(0, -2).join('/') + '/' + (hash || activeSecHash || ''); + } else { + let targetSec = hash ? idToSection[hash.substring(1)] : undefined; + location = 'multipage/' + (targetSec ? targetSec + '.html' : '') + hash; + } +}; -{ +// redirect to single-page/multi-page per preference, except from internal links +(() => { + let referrer; + try { + referrer = new URL(document.referrer); + } catch (_err) { + // ignore + } + if (referrer) { + let referrerPathParts = referrer.pathname.split('/'); + let referrerPathPrefixEnd = + referrerPathParts[referrerPathParts.length - 2] === 'multipage' + ? -2 + : referrerPathParts.findLastIndex(part => part !== '') + 1; + let referrerPathPrefix = referrerPathParts.slice(0, referrerPathPrefixEnd).join('/'); + let pathPrefixEnd = isMultipage ? -2 : pathParts.findLastIndex(part => part !== '') + 1; + let pathPrefix = pathParts.slice(0, pathPrefixEnd).join('/'); + if (referrer.host === location.host && referrerPathPrefix === pathPrefix) { + return; + } + } let resolvedHash = location.hash || activeSecHash || ''; let targetSec = resolvedHash ? idToSection[resolvedHash.substring(1)] : undefined; - let preferMultipage = storage.preferMultipage; - if (isMultipage && preferMultipage === 'false') { + let multipagePreference = storage.multipagePreference; + if (isMultipage && multipagePreference === 'single-page') { window.navigating = true; location = pathParts.slice(0, -2).join('/') + '/' + resolvedHash; } else if ( isMultipage ? targetSec != null && (activeSec || 'index') !== targetSec - : preferMultipage === 'true' + : multipagePreference === 'multi-page' ) { window.navigating = true; - location = 'multipage/' + targetSec + '.html' + location.hash; - } -} -let maybeToggleMultipage = e => { - if (!(e.target instanceof HTMLElement)) { - return; - } - let target = e.target; - let name = target.nodeName.toLowerCase(); - if (name === 'textarea' || name === 'input' || name === 'select' || target.isContentEditable) { - return; - } - if (e.altKey || e.ctrlKey || e.metaKey || e.key !== 'm') { - return; - } - let hash = location.hash; - if (isMultipage) { - storage.preferMultipage = 'false'; - location = pathParts.slice(0, -2).join('/') + '/' + (hash || activeSecHash || ''); - } else { - storage.preferMultipage = 'true'; - let targetSec = hash ? idToSection[hash.substring(1)] : undefined; - location = 'multipage/' + (targetSec ? targetSec + '.html' : '') + hash; + location = 'multipage/' + (targetSec ? targetSec + '.html' : '') + location.hash; } -}; -document.addEventListener('keypress', maybeToggleMultipage); +})(); -let maybeSetMultipagePreference = e => { - if (!(e.target instanceof HTMLElement)) { - return; - } - let target = e.target; - let toggler = target.closest('[data-prefer-multipage]'); - if (!toggler || target.isContentEditable) { - return; +// enable preference togglers +document.documentElement.dataset.multipagePreference = storage.multipagePreference || ''; +if (typeof localStorage !== 'undefined') { + let enableToggles = () => { + for (let el of document.querySelectorAll('[disabled][data-multipage-preference]')) { + el.disabled = false; + } + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', enableToggles); + } else { + enableToggles(); } - switch (target.nodeName.toLowerCase()) { - case 'textarea': - case 'select': + document.addEventListener('click', e => { + if (!(e.target instanceof HTMLElement)) { + return; + } + let target = e.target; + let toggler = target.closest('[data-multipage-preference]'); + if (target.isContentEditable || !toggler || toggler === document.documentElement) { return; - case 'input': { - let isCheckable = target.type === 'checkbox' || target.type === 'radio'; - if (toggler !== target || !isCheckable || !target.checked) { + } + switch (target.nodeName.toLowerCase()) { + case 'textarea': + case 'select': return; + case 'input': { + let isCheckable = target.type === 'checkbox' || target.type === 'radio'; + if (toggler !== target || !isCheckable || !target.checked) { + return; + } } } - } - storage.preferMultipage = toggler.dataset.preferMultipage; -}; -document.addEventListener('click', maybeSetMultipagePreference); + let multipagePreference = toggler.dataset.multipagePreference; + if (multipagePreference !== (storage.multipagePreference || '')) { + storage.multipagePreference = multipagePreference; + document.documentElement.dataset.multipagePreference = multipagePreference; + if (multipagePreference === (isMultipage ? 'single-page' : 'multi-page')) { + toggleMultipage(); + } + } + e.preventDefault(); + }); +} diff --git a/spec/index.html b/spec/index.html index 3e9fbf92..74e9ea3e 100644 --- a/spec/index.html +++ b/spec/index.html @@ -91,6 +91,12 @@

Stylesheets and other assets

Ecmarkup requires CSS styles and other assets. By default all assets are inlined into the document. You can override this by setting assets to “none” (for example if you want to manually link to external assets) or “external”. When using “external” the default directory for assets is `assets` in the same directory as the output file, but you can override this with `--assets-dir`.

+ +

Multipage

+

Multi-page builds support a sticky preference for accessing the resulting document as a single page or as multiple pages. It can be set by embedding elements (such as <a> or <button>) with attribute data-multipage-preference set to either an empty string (for the default lack of preference), "single-page", or "multi-page". When such an element is clicked, the corresponding preference is written into localStorage and a navigation is triggered if necessary. From that point forward, loading any page directly or from an external link will respect that preference and redirect as necessary.

+ Links internal to the document are not subject to such redirection, allowing free alternation between single-page and multi-page experiences. +
+

Editorial Conventions

There are a large number of features in Ecmarkup. Detailed documentation can be found in later sections. This section provides a high-level overview of what capabilities are available and when to use them.

diff --git a/test/baselines/generated-reference/assets-inline.html b/test/baselines/generated-reference/assets-inline.html index 6967ac0a..e7761ce8 100644 --- a/test/baselines/generated-reference/assets-inline.html +++ b/test/baselines/generated-reference/assets-inline.html @@ -1172,9 +1172,8 @@ if (e.altKey || e.ctrlKey || e.metaKey) { return; } - if (e.key === 'm') { - // handled by multipage.js `maybeToggleMultipage` - return; + if (e.key === 'm' && typeof toggleMultipage !== 'undefined') { + toggleMultipage(); } else if (e.key === 'u') { document.documentElement.classList.toggle('show-uc-annotations'); } else if (e.key === 'e') { @@ -3276,7 +3275,18 @@ background: var(--figure-background); width: 500px; } + +/* Other dynamic elements */ +:root[data-multipage-preference=''] [data-multipage-preference=''], +:root[data-multipage-preference='single-page'] [data-multipage-preference='single-page'], +:root[data-multipage-preference='multi-page'] [data-multipage-preference='multi-page'] { + font-weight: bold; +}