From 4a3a859b9c2ad8443b7339b9252c188a19e614ef Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 24 Oct 2026 19:26:56 -0700 Subject: [PATCH] Open the full feature list from prominent release links --- .github/workflows/github-pages.yml | 1 + .github/workflows/run-unit-tests.yml | 2 + RELEASE.md | 2 + docs/_javascript/release_features.js | 61 +++++++++++ docs/_stylesheets/extra.css | 5 + docs/releases/1.18.0.1.md | 5 +- mkdocs.yml | 1 + test/test_release_features.js | 152 +++++++++++++++++++++++++++ 8 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 docs/_javascript/release_features.js create mode 100644 test/test_release_features.js diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 7511497ca..c666e9ab7 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -34,6 +34,7 @@ jobs: pip install mkdocs-material node test/test_firmware_picker.js node test/test_partition_catalog_viewer.js + node test/test_release_features.js python scripts/sync_bootloader_manifest.py mkdocs build cmp firmware/esp32_partition_catalog.json site/_data/esp32_partition_catalog.json diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 59d00855c..8a61f28ba 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -1224,3 +1224,5 @@ jobs: run: python3 -B test/test_hosted_doc_links.py -v - name: Verify hosted-link visibility and navigation run: node test/test_hosted_doc_links.js + - name: Release feature link behavior + run: node test/test_release_features.js diff --git a/RELEASE.md b/RELEASE.md index 3cc310a99..b0d319e1e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -96,6 +96,8 @@ linked bullets for the complete feature overview. Name the compared upstream release and comparison date; distinguish additions from extensions of existing features, and qualify board/role support. Keep it separate from the revision's New and Fixed lists. Recheck it against the actual released source each time. +Put a direct feature link at the top of every release page. Link to the hosted +release guide's `#extra-features` disclosure so clicking opens the feature list. Compare the actual previous release's source and notes. A long commit list can include old work imported by merges; do not present it all as new. Do not relist diff --git a/docs/_javascript/release_features.js b/docs/_javascript/release_features.js new file mode 100644 index 000000000..49e45363b --- /dev/null +++ b/docs/_javascript/release_features.js @@ -0,0 +1,61 @@ +/* Open the release feature list when its link is followed. */ +(function () { + "use strict"; + + const featureHash = "#extra-features"; + + function openReleaseFeatures(doc, hash) { + if (hash !== featureHash) return false; + const details = doc.getElementById("extra-features"); + if (!details || String(details.tagName).toLowerCase() !== "details") return false; + details.open = true; + details.scrollIntoView({ block: "start" }); + return true; + } + + function followReleaseFeaturesLink(event, doc, location) { + if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey + || (typeof event.button === "number" && event.button !== 0)) return false; + let target = event.target; + if (target && typeof target.closest !== "function") target = target.parentElement; + const link = target && typeof target.closest === "function" ? target.closest("a[href]") : null; + if (!link || link.hasAttribute("download")) return false; + const linkTarget = (link.getAttribute("target") || "").toLowerCase(); + if (linkTarget && linkTarget !== "_self") return false; + let destination; + let current; + try { + current = new URL(location.href); + destination = new URL(link.getAttribute("href"), doc.baseURI || current.href); + } catch (_) { + return false; + } + if (destination.origin !== current.origin || destination.pathname !== current.pathname + || destination.search !== current.search) return false; + // Do not prevent normal navigation. Opening before its default action also + // handles a repeated click when this hash is already in the address bar. + return openReleaseFeatures(doc, destination.hash); + } + + if (typeof module === "object" && module.exports) { + module.exports = { openReleaseFeatures, followReleaseFeaturesLink }; + } + if (typeof window === "undefined" || typeof document === "undefined") return; + + const open = function () { + openReleaseFeatures(document, window.location.hash); + }; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", open, { once: true }); + } else { + open(); + } + window.addEventListener("hashchange", open); + document.addEventListener("click", function (event) { + followReleaseFeaturesLink(event, document, window.location); + }); + // Material can replace the article without reloading this script. + if (typeof document$ !== "undefined" && document$ && typeof document$.subscribe === "function") { + document$.subscribe(open); + } +})(); diff --git a/docs/_stylesheets/extra.css b/docs/_stylesheets/extra.css index f9f75b120..a3398215e 100644 --- a/docs/_stylesheets/extra.css +++ b/docs/_stylesheets/extra.css @@ -14,3 +14,8 @@ .md-typeset a { text-decoration: underline; } + +/* Keep the feature summary visible below the fixed navigation after a jump. */ +#extra-features { + scroll-margin-top: 6rem; +} diff --git a/docs/releases/1.18.0.1.md b/docs/releases/1.18.0.1.md index f968804e3..2a8aac1f9 100644 --- a/docs/releases/1.18.0.1.md +++ b/docs/releases/1.18.0.1.md @@ -7,7 +7,7 @@ **Stable release.** Meshtext chat, simpler ESP32 administration and new repeater controls. Changes below are relative to **1.17.1.9**. -[Choose your firmware](../firmware_picker.md) | [Update instructions](../ota_user_guide.md) +[See all features beyond mainline MeshCore](#extra-features) | [Choose your firmware](../firmware_picker.md) | [Update instructions](../ota_user_guide.md) ## New @@ -41,8 +41,7 @@ controls. Changes below are relative to **1.17.1.9**. - XIAO nRF52 external-flash startup, storage alignment and Bluetooth handling. - V4 USB logging idle/reopen failures and ESP32 Bluetooth/power-saving recovery. - -
+
Features beyond mainline MeshCore

Included in 1.18.0.1 compared with mainline MeshCore 1.18.0, checked 24 October 2026. This includes earlier Cascade additions and extensions to existing mainline features. Availability depends on the board and firmware role; some features require compatible clients or default off.

diff --git a/mkdocs.yml b/mkdocs.yml index 558851cec..2c3b9014c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -132,6 +132,7 @@ extra_css: - _stylesheets/esp32_partition_catalog.css?v=20261001-1 extra_javascript: + - _javascript/release_features.js?v=20261024-1 - _javascript/hosted_doc_links.js?v=20261007-1 - _javascript/notification_builder.js?v=20261006-channel9 - https://unpkg.com/leaflet@1.9.4/dist/leaflet.js diff --git a/test/test_release_features.js b/test/test_release_features.js new file mode 100644 index 000000000..82ccd728f --- /dev/null +++ b/test/test_release_features.js @@ -0,0 +1,152 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const { openReleaseFeatures, followReleaseFeaturesLink } = require("../docs/_javascript/release_features.js"); + +const page = "https://mikecarper.github.io/MeshCore/releases/1.18.0.1/"; +const script = fs.readFileSync(require.resolve("../docs/_javascript/release_features.js"), "utf8"); + +function fixture(hash = "", readyState = "complete") { + const documentHandlers = new Map(); + const windowHandlers = new Map(); + let navigation; + let element; + const location = { href: page + hash, hash }; + const doc = { + baseURI: page, + readyState, + getElementById(id) { + assert.equal(id, "extra-features"); + return element; + }, + addEventListener(name, callback, options) { + if (name === "DOMContentLoaded") assert.equal(options.once, true); + assert.equal(documentHandlers.has(name), false, "handlers are installed once per page"); + documentHandlers.set(name, callback); + } + }; + function replaceDetails(tagName = "DETAILS") { + element = { tagName, open: false, scrolls: 0, scrollIntoView(options) { + assert.equal(this.open, true, "expand before scrolling"); + assert.equal(options.block, "start"); + this.scrolls++; + } }; + return element; + } + replaceDetails(); + return { + doc, location, replaceDetails, + get details() { return element; }, + removeDetails() { element = null; }, + boot() { + vm.runInNewContext(script, { + URL, + window: { location, addEventListener(name, callback) { windowHandlers.set(name, callback); } }, + document: doc, + document$: { subscribe(callback) { navigation = callback; } } + }); + }, + ready() { documentHandlers.get("DOMContentLoaded")(); }, + navigate(hash) { location.hash = hash; location.href = page + hash; windowHandlers.get("hashchange")(); }, + materialNavigate() { navigation(); }, + click(event) { documentHandlers.get("click")(event); } + }; +} + +function click(href, properties = {}, attributes = {}) { + const values = { href, ...attributes }; + const link = { + getAttribute(name) { return values[name] ?? null; }, + hasAttribute(name) { return Object.hasOwn(values, name); } + }; + return { + button: 0, + target: { closest(selector) { assert.equal(selector, "a[href]"); return link; } }, + preventDefault() { assert.fail("normal link navigation must be preserved"); }, + ...properties + }; +} + +// An initial fragment must work both before and after DOM readiness. +const loading = fixture("#extra-features", "loading"); +loading.boot(); +assert.equal(loading.details.open, false); +loading.ready(); +assert.equal(loading.details.open, true); +assert.equal(loading.details.scrolls, 1); +const ready = fixture("#extra-features"); +ready.boot(); +assert.equal(ready.details.open, true); + +// Unrelated hashes do not expand, including after an instant-navigation update. +const ordinary = fixture("#install"); +ordinary.boot(); +ordinary.materialNavigate(); +assert.equal(ordinary.details.open, false); +assert.equal(ordinary.details.scrolls, 0); +ordinary.navigate("#extra-features"); +assert.equal(ordinary.details.open, true); +const replacement = ordinary.replaceDetails(); +ordinary.materialNavigate(); +assert.equal(replacement.open, true); +assert.equal(replacement.scrolls, 1); + +// Clicking the already-selected fragment reopens a manually closed list. +replacement.open = false; +ordinary.click(click("#extra-features")); +assert.equal(replacement.open, true); +assert.equal(replacement.scrolls, 2); +replacement.open = false; +ordinary.click(click(page + "#extra-features")); +assert.equal(replacement.open, true); + +// Same-page clicks work without depending on a future hashchange event. +const directClick = fixture(); +directClick.boot(); +directClick.click(click("#extra-features")); +assert.equal(directClick.details.open, true); +assert.equal(directClick.location.hash, "", "the browser owns URL navigation"); + +// Preserve normal browser actions and links to other pages or sites. +const rejected = [ + click("#install"), + click("#extra-features", { defaultPrevented: true }), + ...["ctrlKey", "metaKey", "shiftKey", "altKey"].map(key => click("#extra-features", { [key]: true })), + click("#extra-features", { button: 1 }), + click("#extra-features", { button: 2 }), + click("#extra-features", {}, { target: "_blank" }), + click("#extra-features", {}, { target: "another-window" }), + click("#extra-features", {}, { download: "features.html" }), + click("https://example.org/MeshCore/releases/1.18.0.1/#extra-features"), + click("../1.18.0/#extra-features"), + click("?different=1#extra-features"), + click("mailto:example@example.org#extra-features"), + { button: 0, target: null }, + { button: 0, target: { closest() { return null; } } } +]; +for (const event of rejected) { + const node = ordinary.replaceDetails(); + assert.equal(followReleaseFeaturesLink(event, ordinary.doc, ordinary.location), false); + assert.equal(node.open, false); + assert.equal(node.scrolls, 0); +} + +// Text nodes inside links, explicit same-frame targets and a base URL are supported. +const textClick = click("#extra-features", {}, { target: "_self" }); +textClick.target = { parentElement: textClick.target }; +assert.equal(followReleaseFeaturesLink(textClick, ordinary.doc, ordinary.location), true); +ordinary.doc.baseURI = "https://example.org/"; +const untouched = ordinary.replaceDetails(); +assert.equal(followReleaseFeaturesLink(click("#extra-features"), ordinary.doc, ordinary.location), false); +assert.equal(untouched.open, false); + +// Missing sections and an accidentally reused ID are harmless. +ordinary.removeDetails(); +assert.equal(openReleaseFeatures(ordinary.doc, "#extra-features"), false); +ordinary.replaceDetails("DIV"); +assert.equal(openReleaseFeatures(ordinary.doc, "#extra-features"), false); +assert.equal(openReleaseFeatures(ordinary.doc, "#extra-features-other"), false); + +console.log("Release feature links: startup, hash, repeat click, navigation and browser-action guards passed.");