From f1b032fbcac370092633169bd312259a55ebb4e0 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:18:42 -0600 Subject: [PATCH 1/3] fix: group release navigation by month and mark patch releases --- scripts/docs-site/build.mjs | 9 +- scripts/docs-site/release-announcement.css | 3 +- scripts/docs-site/release-navigation.mjs | 63 ++++++++++ scripts/docs-site/release-navigation.test.mjs | 118 ++++++++++++++++++ scripts/docs-site/smoke.mjs | 9 ++ 5 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 scripts/docs-site/release-navigation.mjs create mode 100644 scripts/docs-site/release-navigation.test.mjs diff --git a/scripts/docs-site/build.mjs b/scripts/docs-site/build.mjs index 4b20ea234c..36b3249b25 100644 --- a/scripts/docs-site/build.mjs +++ b/scripts/docs-site/build.mjs @@ -28,6 +28,7 @@ import { parseFrontmatter } from "../../.openclaw-sync/lib/docs-markdown.mjs"; import { renderPageOgCards, renderOgPng } from "./og-cards.mjs"; import { renderDefaultOgSvg } from "./og-card-template.mjs"; import { activeTabTitle, groupForPage, flattenNavEntries, flattenNav } from "./navigation.mjs"; +import { releaseNavigationEntries, patchReleaseSlugs, patchReleaseBadgeHtml } from "./release-navigation.mjs"; import { resolveRedirects } from "../../.openclaw-sync/lib/docs-redirects.mjs"; const root = process.cwd(); @@ -99,6 +100,7 @@ const allPages = [...collectPages(locales), ...(includeElementsFixture ? [elemen const allPageByKey = new Map(allPages.map((page) => [pageKey(page.locale, page.slug), page])); const announcement = latestReleaseAnnouncement(allPages); const announcedReleasePage = announcement?.page ?? null; +const patchReleases = patchReleaseSlugs(allPages); let pages = allPages; let pageByKey = new Map(pages.map((page) => [pageKey(page.locale, page.slug), page])); const navByLocale = new Map(locales.map((locale) => [locale.code, buildNav(locale)])); @@ -194,6 +196,7 @@ function collectPages(localeList) { raw, title, summary: String(parsed.data.summary ?? ""), + description: String(parsed.data.description ?? ""), readWhen: parsed.data.read_when ?? [], body: parsed.content, meta: { @@ -709,19 +712,19 @@ function icon(name) { } function navGroupHtml(activePage, group) { - return ``; + return ``; } function navEntryHtml(activePage, entry) { const current = entry.group && flattenNavEntries(entry.pages).some((page) => page.locale === activePage.locale && page.slug === activePage.slug); return entry.group - ? `` + ? `` : navLink(activePage, entry); } function navLink(activePage, page) { const active = activePage.locale === page.locale && activePage.slug === page.slug ? " active" : ""; - return `${escapeHtml(page.title)}${announcement && page.slug === announcedReleasePage.slug ? releaseBadgeHtml(announcement, activePage.locale) : ""}`; + return `${escapeHtml(page.title)}${patchReleases.has(page.slug) ? patchReleaseBadgeHtml(activePage.locale) : ""}${announcement && page.slug === announcedReleasePage.slug ? releaseBadgeHtml(announcement, activePage.locale) : ""}`; } function tableOfContents(html) { diff --git a/scripts/docs-site/release-announcement.css b/scripts/docs-site/release-announcement.css index 1701f9491e..64f4fcfd88 100644 --- a/scripts/docs-site/release-announcement.css +++ b/scripts/docs-site/release-announcement.css @@ -2,7 +2,8 @@ .docs-layout .release-nav-version { display:inline-flex; align-items:center; gap:5px; flex:none; margin-inline-start:auto; padding:3px 5px; border-radius:5px; background:var(--oc-status-success-bg); color:var(--oc-status-success-fg); font:10px/1.3 var(--oc-font-mono); white-space:nowrap; } .docs-layout :is(.docs-section > summary,.docs-section-trigger):has(.release-nav-version) > .icon { margin-inline-start:0; } .release-nav-version i, .release-entry-badge i, .release-strip-heading > i { display:inline-block; width:5px; height:5px; flex:0 0 5px; border-radius:50%; background:var(--oc-status-success-fg); } -.docs-layout .nav-link:has(.release-entry-badge) { display:flex; align-items:center; gap:8px; } +.docs-layout .nav-link:has(.release-entry-badge, .release-patch-badge) { display:flex; align-items:center; flex-wrap:wrap; gap:6px; } +.release-patch-badge { display:inline-flex; flex:none; padding:1px 5px; border:1px solid var(--docs-border); border-radius:4px; color:var(--docs-muted); background:var(--docs-surface); font:500 10px/1.5 var(--oc-font-body); } .release-entry-badge { display:inline-flex; align-items:center; gap:5px; padding:1px 5px; border-radius:4px; color:var(--oc-status-success-fg); background:var(--oc-status-success-bg); font:500 10px/1.5 var(--oc-font-body); } /* Keep the release announcement a single compact row at every width. */ .docs-layout .doc .release-strip { position:relative; isolation:isolate; container:release-strip / inline-size; display:flex; align-items:center; justify-content:space-between; gap:20px; min-height:44px; margin:0 0 24px; padding:10px 16px; border:1px solid var(--docs-border); border-radius:var(--oc-radius-lg); background:var(--docs-surface); color:var(--ink); text-decoration:none; box-shadow:var(--docs-surface-shadow); } diff --git a/scripts/docs-site/release-navigation.mjs b/scripts/docs-site/release-navigation.mjs new file mode 100644 index 0000000000..2ab97dfbea --- /dev/null +++ b/scripts/docs-site/release-navigation.mjs @@ -0,0 +1,63 @@ +import { flattenNavEntries } from "./navigation.mjs"; +import { escapeUiText } from "./home-strings.mjs"; + +// Use release-owned English descriptions/introductions, never calendar version +// numbers or translated prose, to classify releases consistently in every locale. +export function patchReleaseSlugs(pages) { + return new Set(pages.filter(page => page.locale === "en" && releaseMonth(page.slug) + && (/\bpatch release\b/i.test(page.description ?? "") + || /^This (?:is a )?patch release(?: |[.,])/m.test((page.body ?? "").split(/^## /m)[0]))) + .map(page => page.slug)); +} + +const patchLabels = { + en: "Patch", sv: "Patch", de: "Patch", fr: "Correctif", es: "Parche", "pt-BR": "Correção", + ar: "تصحيح", fa: "وصله", "zh-CN": "补丁", "zh-TW": "修補", "ja-JP": "パッチ", ko: "패치", + hi: "पैच", it: "Correzione", vi: "Bản vá", nl: "Patch", tr: "Yama", uk: "Виправлення", + id: "Patch", pl: "Poprawka", ru: "Исправление", th: "แพตช์", +}; + +export function patchReleaseBadgeHtml(locale) { + return ` ${escapeUiText(patchLabels[locale] ?? patchLabels.en)}`; +} + +function releaseMonth(slug) { + const match = /^releases\/(\d{4})\.(\d{1,2})\.\d+(?:[-.][\w.-]+)?$/.exec(slug ?? ""); + if (!match || Number(match[2]) < 1 || Number(match[2]) > 12) return null; + return `${match[1]}-${match[2].padStart(2, "0")}`; +} + +// Group adjacent release pages without changing source order, links, or groups. +export function releaseNavigationEntries(entries, locale, activeSlug) { + const firstMonth = flattenNavEntries(entries).map(page => releaseMonth(page.slug)).find(Boolean); + if (!firstMonth) return entries; + const selectedMonth = releaseMonth(activeSlug) ?? firstMonth; + const formatter = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }); + function groupMonths(pages) { + const result = []; + for (const entry of pages) { + if (entry.group) { + result.push({ ...entry, pages: groupMonths(entry.pages) }); + continue; + } + const month = releaseMonth(entry.slug); + if (!month) { + result.push(entry); + continue; + } + let group = result.at(-1); + if (group?.releaseMonth !== month) { + group = { + group: formatter.format(new Date(`${month}-01T00:00:00Z`)), + releaseMonth: month, + defaultOpen: month === selectedMonth, + pages: [], + }; + result.push(group); + } + group.pages.push(entry); + } + return result; + } + return groupMonths(entries); +} diff --git a/scripts/docs-site/release-navigation.test.mjs b/scripts/docs-site/release-navigation.test.mjs new file mode 100644 index 0000000000..0fb377163a --- /dev/null +++ b/scripts/docs-site/release-navigation.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { chromium } from "playwright"; +import { releaseNavigationEntries, patchReleaseSlugs, patchReleaseBadgeHtml } from "./release-navigation.mjs"; +import { homeStrings } from "./home-strings.mjs"; +import { flattenNavEntries } from "./navigation.mjs"; +import { fixture, write } from "./test-helpers/redirect-fixture.mjs"; + +const slugs = ["releases/index", "releases/2026.11.3", "releases/2026.11.2", "releases/2026.10.10", "releases/2026.10.1", "releases/2026.9.9", "releases/2025.11.1", "changelog"]; +const entries = slugs.map(slug => ({ slug, locale: "en", title: slug })); + +test("release months preserve every source link and order without mutating navigation", () => { + const original = JSON.stringify(entries); + const grouped = releaseNavigationEntries(entries, "en", "releases/2026.10.1"); + assert.deepEqual(flattenNavEntries(grouped), entries); + assert.equal(JSON.stringify(entries), original); + assert.deepEqual(grouped.filter(entry => entry.releaseMonth).map(entry => [entry.group, entry.defaultOpen]), [ + ["November 2026", false], ["October 2026", true], ["September 2026", false], ["November 2025", false], + ]); + assert.equal(grouped[0], entries[0]); + assert.equal(grouped.at(-1), entries.at(-1)); +}); + +test("overview defaults to the newest source month and labels follow the page locale", () => { + const grouped = releaseNavigationEntries(entries, "fr", "releases/index"); + assert.equal(grouped[1].group, "novembre 2026"); + assert.deepEqual(grouped.filter(entry => entry.defaultOpen).map(entry => entry.releaseMonth), ["2026-11"]); +}); + +test("nested source groups, non-release pages and invalid months are preserved", () => { + const other = { group: "Reference", pages: [{ slug: "guide/2026.11.1" }, { slug: "releases/2026.13.1" }] }; + const source = [{ group: "Archive", pages: entries }, other]; + const grouped = releaseNavigationEntries(source, "en", "releases/2025.11.1"); + assert.deepEqual(flattenNavEntries(grouped), flattenNavEntries(source)); + assert.equal(grouped[0].group, "Archive"); + assert.equal(grouped[0].pages.at(-2).defaultOpen, true); + assert.deepEqual(grouped[1], other); +}); + +test("patch badges require a release-owned introduction rather than version guessing", () => { + const page = (slug, body, locale = "en") => ({ slug, body, locale }); + assert.deepEqual([...patchReleaseSlugs([ + page("releases/2026.11.3", "This is a patch release with no new features."), + page("releases/2026.11.1", "This patch release adds no new features."), + page("releases/2026.10.10", "New features.\n\n## History\nThis is a patch release with no new features."), + page("guide", "This is a patch release with no new features."), + page("releases/2026.11.2", "This is a patch release with no new features.", "fr"), + { ...page("releases/2026.10.2", "Fixes for updates."), description: "OpenClaw v2026.10.2 patch release notes" }, + ])], ["releases/2026.11.3", "releases/2026.11.1", "releases/2026.10.2"]); + assert.match(patchReleaseBadgeHtml("fr"), />Correctif { + const browser = await chromium.launch({ headless: true }); + t.after(() => browser.close()); + const f = fixture(t, [], Object.fromEntries(slugs.map(slug => [`${slug}.md`, `---\ntitle: "${slug.startsWith("releases/202") ? `v${slug.slice(9)}` : slug}"\n---\n# Release fixture\n\n${["releases/2026.11.3", "releases/2026.10.1"].includes(slug) ? "This is a patch release with no new features.\n\n" : ""}[Newest](/releases/2026.11.3)\n`]))); + write(f.root, "docs/docs.json", JSON.stringify({ navigation: { languages: [{ language: "en", tabs: [ + { tab: "Get started", groups: [{ group: "Docs", pages: ["index"] }] }, + { tab: "Releases", groups: [{ group: "Release notes", pages: slugs }] }, + ] }] } })); + const built = f.build(); + assert.equal(built.status, 0, built.stderr); + const site = path.join(f.root, "dist/docs-site"); + for (const width of [390, 1440]) { + const context = await browser.newContext({ viewport: { width, height: 900 } }); + context.setDefaultTimeout(5000); + await context.route("**/*", async route => { + const url = new URL(route.request().url()); + if (url.origin !== "http://docs.test") return route.abort(); + let file = path.join(site, url.pathname); + if (!path.extname(file) || fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html"); + if (!fs.existsSync(file)) return route.fulfill({ status: 404, body: "Not found" }); + const contentType = ({ ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2" })[path.extname(file)] || "application/octet-stream"; + return route.fulfill({ contentType, body: fs.readFileSync(file) }); + }); + const page = await context.newPage(); + await page.goto("http://docs.test/releases/2026.10.1"); + await page.locator('.docs-sidebar-levels[data-level="section"]').waitFor({ state: "attached" }); + if (width < 821) await page.locator("[data-nav-toggle]").click(); + const months = page.locator(".release-nav-month"); + assert.equal(await months.count(), 4); + assert.equal(await page.locator('.sidebar a[href="/releases/2026.10.1"] .release-patch-badge').textContent(), "Patch"); + assert.equal(await page.locator('.sidebar a[href="/releases/2026.10.10"] .release-patch-badge').count(), 0); + assert.equal(await page.locator('.sidebar a[href="/releases/2026.11.3"] .release-entry-badge').count(), 1); + assert.deepEqual(await months.evaluateAll(nodes => nodes.map(node => node.open)), [false, true, false, false]); + assert.equal(await page.locator('.nav-link.active[href="/releases/2026.10.1"]').isVisible(), true); + // A month disclosure remains a keyboard-operable native control. + const november = months.nth(0).locator(":scope > summary"); + await november.focus(); + await page.keyboard.press("Enter"); + assert.equal(await months.nth(0).evaluate(node => node.open), true); + await page.locator('.sidebar a[href="/releases/2026.11.3"]').click(); + await page.waitForURL("**/releases/2026.11.3"); + if (width < 821) await page.locator("[data-nav-toggle]").click(); + assert.equal(await page.locator('.nav-link.active[href="/releases/2026.11.3"]').isVisible(), true); + assert.equal(await months.nth(0).evaluate(node => node.open), true); + // Navigating to a closed month through article content must reveal it too. + await page.goto("http://docs.test/releases/2026.9.9"); + await page.locator('.docs-sidebar-levels[data-level="section"]').waitFor({ state: "attached" }); + await page.locator(".doc a", { hasText: "Newest" }).click(); + await page.waitForURL("**/releases/2026.11.3"); + if (width < 821) await page.locator("[data-nav-toggle]").click(); + assert.equal(await page.locator('.nav-link.active[href="/releases/2026.11.3"]').isVisible(), true); + await page.goto("http://docs.test/releases/2026.11.3"); + await page.locator('.docs-sidebar-levels[data-level="section"]').waitFor({ state: "attached" }); + if (width < 821) await page.locator("[data-nav-toggle]").click(); + assert.deepEqual(await months.evaluateAll(nodes => nodes.map(node => node.open)), [true, false, false, false]); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + if (process.env.RELEASE_NAV_ARTIFACT_DIR) { + fs.mkdirSync(process.env.RELEASE_NAV_ARTIFACT_DIR, { recursive: true }); + await page.screenshot({ path: path.join(process.env.RELEASE_NAV_ARTIFACT_DIR, `release-months-${width}.png`) }); + } + await context.close(); + } +}); diff --git a/scripts/docs-site/smoke.mjs b/scripts/docs-site/smoke.mjs index f93808c105..e0a46f4dfd 100644 --- a/scripts/docs-site/smoke.mjs +++ b/scripts/docs-site/smoke.mjs @@ -558,6 +558,15 @@ if (docsSections?.attribs["aria-label"] !== homeStrings.en.navigation[6] || /class="(?:tabs|mobile-tabs)"/.test(index)) { throw new Error("assets: complete vertical docs navigation is missing"); } +const releaseLinks = DomUtils.findAll(node => node.name === "a" + && /^\/releases\/\d{4}\.\d{1,2}\.\d+$/.test((node.attribs.href ?? "").slice(basePath.length)), docsSections.children); +for (const link of releaseLinks) { + let group = link.parent; + while (group && !group.attribs?.class?.split(" ").includes("release-nav-month")) group = group.parent; + if (!group || group.name !== "details" || !group.children.some(node => node.name === "summary")) { + throw new Error(`release navigation: version link must be inside a native month disclosure: ${link.attribs.href}`); + } +} if (!/function initChat/.test(siteJs) || !/data-chat-form/.test(siteJs) || !/new URL\("\/ask-molty\/sign-in",location\.href\)/.test(siteJs) From 4761b47bcfff5f9baae6b3ff4e6bdfde21cd3203 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:21:51 -0600 Subject: [PATCH 2/3] fix: keep localized release month labels Gregorian --- scripts/docs-site/release-navigation.mjs | 2 +- scripts/docs-site/release-navigation.test.mjs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/docs-site/release-navigation.mjs b/scripts/docs-site/release-navigation.mjs index 2ab97dfbea..fbf5515805 100644 --- a/scripts/docs-site/release-navigation.mjs +++ b/scripts/docs-site/release-navigation.mjs @@ -32,7 +32,7 @@ export function releaseNavigationEntries(entries, locale, activeSlug) { const firstMonth = flattenNavEntries(entries).map(page => releaseMonth(page.slug)).find(Boolean); if (!firstMonth) return entries; const selectedMonth = releaseMonth(activeSlug) ?? firstMonth; - const formatter = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }); + const formatter = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", calendar: "gregory", timeZone: "UTC" }); function groupMonths(pages) { const result = []; for (const entry of pages) { diff --git a/scripts/docs-site/release-navigation.test.mjs b/scripts/docs-site/release-navigation.test.mjs index 0fb377163a..97f40602b3 100644 --- a/scripts/docs-site/release-navigation.test.mjs +++ b/scripts/docs-site/release-navigation.test.mjs @@ -29,6 +29,16 @@ test("overview defaults to the newest source month and labels follow the page lo assert.deepEqual(grouped.filter(entry => entry.defaultOpen).map(entry => entry.releaseMonth), ["2026-11"]); }); +test("localized month names retain the Gregorian release calendar", () => { + for (const [locale, expected] of [["fa", "ژوئن ۲۰۲۶"], ["th", "มิถุนายน 2026"]]) { + const grouped = releaseNavigationEntries([{ slug: "releases/2026.6.11" }], locale, "releases/2026.6.11"); + assert.equal(grouped[0].group, expected); + assert.equal(grouped[0].releaseMonth, "2026-06"); + assert.equal(grouped[0].defaultOpen, true); + assert.equal(grouped[0].pages[0].slug, "releases/2026.6.11"); + } +}); + test("nested source groups, non-release pages and invalid months are preserved", () => { const other = { group: "Reference", pages: [{ slug: "guide/2026.11.1" }, { slug: "releases/2026.13.1" }] }; const source = [{ group: "Archive", pages: entries }, other]; From cb0e4b683736e465cc16d0c723c56fa6d244c39d Mon Sep 17 00:00:00 2001 From: hannesrudolph <49103247+hannesrudolph@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:57:22 +0000 Subject: [PATCH 3/3] fix: release navigation becomes unreadable as versions accumulate OpenClaw-Publication: be54f9fd-83d8-81b1-b8b5-dfba5a4cf841