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
9 changes: 6 additions & 3 deletions scripts/docs-site/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)]));
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -709,19 +712,19 @@ function icon(name) {
}

function navGroupHtml(activePage, group) {
return `<details class="nav-section" open><summary><h2>${escapeHtml(group.title)}</h2>${icon("chevron-down")}</summary>${group.pages.map((entry) => navEntryHtml(activePage, entry)).join("")}</details>`;
return `<details class="nav-section" open><summary><h2>${escapeHtml(group.title)}</h2>${icon("chevron-down")}</summary>${releaseNavigationEntries(group.pages, activePage.locale, activePage.slug).map((entry) => navEntryHtml(activePage, entry)).join("")}</details>`;
}

function navEntryHtml(activePage, entry) {
const current = entry.group && flattenNavEntries(entry.pages).some((page) => page.locale === activePage.locale && page.slug === activePage.slug);
return entry.group
? `<details class="nav-nested"${current ? " open" : ""}><summary><h2>${escapeHtml(entry.group)}</h2>${icon("chevron-down")}</summary>${entry.pages.map((child) => navEntryHtml(activePage, child)).join("")}</details>`
? `<details class="nav-nested${entry.releaseMonth ? " release-nav-month" : ""}"${current || entry.defaultOpen ? " open" : ""}><summary><h2>${escapeHtml(entry.group)}</h2>${icon("chevron-down")}</summary>${entry.pages.map((child) => navEntryHtml(activePage, child)).join("")}</details>`
: navLink(activePage, entry);
}

function navLink(activePage, page) {
const active = activePage.locale === page.locale && activePage.slug === page.slug ? " active" : "";
return `<a class="nav-link${active}" href="${pageUrl(page)}"${previewLinkAttrs(page)}${active ? ' aria-current="page"' : ""}>${escapeHtml(page.title)}${announcement && page.slug === announcedReleasePage.slug ? releaseBadgeHtml(announcement, activePage.locale) : ""}</a>`;
return `<a class="nav-link${active}" href="${pageUrl(page)}"${previewLinkAttrs(page)}${active ? ' aria-current="page"' : ""}>${escapeHtml(page.title)}${patchReleases.has(page.slug) ? patchReleaseBadgeHtml(activePage.locale) : ""}${announcement && page.slug === announcedReleasePage.slug ? releaseBadgeHtml(announcement, activePage.locale) : ""}</a>`;
}

function tableOfContents(html) {
Expand Down
3 changes: 2 additions & 1 deletion scripts/docs-site/release-announcement.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
63 changes: 63 additions & 0 deletions scripts/docs-site/release-navigation.mjs
Original file line number Diff line number Diff line change
@@ -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 ` <span class="release-patch-badge">${escapeUiText(patchLabels[locale] ?? patchLabels.en)}</span>`;
}

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", calendar: "gregory", 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);
}
128 changes: 128 additions & 0 deletions scripts/docs-site/release-navigation.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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("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];
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</);
for (const locale of Object.keys(homeStrings)) assert.ok(!patchReleaseBadgeHtml(locale).includes("undefined"));
});

test("responsive release disclosures reveal the selected month on direct and PJAX navigation", async t => {
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();
}
});
9 changes: 9 additions & 0 deletions scripts/docs-site/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading