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
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@
},
"globals": {
"multipageMap": "readonly",
"usesMultipage": "readonly",
"isMultipage": "readonly",
"idToSection": "readonly",
"toggleMultipage": "readonly",
"sdoMap": "readonly",
"biblio": "readonly",
"debounce": "writable",
Expand Down
7 changes: 7 additions & 0 deletions css/elements.css
Original file line number Diff line number Diff line change
Expand Up @@ -1675,3 +1675,10 @@ li.menu-search-result-term::before {
background: var(--figure-background);
width: 500px;
}

/* Other dynamic elements */
:root[data-multipage-preference=''] [data-set-multipage-preference=''],
:root[data-multipage-preference='single-page'] [data-set-multipage-preference='single-page'],
:root[data-multipage-preference='multi-page'] [data-set-multipage-preference='multi-page'] {
font-weight: bold;
}
4 changes: 4 additions & 0 deletions css/print.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.no-print {
display: none !important;
}

@font-face {
font-family: 'Arial Plus';
src: local('Arial');
Expand Down
71 changes: 28 additions & 43 deletions js/menu.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
'use strict';

// Duplicates multipage.js for fault tolerance.
function parseSpecPath(url) {
let pathParts = url.pathname.split('/');
let partCount = pathParts.length;
let isMultipage = pathParts[partCount - 2] === 'multipage';
let section = isMultipage ? pathParts[partCount - 1].replace(/\.html$/, '') : undefined;
let pathPrefixEnd = isMultipage ? -2 : pathParts.findLastIndex(part => part !== '') + 1;
let pathPrefix = pathParts.slice(0, pathPrefixEnd).join('/') + '/';
return { pathParts, pathPrefix, isMultipage, section };
}

let { pathPrefix, isMultipage } = parseSpecPath(location);

function Search(menu) {
this.menu = menu;
this.$search = document.getElementById('menu-search');
Expand Down Expand Up @@ -538,27 +552,13 @@ Menu.prototype.pinListClick = function (event) {
};

// All per-document pins live in one storage entry, as an object of
// { [documentKey]: { pins: string[], lastUsed: <ms epoch> } } (see #702).
// { [pathPrefix]: { pins: string[], lastUsed: <ms epoch> } } (see #702).
const PIN_STORAGE_KEY = 'pinEntries';
// Forget a document's pins if it hasn't been visited in this long, so that the
// unique-per-PR paths used by preview deployments don't grow localStorage without
// bound.
const PIN_TTL_MS = 180 * 24 * 60 * 60 * 1000; // 180 days

// Return the key associated with the current document in the object persisted at
// PIN_STORAGE_KEY (used to prevent pins in one spec/preview from clobbering those in
// other documents served from the same origin).
Menu.prototype.getDocumentKey = function () {
// Directory of the current document (drop any filename such as index.html / foo.html).
let dir = location.pathname.replace(/[^/]*$/, '');
// Multipage pages live at <root>/multipage/<page>.html; fold them onto the
// document root so every page of one spec shares a single set of pins.
if (usesMultipage && dir.endsWith('/multipage/')) {
dir = dir.slice(0, -'multipage/'.length);
}
return dir;
};

// Parse the raw stored value into a store of { path: { pins, lastUsed } },
// migrating the legacy global `pinEntries` array if present.
Menu.prototype.parsePinEntries = function (raw) {
Expand All @@ -580,7 +580,7 @@ Menu.prototype.parsePinEntries = function (raw) {
for (let spec of ['ecma262', 'ecma402', 'ecma404', 'ecma426']) {
migrated['/' + spec + '/'] = { pins: parsed, lastUsed };
}
migrated[this.getDocumentKey()] = { pins: parsed, lastUsed };
migrated[pathPrefix] = { pins: parsed, lastUsed };
return migrated;
}
return parsed && typeof parsed === 'object' ? parsed : {};
Expand All @@ -589,10 +589,10 @@ Menu.prototype.parsePinEntries = function (raw) {
// Drop documents not visited within PIN_TTL_MS. Mutates and returns `store`.
Menu.prototype.prunePinStore = function (store) {
let now = Date.now();
for (let path of Object.keys(store)) {
let entry = store[path];
for (let pathPrefix of Object.keys(store)) {
let entry = store[pathPrefix];
if (!entry || typeof entry.lastUsed !== 'number' || now - entry.lastUsed > PIN_TTL_MS) {
delete store[path];
delete store[pathPrefix];
}
}
return store;
Expand All @@ -609,7 +609,7 @@ Menu.prototype.persistPinEntries = function () {
}

let store = this.prunePinStore(this.parsePinEntries(raw));
let key = this.getDocumentKey();
let key = pathPrefix;
let ids = Object.keys(this._pinnedIds);
if (ids.length === 0) {
// Don't leave an empty entry lingering once the last pin is removed.
Expand All @@ -635,7 +635,7 @@ Menu.prototype.loadPinEntries = function () {
}

let store = this.parsePinEntries(raw);
let entry = store[this.getDocumentKey()] || { pins: [] };
let entry = store[pathPrefix] || { pins: [] };
// Update in-memory state (including dropping missing ids) and the DOM.
for (let i = 0; i < entry.pins.length; i++) {
this.addPinEntry(entry.pins[i]);
Expand Down Expand Up @@ -1149,12 +1149,12 @@ function sortByClauseNumber(clause1, clause2) {
}

function makeLinkToId(id) {
let hash = '#' + id;
if (typeof idToSection === 'undefined' || !idToSection[id]) {
return hash;
let path = '';
if (isMultipage) {
let targetSec = typeof idToSection !== 'undefined' && idToSection[id];
path = targetSec === 'index' ? './' : targetSec ? targetSec + '.html' : '';
}
let targetSec = idToSection[id];
return (targetSec === 'index' ? './' : targetSec + '.html') + hash;
return path + '#' + id;
}

function doShortcut(e) {
Expand All @@ -1169,23 +1169,8 @@ 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' && typeof toggleMultipage !== 'undefined') {
toggleMultipage();
} else if (e.key === 'u') {
document.documentElement.classList.toggle('show-uc-annotations');
} else if (e.key === 'e') {
Expand Down
169 changes: 162 additions & 7 deletions js/multipage.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
'use strict';

// Duplicates menu.js for fault tolerance.
function parseSpecPath(url) {
let pathParts = url.pathname.split('/');
let partCount = pathParts.length;
let isMultipage = pathParts[partCount - 2] === 'multipage';
let section = isMultipage ? pathParts[partCount - 1].replace(/\.html$/, '') : undefined;
let pathPrefixEnd = isMultipage ? -2 : pathParts.findLastIndex(part => part !== '') + 1;
let pathPrefix = pathParts.slice(0, pathPrefixEnd).join('/') + '/';
return { pathParts, pathPrefix, isMultipage, section };
}

let idToSection = Object.create(null);
for (let [section, ids] of Object.entries(multipageMap)) {
for (let id of ids) {
Expand All @@ -7,13 +19,156 @@ 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;

// Multipage preferences apply separately to each document, but are stored in a
// single { [pathPrefix]: { preference: boolean, lastUsed: <ms epoch> } } object.
const MULTIPAGE_PREFERENCE_STORAGE_KEY = 'multipagePreference';
// Forget a document's preference if it hasn't been visited in this long, so that
// the unique-per-PR paths used by preview deployments don't grow localStorage
// without bound.
const MULTIPAGE_PREFERENCE_TTL_MS = 180 * 24 * 60 * 60 * 1000; // 180 days
// For styling, the document element exposes the current preference and buttons
// for updating it expose their associated value (cf. css/elements.css).
const MULTIPAGE_PREFERENCE_ATTR = 'data-multipage-preference';
const SET_MULTIPAGE_PREFERENCE_ATTR = 'data-set-multipage-preference';

function parseMultipagePreferences(storage) {
try {
let raw = storage[MULTIPAGE_PREFERENCE_STORAGE_KEY];
let preferencesByPathPrefix = JSON.parse(raw);
let now = Date.now();
for (let pathPrefix of Object.keys(preferencesByPathPrefix)) {
let entry = preferencesByPathPrefix[pathPrefix];
if (
!entry ||
typeof entry.lastUsed !== 'number' ||
now - entry.lastUsed > MULTIPAGE_PREFERENCE_TTL_MS
) {
delete preferencesByPathPrefix[pathPrefix];
}
}
return preferencesByPathPrefix;
} catch (e) {
return undefined;
}
}

function getMultipagePreference(storage) {
let preferencesByPathPrefix = parseMultipagePreferences(storage) || {};

// For PR previews, default to parent prefixes.
let { pathPrefix } = parseSpecPath(location);
while (pathPrefix) {
let entry = preferencesByPathPrefix[pathPrefix];
if (entry) {
entry.lastUsed = Date.now();
return entry.preference;
}
pathPrefix = pathPrefix.replace(/[^/]*\/$/, '');
}

return '';
}

function setMultipagePreference(storage, preference, skipNavigation) {
let preferencesByPathPrefix = parseMultipagePreferences(storage) || {};
let oldPreference = getMultipagePreference(storage);

let { pathPrefix, isMultipage } = parseSpecPath(location);
preferencesByPathPrefix[pathPrefix] = { preference, lastUsed: Date.now() };
try {
storage[MULTIPAGE_PREFERENCE_STORAGE_KEY] = JSON.stringify(preferencesByPathPrefix);
} catch (e) {
// storage may be full or unavailable.
}

if (skipNavigation || preference === oldPreference) return;
if (preference === (isMultipage ? 'single-page' : 'multi-page')) {
toggleMultipage();
}
}

function toggleMultipage() {
let { pathPrefix, isMultipage, section: activeSec } = parseSpecPath(location);
let activeSecHash =
activeSec && idToSection['sec-' + activeSec] != null ? '#sec-' + activeSec : undefined;
let hash = location.hash;

if (isMultipage) {
location = pathPrefix + (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
(() => {
let { pathPrefix, isMultipage, section: activeSec } = parseSpecPath(location);
let activeSecHash =
activeSec && idToSection['sec-' + activeSec] != null ? '#sec-' + activeSec : undefined;
let hash = location.hash;
let resolvedHash = hash || activeSecHash || '';
let targetSec = resolvedHash ? idToSection[resolvedHash.substring(1)] : undefined;

// ...except from internal links
let referrer;
try {
referrer = new URL(document.referrer);
} catch (_err) {
// ignore
}
if (referrer && referrer.host === location.host) {
if (parseSpecPath(referrer).pathPrefix === pathPrefix) return;
}

let storage = window.localStorage || Object.create(null);
let multipagePreference = getMultipagePreference(storage);
if (isMultipage && multipagePreference === 'single-page') {
window.navigating = true;
location = pathPrefix + resolvedHash;
} else if (
isMultipage
? targetSec != null && (activeSec || 'index') !== targetSec
: multipagePreference === 'multi-page'
) {
window.navigating = true;
location = 'multipage/' + (targetSec ? targetSec + '.html' : '') + location.hash;
}
})();

// enable preference togglers
document.documentElement.setAttribute(
MULTIPAGE_PREFERENCE_ATTR,
getMultipagePreference(window.localStorage),
);
if (window.localStorage) {
let storage = window.localStorage;
let enableToggles = container => {
container.addEventListener('click', e => {
let target = e.target.closest?.(`[${SET_MULTIPAGE_PREFERENCE_ATTR}]`);
let preference = target?.getAttribute(SET_MULTIPAGE_PREFERENCE_ATTR);
if (typeof preference !== 'string') {
return;
}
document.documentElement.setAttribute(MULTIPAGE_PREFERENCE_ATTR, preference);
setMultipagePreference(storage, preference);
});
// Work around a bug where contents are sometimes empty.
setTimeout(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super sketchy but okay.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely agreed; I was shocked to see an observably empty shortcuts-help div.

for (let el of container.querySelectorAll(`[${SET_MULTIPAGE_PREFERENCE_ATTR}]`)) {
el.disabled = false;
}
}, 0);
};

let shortcuts = document.getElementById('shortcuts-help');
if (shortcuts) {
enableToggles(shortcuts);
} else {
document.addEventListener('DOMContentLoaded', () => {
shortcuts = document.getElementById('shortcuts-help');
if (shortcuts) enableToggles(shortcuts);
});
}
}
6 changes: 6 additions & 0 deletions spec/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ <h1>Stylesheets and other assets</h1>
<p>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`.</p>
</emu-clause>

<emu-clause id="multipage">
<h1>Multipage</h1>
<p>Multi-page builds support a sticky preference for accessing the resulting document as a single page or as multiple pages, which is either an empty string (for the default lack of preference), “single-page”, or “multi-page”. It can be read from the <code>data-multipage-preference</code> attribute of the document element or by calling `getMultipagePreference(window.localStorage)`, and set by calling `setMultipagePreference(window.localStorage, preference)` (which also triggers a navigation as necessary unless provided with a truthy third argument). From that point forward, loading any page directly or from an external link will respect that preference and redirect as necessary.</p>
<emu-note>Links <em>internal</em> to the document are not subject to such redirection, allowing free alternation between single-page and multi-page experiences.</emu-note>
</emu-clause>

<emu-clause id="editorial-conventions">
<h1>Editorial Conventions</h1>
<p>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.</p>
Expand Down
Loading