openvibers/openvibe.shared Commits
4.102.0: sidebar rows with a second line; boost lets parsed pages go (#264)
Changes
@@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.102.0 — 2026-11-02++++- **A sidebar row with two lines** (`kit.sidebar` items): `sub` is a quiet second line under the name, a++ conversation's last message or where a search matched, with the mark beside both lines. `attrs` gives a row `data-*`++ attributes so a page's script finds it again (Chat's support inbox, `data-conv`); any other name is dropped. A++ one-line row's markup is unchanged.++- **Boost lets each parsed page go.** After a swap, boost empties the page it parsed. Chrome held that page's own++ `<video>` (the swap shows a copy) as a pending activity, and with it the whole parsed page: one document and every++ node more on each visit. On openvibe.cam, `/` ↔ `/app` grew by 3 documents and 1,729 nodes in three laps and failed++ the release check's navigation test. `boost-chrome.test.js` visits a page with a video five times in place and checks++ the document count stays put.++## 4.101.0 — 2026-11-02- **A puzzle glyph** (`puzzle`, with a solid drawing): a jigsaw piece, knobs out on top and right, notches in, for a@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.101.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.102.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.101.0",++ "version": "4.102.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -132,10 +132,11 @@const Fx = await fx(mainEl);const h = Fx ? Fx.start(mainEl, { prefix }) : null;emit('before', { url: target.href });++ let doc = null;try {const page = await fetchPage(target.href);if (inflight !== mine) return; // a newer move won-- const doc = new root.DOMParser().parseFromString(page.html, 'text/html');++ doc = new root.DOMParser().parseFromString(page.html, 'text/html');const next = doc.querySelector(mainSel);// A different release, other external scripts, or no <main>: this page cannot be swapped safely.if (!next || marker(doc) !== here.marker || externalScripts(doc) !== here.scripts) throw new Error('not swappable');@@@ -172,6 +173,12 @@s.fallbacks++;if (h) h.done();root.location.assign(target.href); // any doubt: a normal page load++ } finally {++ // Let the parsed page go. Its own copy of a <video> or <audio> (the swap imported a copy) is held by++ // Chrome as a pending activity (RemotePlayback), and with it the whole parsed page: one more document++ // and every node of the page on each visit (openvibe.cam/app, the release check's navigation test,++ // 2026-11-02). Emptying the parsed page releases it.++ if (doc && doc.documentElement) doc.documentElement.replaceChildren();}}@@@ -466,6 +466,9 @@ kit.withSidebar({ sidebar: kit.sidebar({dot: `true`, `'busy'`, `'needs'` or `'bad'`);- then the sections, which scroll on their own;- and an optional foot.++- **A row with two lines (4.102.0):** `sub` is a quiet second line under the name (a conversation's last message, where++ a search matched), and the mark sits beside both lines. `attrs` gives the row `data-*` attributes, so a page's script++ can find a row again (`{ 'data-conv': 12 }`); any other name is dropped.- **On a computer:** a 272 px column beside the page (`--ovk-sidebar-w`), sticky under the navbar.- **On a phone (760 px and under):** a drawer over everything.- `kit.sidebarButton` opens it.@@@ -360,6 +360,12 @@ div.ovk-set-row { cursor: default; }.ovk-sidebar-item.is-here { background: var(--ovk-panel-2); font-weight: 600; }.ovk-sidebar-t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ovk-sidebar-m { flex: none; color: var(--ovk-muted); font-size: 12px; }++/* A row with a quiet second line (4.102.0): a conversation's last message under its name, the mark beside both lines. */++.ovk-sidebar-item.has-sub { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 9px; row-gap: 1px; padding-block: 6px; }++.ovk-sidebar-item.has-sub.has-mark { grid-template-columns: auto minmax(0, 1fr) auto auto; }++.ovk-sidebar-item.has-sub.has-mark > :first-child { grid-row: 1 / span 2; }++.ovk-sidebar-s { grid-column: 1 / -1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font-size: 12.5px; font-weight: 400; }++.ovk-sidebar-item.has-sub.has-mark .ovk-sidebar-s { grid-column: 2 / -1; }.ovk-sidebar-empty { margin: 8px 10px; color: var(--ovk-muted); font-size: 13.5px; }.ovk-sidebar-foot { padding-top: 8px; border-top: 1px solid var(--ovk-line); }@media (max-width: 760px) {@@@ -211,8 +211,11 @@const link = (x) => `<a class="ovk-sidebar-link${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`+ `${x.iconHtml != null ? x.iconHtml : icon(x.icon || 'chevron')}<span>${esc(x.label)}</span>`+ `${Number(x.count) > 0 ? `<b class="ovk-sidebar-count">${Number(x.count) > 99 ? '99+' : Number(x.count)}</b>` : dotHtml(x.dot)}</a>`;-- const item = (x) => `<a class="ovk-sidebar-item${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`-- + `${x.mark || ''}<span class="ovk-sidebar-t">${esc(x.label)}</span>${x.meta ? `<span class="ovk-sidebar-m">${esc(x.meta)}</span>` : ''}${dotHtml(x.dot)}</a>`;++ // A row's data-* attributes (a site finds its rows by them: a conversation's id), nothing else.++ const data = (a) => Object.entries(a || {}).filter(([k, v]) => /^data-[a-z0-9-]+$/.test(k) && v != null && v !== false).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');++ const item = (x) => `<a class="ovk-sidebar-item${x.sub ? ' has-sub' : ''}${x.sub && x.mark ? ' has-mark' : ''}${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}${data(x.attrs)}>`++ + `${x.mark || ''}<span class="ovk-sidebar-t">${esc(x.label)}</span>${x.meta ? `<span class="ovk-sidebar-m">${esc(x.meta)}</span>` : ''}${dotHtml(x.dot)}`++ + `${x.sub ? `<span class="ovk-sidebar-s">${esc(x.sub)}</span>` : ''}</a>`;const secs = (sections || []).map((sec) => {const items = sec.items || [];return `<section class="ovk-sidebar-sec">${sec.title ? `<h2 class="ovk-sidebar-h">${esc(sec.title)}</h2>` : ''}${items.map(item).join('')}`@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.101.0",++ "version": "4.102.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -21,11 +21,12 @@ const page = ({ title, marker = 'site@r1', css = [], scripts = ['/js/site.js'],<link rel="icon" href="data:,">${css.map((c) => `<link rel="stylesheet" href="${c}">`).join('')}${scripts.map((s) => `<script src="${s}"></script>`).join('')}<script src="/boost.js" data-main="#main" defer></script></head><body class="page-${title.replace(/\W+/g, '-').toLowerCase()}"><header>header</header><main id="main" data-page="${title}">${body}</main><footer>footer</footer></body></html>`;--const LINKS = '<a id="to-a" href="/a">A</a> <a id="to-b" href="/b">B</a> <a id="to-x" href="/x">X</a> <a id="to-y" href="/y">Y</a> <a id="to-api" href="/api/thing">api</a> <a id="to-new" href="/a" target="_blank">new</a>';++const LINKS = '<a id="to-v" href="/v">V</a> <a id="to-home" href="/">Home</a> <a id="to-a" href="/a">A</a> <a id="to-b" href="/b">B</a> <a id="to-x" href="/x">X</a> <a id="to-y" href="/y">Y</a> <a id="to-api" href="/api/thing">api</a> <a id="to-new" href="/a" target="_blank">new</a>';const PAGES = {'/': page({ title: 'Home', canonical: '/', body: `<h1>Home</h1><a id="to-part" href="#part">part</a><div style="height:3000px">${LINKS}</div><p id="bottom">bottom</p><div style="height:900px"></div><p id="part">part</p><div style="height:900px"></div>` }),'/a': page({ title: 'Page A', canonical: '/a', css: ['/css/a.css'], body: `<h1>A</h1>${LINKS}<script>window.inlineRuns = (window.inlineRuns || 0) + 1;</script><script type="application/ld+json">{"@type":"WebPage","name":"A"}</script>` }),'/b': page({ title: 'Page B', canonical: '/b', body: `<h1>B</h1>${LINKS}` }),++ '/v': page({ title: 'Page V', canonical: '/v', body: `<h1>V</h1><video muted playsinline aria-label="preview"></video><canvas hidden></canvas>${LINKS}` }),'/x': page({ title: 'Page X', canonical: '/x', marker: 'site@r2', body: `<h1>X</h1>${LINKS}` }),'/y': page({ title: 'Page Y', canonical: '/y', scripts: ['/js/site.js', '/js/other.js'], body: `<h1>Y</h1>${LINKS}` }),'/api/thing': page({ title: 'API', canonical: '/api/thing', body: '<h1>api</h1>' }),@@@ -97,6 +98,18 @@ const server = http.createServer((req, res) => {assert.ok(Math.abs(v.y - 40) < 5, `and returns to where the person was (${v.y})`);await p.evaluate('window.alive = 1; 1');++ // A page with a <video> visited again and again in place lets each parsed page go (4.102.0). Chrome held the parsed++ // page's own <video> as a pending activity, and with it the whole page: a document more on every visit.++ const docs = async () => { await p.send('HeapProfiler.collectGarbage'); await p.send('HeapProfiler.collectGarbage'); return (await p.metrics()).Documents; };++ const swap = (id) => p.evaluate(`new Promise((done) => { document.addEventListener('ov:boost:load', () => done(document.getElementById('main').dataset.page), { once: true }); document.getElementById('${id}').click(); })`);++ assert.strictEqual(await swap('to-v'), 'Page V');++ assert.strictEqual(await swap('to-home'), 'Home');++ const docs0 = await docs();++ for (let i = 0; i < 4; i++) { await swap('to-v'); await swap('to-home'); }++ const docs1 = await docs();++ assert.strictEqual(await p.evaluate('window.alive'), 1, 'all in place');++ assert.ok(docs1 <= docs0, `the parsed pages were let go (documents ${docs0} → ${docs1})`);++// Not swappable: an excluded path and a new tab are left to the browser; another release or other scripts load normally.// Did boost claim the click? A window listener runs after boost's document listener: it records the answer and// then cancels the browser's own navigation so the test stays on this page.@@@ -278,6 +278,15 @@ const kit = require('../kit');assert.ok(html.includes('<h2 class="ovk-sidebar-h">Today</h2>') && html.includes('Fix <it>') && html.includes('<p class="ovk-sidebar-empty">Nothing yet</p>'), 'sections, and an empty one says so');assert.ok(html.includes('data-ovk-sidebar-close') && html.includes('<label class="ovk-sr" for="side-q">'), 'a close button and a labelled search');assert.match(kit.sidebarButton({ id: 'side' }), /data-ovk-sidebar-open aria-controls="side" aria-expanded="false"/);++ // 4.102.0: a second line, and data attributes a page's script finds the row by (only data-*).++ const two = kit.sidebar({ sections: [{ items: [++ { href: '/inbox/1?c=7', label: 'Ana', meta: '2m', dot: 'needs', sub: 'Where is <my> order?', mark: '<span class="m">A</span>', attrs: { 'data-conv': 7, onclick: 'x()', 'data-x"y': 1, 'data-off': false } },++ { href: '/inbox/1?c=8', label: 'Bo', sub: 'Thanks' },++ ] }] });++ assert.ok(two.includes('<a class="ovk-sidebar-item has-sub has-mark" href="/inbox/1?c=7" data-conv="7">'), 'a mark beside two lines, the row findable by data-conv');++ assert.ok(two.includes('<span class="ovk-sidebar-s">Where is <my> order?</span></a>') && !two.includes('onclick') && !two.includes('data-x') && !two.includes('data-off'), 'the line escaped, only data-* attributes kept');++ assert.ok(two.includes('<a class="ovk-sidebar-item has-sub" href="/inbox/1?c=8">'), 'no mark: no mark column');++ assert.ok(!html.includes('has-sub') && !html.includes('class="ovk-sidebar-s"'), 'a one-line row is unchanged');assert.match(kit.withSidebar({ sidebar: html, body: '<p>x</p>' }), /^<div class="ovk-with-sidebar"><aside[\s\S]*<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden><\/div><div class="ovk-with-sidebar-main"><p>x<\/p><\/div><\/div>$/);const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'kit.css'), 'utf8');assert.ok(/@media \(max-width: 760px\) \{\s*\.ovk-with-sidebar \{ grid-template-columns: minmax\(0, 1fr\); \}\s*\.ovk-sidebar \{ position: fixed; z-index: 10060;/.test(css), 'a drawer over everything on a phone');
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,18 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.102.0 — 2026-11-02
+
+- **A sidebar row with two lines** (`kit.sidebar` items): `sub` is a quiet second line under the name, a
+ conversation's last message or where a search matched, with the mark beside both lines. `attrs` gives a row `data-*`
+ attributes so a page's script finds it again (Chat's support inbox, `data-conv`); any other name is dropped. A
+ one-line row's markup is unchanged.
+- **Boost lets each parsed page go.** After a swap, boost empties the page it parsed. Chrome held that page's own
+ `<video>` (the swap shows a copy) as a pending activity, and with it the whole parsed page: one document and every
+ node more on each visit. On openvibe.cam, `/` ↔ `/app` grew by 3 documents and 1,729 nodes in three laps and failed
+ the release check's navigation test. `boost-chrome.test.js` visits a page with a video five times in place and checks
+ the document count stays put.
+
## 4.101.0 — 2026-11-02
## 4.101.0 — 2026-11-02
- **A puzzle glyph** (`puzzle`, with a solid drawing): a jigsaw piece, knobs out on top and right, notches in, for a
- **A puzzle glyph** (`puzzle`, with a solid drawing): a jigsaw piece, knobs out on top and right, notches in, for a
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.101.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.102.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.101.0",
+ "version": "4.102.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -132,10 +132,11 @@
@@ -132,10 +132,11 @@
const Fx = await fx(mainEl);
const Fx = await fx(mainEl);
const h = Fx ? Fx.start(mainEl, { prefix }) : null;
const h = Fx ? Fx.start(mainEl, { prefix }) : null;
emit('before', { url: target.href });
emit('before', { url: target.href });
+ let doc = null;
try {
try {
const page = await fetchPage(target.href);
const page = await fetchPage(target.href);
if (inflight !== mine) return; // a newer move won
if (inflight !== mine) return; // a newer move won
- const doc = new root.DOMParser().parseFromString(page.html, 'text/html');
+ doc = new root.DOMParser().parseFromString(page.html, 'text/html');
const next = doc.querySelector(mainSel);
const next = doc.querySelector(mainSel);
// A different release, other external scripts, or no <main>: this page cannot be swapped safely.
// A different release, other external scripts, or no <main>: this page cannot be swapped safely.
if (!next || marker(doc) !== here.marker || externalScripts(doc) !== here.scripts) throw new Error('not swappable');
if (!next || marker(doc) !== here.marker || externalScripts(doc) !== here.scripts) throw new Error('not swappable');
@@ -172,6 +173,12 @@
@@ -172,6 +173,12 @@
s.fallbacks++;
s.fallbacks++;
if (h) h.done();
if (h) h.done();
root.location.assign(target.href); // any doubt: a normal page load
root.location.assign(target.href); // any doubt: a normal page load
+ } finally {
+ // Let the parsed page go. Its own copy of a <video> or <audio> (the swap imported a copy) is held by
+ // Chrome as a pending activity (RemotePlayback), and with it the whole parsed page: one more document
+ // and every node of the page on each visit (openvibe.cam/app, the release check's navigation test,
+ // 2026-11-02). Emptying the parsed page releases it.
+ if (doc && doc.documentElement) doc.documentElement.replaceChildren();
}
}
}
}
@@ -466,6 +466,9 @@ kit.withSidebar({ sidebar: kit.sidebar({
@@ -466,6 +466,9 @@ kit.withSidebar({ sidebar: kit.sidebar({
dot: `true`, `'busy'`, `'needs'` or `'bad'`);
dot: `true`, `'busy'`, `'needs'` or `'bad'`);
- then the sections, which scroll on their own;
- then the sections, which scroll on their own;
- and an optional foot.
- and an optional foot.
+- **A row with two lines (4.102.0):** `sub` is a quiet second line under the name (a conversation's last message, where
+ a search matched), and the mark sits beside both lines. `attrs` gives the row `data-*` attributes, so a page's script
+ can find a row again (`{ 'data-conv': 12 }`); any other name is dropped.
- **On a computer:** a 272 px column beside the page (`--ovk-sidebar-w`), sticky under the navbar.
- **On a computer:** a 272 px column beside the page (`--ovk-sidebar-w`), sticky under the navbar.
- **On a phone (760 px and under):** a drawer over everything.
- **On a phone (760 px and under):** a drawer over everything.
- `kit.sidebarButton` opens it.
- `kit.sidebarButton` opens it.
@@ -360,6 +360,12 @@ div.ovk-set-row { cursor: default; }
@@ -360,6 +360,12 @@ div.ovk-set-row { cursor: default; }
.ovk-sidebar-item.is-here { background: var(--ovk-panel-2); font-weight: 600; }
.ovk-sidebar-item.is-here { background: var(--ovk-panel-2); font-weight: 600; }
.ovk-sidebar-t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovk-sidebar-t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovk-sidebar-m { flex: none; color: var(--ovk-muted); font-size: 12px; }
.ovk-sidebar-m { flex: none; color: var(--ovk-muted); font-size: 12px; }
+/* A row with a quiet second line (4.102.0): a conversation's last message under its name, the mark beside both lines. */
+.ovk-sidebar-item.has-sub { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 9px; row-gap: 1px; padding-block: 6px; }
+.ovk-sidebar-item.has-sub.has-mark { grid-template-columns: auto minmax(0, 1fr) auto auto; }
+.ovk-sidebar-item.has-sub.has-mark > :first-child { grid-row: 1 / span 2; }
+.ovk-sidebar-s { grid-column: 1 / -1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font-size: 12.5px; font-weight: 400; }
+.ovk-sidebar-item.has-sub.has-mark .ovk-sidebar-s { grid-column: 2 / -1; }
.ovk-sidebar-empty { margin: 8px 10px; color: var(--ovk-muted); font-size: 13.5px; }
.ovk-sidebar-empty { margin: 8px 10px; color: var(--ovk-muted); font-size: 13.5px; }
.ovk-sidebar-foot { padding-top: 8px; border-top: 1px solid var(--ovk-line); }
.ovk-sidebar-foot { padding-top: 8px; border-top: 1px solid var(--ovk-line); }
@media (max-width: 760px) {
@media (max-width: 760px) {
@@ -211,8 +211,11 @@
@@ -211,8 +211,11 @@
const link = (x) => `<a class="ovk-sidebar-link${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`
const link = (x) => `<a class="ovk-sidebar-link${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`
+ `${x.iconHtml != null ? x.iconHtml : icon(x.icon || 'chevron')}<span>${esc(x.label)}</span>`
+ `${x.iconHtml != null ? x.iconHtml : icon(x.icon || 'chevron')}<span>${esc(x.label)}</span>`
+ `${Number(x.count) > 0 ? `<b class="ovk-sidebar-count">${Number(x.count) > 99 ? '99+' : Number(x.count)}</b>` : dotHtml(x.dot)}</a>`;
+ `${Number(x.count) > 0 ? `<b class="ovk-sidebar-count">${Number(x.count) > 99 ? '99+' : Number(x.count)}</b>` : dotHtml(x.dot)}</a>`;
- const item = (x) => `<a class="ovk-sidebar-item${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`
- + `${x.mark || ''}<span class="ovk-sidebar-t">${esc(x.label)}</span>${x.meta ? `<span class="ovk-sidebar-m">${esc(x.meta)}</span>` : ''}${dotHtml(x.dot)}</a>`;
+ // A row's data-* attributes (a site finds its rows by them: a conversation's id), nothing else.
+ const data = (a) => Object.entries(a || {}).filter(([k, v]) => /^data-[a-z0-9-]+$/.test(k) && v != null && v !== false).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
+ const item = (x) => `<a class="ovk-sidebar-item${x.sub ? ' has-sub' : ''}${x.sub && x.mark ? ' has-mark' : ''}${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}${data(x.attrs)}>`
+ + `${x.mark || ''}<span class="ovk-sidebar-t">${esc(x.label)}</span>${x.meta ? `<span class="ovk-sidebar-m">${esc(x.meta)}</span>` : ''}${dotHtml(x.dot)}`
+ + `${x.sub ? `<span class="ovk-sidebar-s">${esc(x.sub)}</span>` : ''}</a>`;
const secs = (sections || []).map((sec) => {
const secs = (sections || []).map((sec) => {
const items = sec.items || [];
const items = sec.items || [];
return `<section class="ovk-sidebar-sec">${sec.title ? `<h2 class="ovk-sidebar-h">${esc(sec.title)}</h2>` : ''}${items.map(item).join('')}`
return `<section class="ovk-sidebar-sec">${sec.title ? `<h2 class="ovk-sidebar-h">${esc(sec.title)}</h2>` : ''}${items.map(item).join('')}`
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.101.0",
+ "version": "4.102.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -21,11 +21,12 @@ const page = ({ title, marker = 'site@r1', css = [], scripts = ['/js/site.js'],
@@ -21,11 +21,12 @@ const page = ({ title, marker = 'site@r1', css = [], scripts = ['/js/site.js'],
<link rel="icon" href="data:,">${css.map((c) => `<link rel="stylesheet" href="${c}">`).join('')}
<link rel="icon" href="data:,">${css.map((c) => `<link rel="stylesheet" href="${c}">`).join('')}
${scripts.map((s) => `<script src="${s}"></script>`).join('')}<script src="/boost.js" data-main="#main" defer></script></head>
${scripts.map((s) => `<script src="${s}"></script>`).join('')}<script src="/boost.js" data-main="#main" defer></script></head>
<body class="page-${title.replace(/\W+/g, '-').toLowerCase()}"><header>header</header><main id="main" data-page="${title}">${body}</main><footer>footer</footer></body></html>`;
<body class="page-${title.replace(/\W+/g, '-').toLowerCase()}"><header>header</header><main id="main" data-page="${title}">${body}</main><footer>footer</footer></body></html>`;
-const LINKS = '<a id="to-a" href="/a">A</a> <a id="to-b" href="/b">B</a> <a id="to-x" href="/x">X</a> <a id="to-y" href="/y">Y</a> <a id="to-api" href="/api/thing">api</a> <a id="to-new" href="/a" target="_blank">new</a>';
+const LINKS = '<a id="to-v" href="/v">V</a> <a id="to-home" href="/">Home</a> <a id="to-a" href="/a">A</a> <a id="to-b" href="/b">B</a> <a id="to-x" href="/x">X</a> <a id="to-y" href="/y">Y</a> <a id="to-api" href="/api/thing">api</a> <a id="to-new" href="/a" target="_blank">new</a>';
const PAGES = {
const PAGES = {
'/': page({ title: 'Home', canonical: '/', body: `<h1>Home</h1><a id="to-part" href="#part">part</a><div style="height:3000px">${LINKS}</div><p id="bottom">bottom</p><div style="height:900px"></div><p id="part">part</p><div style="height:900px"></div>` }),
'/': page({ title: 'Home', canonical: '/', body: `<h1>Home</h1><a id="to-part" href="#part">part</a><div style="height:3000px">${LINKS}</div><p id="bottom">bottom</p><div style="height:900px"></div><p id="part">part</p><div style="height:900px"></div>` }),
'/a': page({ title: 'Page A', canonical: '/a', css: ['/css/a.css'], body: `<h1>A</h1>${LINKS}<script>window.inlineRuns = (window.inlineRuns || 0) + 1;</script><script type="application/ld+json">{"@type":"WebPage","name":"A"}</script>` }),
'/a': page({ title: 'Page A', canonical: '/a', css: ['/css/a.css'], body: `<h1>A</h1>${LINKS}<script>window.inlineRuns = (window.inlineRuns || 0) + 1;</script><script type="application/ld+json">{"@type":"WebPage","name":"A"}</script>` }),
'/b': page({ title: 'Page B', canonical: '/b', body: `<h1>B</h1>${LINKS}` }),
'/b': page({ title: 'Page B', canonical: '/b', body: `<h1>B</h1>${LINKS}` }),
+ '/v': page({ title: 'Page V', canonical: '/v', body: `<h1>V</h1><video muted playsinline aria-label="preview"></video><canvas hidden></canvas>${LINKS}` }),
'/x': page({ title: 'Page X', canonical: '/x', marker: 'site@r2', body: `<h1>X</h1>${LINKS}` }),
'/x': page({ title: 'Page X', canonical: '/x', marker: 'site@r2', body: `<h1>X</h1>${LINKS}` }),
'/y': page({ title: 'Page Y', canonical: '/y', scripts: ['/js/site.js', '/js/other.js'], body: `<h1>Y</h1>${LINKS}` }),
'/y': page({ title: 'Page Y', canonical: '/y', scripts: ['/js/site.js', '/js/other.js'], body: `<h1>Y</h1>${LINKS}` }),
'/api/thing': page({ title: 'API', canonical: '/api/thing', body: '<h1>api</h1>' }),
'/api/thing': page({ title: 'API', canonical: '/api/thing', body: '<h1>api</h1>' }),
@@ -97,6 +98,18 @@ const server = http.createServer((req, res) => {
@@ -97,6 +98,18 @@ const server = http.createServer((req, res) => {
assert.ok(Math.abs(v.y - 40) < 5, `and returns to where the person was (${v.y})`);
assert.ok(Math.abs(v.y - 40) < 5, `and returns to where the person was (${v.y})`);
await p.evaluate('window.alive = 1; 1');
await p.evaluate('window.alive = 1; 1');
+ // A page with a <video> visited again and again in place lets each parsed page go (4.102.0). Chrome held the parsed
+ // page's own <video> as a pending activity, and with it the whole page: a document more on every visit.
+ const docs = async () => { await p.send('HeapProfiler.collectGarbage'); await p.send('HeapProfiler.collectGarbage'); return (await p.metrics()).Documents; };
+ const swap = (id) => p.evaluate(`new Promise((done) => { document.addEventListener('ov:boost:load', () => done(document.getElementById('main').dataset.page), { once: true }); document.getElementById('${id}').click(); })`);
+ assert.strictEqual(await swap('to-v'), 'Page V');
+ assert.strictEqual(await swap('to-home'), 'Home');
+ const docs0 = await docs();
+ for (let i = 0; i < 4; i++) { await swap('to-v'); await swap('to-home'); }
+ const docs1 = await docs();
+ assert.strictEqual(await p.evaluate('window.alive'), 1, 'all in place');
+ assert.ok(docs1 <= docs0, `the parsed pages were let go (documents ${docs0} → ${docs1})`);
+
// Not swappable: an excluded path and a new tab are left to the browser; another release or other scripts load normally.
// Not swappable: an excluded path and a new tab are left to the browser; another release or other scripts load normally.
// Did boost claim the click? A window listener runs after boost's document listener: it records the answer and
// Did boost claim the click? A window listener runs after boost's document listener: it records the answer and
// then cancels the browser's own navigation so the test stays on this page.
// then cancels the browser's own navigation so the test stays on this page.
@@ -278,6 +278,15 @@ const kit = require('../kit');
@@ -278,6 +278,15 @@ const kit = require('../kit');
assert.ok(html.includes('<h2 class="ovk-sidebar-h">Today</h2>') && html.includes('Fix <it>') && html.includes('<p class="ovk-sidebar-empty">Nothing yet</p>'), 'sections, and an empty one says so');
assert.ok(html.includes('<h2 class="ovk-sidebar-h">Today</h2>') && html.includes('Fix <it>') && html.includes('<p class="ovk-sidebar-empty">Nothing yet</p>'), 'sections, and an empty one says so');
assert.ok(html.includes('data-ovk-sidebar-close') && html.includes('<label class="ovk-sr" for="side-q">'), 'a close button and a labelled search');
assert.ok(html.includes('data-ovk-sidebar-close') && html.includes('<label class="ovk-sr" for="side-q">'), 'a close button and a labelled search');
assert.match(kit.sidebarButton({ id: 'side' }), /data-ovk-sidebar-open aria-controls="side" aria-expanded="false"/);
assert.match(kit.sidebarButton({ id: 'side' }), /data-ovk-sidebar-open aria-controls="side" aria-expanded="false"/);
+ // 4.102.0: a second line, and data attributes a page's script finds the row by (only data-*).
+ const two = kit.sidebar({ sections: [{ items: [
+ { href: '/inbox/1?c=7', label: 'Ana', meta: '2m', dot: 'needs', sub: 'Where is <my> order?', mark: '<span class="m">A</span>', attrs: { 'data-conv': 7, onclick: 'x()', 'data-x"y': 1, 'data-off': false } },
+ { href: '/inbox/1?c=8', label: 'Bo', sub: 'Thanks' },
+ ] }] });
+ assert.ok(two.includes('<a class="ovk-sidebar-item has-sub has-mark" href="/inbox/1?c=7" data-conv="7">'), 'a mark beside two lines, the row findable by data-conv');
+ assert.ok(two.includes('<span class="ovk-sidebar-s">Where is <my> order?</span></a>') && !two.includes('onclick') && !two.includes('data-x') && !two.includes('data-off'), 'the line escaped, only data-* attributes kept');
+ assert.ok(two.includes('<a class="ovk-sidebar-item has-sub" href="/inbox/1?c=8">'), 'no mark: no mark column');
+ assert.ok(!html.includes('has-sub') && !html.includes('class="ovk-sidebar-s"'), 'a one-line row is unchanged');
assert.match(kit.withSidebar({ sidebar: html, body: '<p>x</p>' }), /^<div class="ovk-with-sidebar"><aside[\s\S]*<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden><\/div><div class="ovk-with-sidebar-main"><p>x<\/p><\/div><\/div>$/);
assert.match(kit.withSidebar({ sidebar: html, body: '<p>x</p>' }), /^<div class="ovk-with-sidebar"><aside[\s\S]*<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden><\/div><div class="ovk-with-sidebar-main"><p>x<\/p><\/div><\/div>$/);
const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'kit.css'), 'utf8');
const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'kit.css'), 'utf8');
assert.ok(/@media \(max-width: 760px\) \{\s*\.ovk-with-sidebar \{ grid-template-columns: minmax\(0, 1fr\); \}\s*\.ovk-sidebar \{ position: fixed; z-index: 10060;/.test(css), 'a drawer over everything on a phone');
assert.ok(/@media \(max-width: 760px\) \{\s*\.ovk-with-sidebar \{ grid-template-columns: minmax\(0, 1fr\); \}\s*\.ovk-sidebar \{ position: fixed; z-index: 10060;/.test(css), 'a drawer over everything on a phone');