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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ English content and navigation are authored in [`openclaw/openclaw`](https://git

- `scripts/docs-site/site-layout.css` adapts the community site's compact network header, ruled frame, connected panels, and footer to documentation. The full source section tree is available in the left sidebar, with native expandable groups. Desktop pages use the full browser width, with navigation on the left, a centered reading column, and the TOC on the right. The landing hero spans the area beside the sidebar; the article and TOC start together below it. Narrower layouts collapse the TOC and navigation. `hero-art.mjs` owns the shared ASCII artwork with reduced-motion and route cleanup. Shared semantic tokens and component foundations remain in `site-css.mjs`; structural panels are square, while controls retain rounded corners.
- `npm run docs:build` renders the synced Markdown and MDX-style docs into `dist/docs-site`.
- The release badge and homepage announcement follow the highest numeric stable English release page in the synced docs. Hidden, draft, unreleased and beta pages are excluded. English announcement text comes from that page's `summary`; no renderer version pin needs updating when a new release page is synced.
- English collection excludes top-level roots owned by published locales; each locale renders its own sources once, while nested or unrecognized directory names remain ordinary English paths.
- Search titles and summaries and LLM corpus headings follow the page renderer's YAML frontmatter rules. The English corpus excludes language directories at the docs root while retaining nested paths such as `guide/fr/topic`.
- Page-specific social preview images follow English navigation groups at every nesting depth.
Expand Down
10 changes: 4 additions & 6 deletions scripts/docs-site/build.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import fs from "node:fs";
import { releaseAnnouncement, releaseVersionHtml, releaseBadgeHtml } from "./release-announcement.mjs";
import { latestReleaseAnnouncement, releaseVersionHtml, releaseBadgeHtml } from "./release-announcement.mjs";
import { homeLayoutHtml } from "./home-layout.mjs";
import { homeStringsForLocale } from "./home-strings.mjs";
import { homeHeroArt } from "./home-hero.mjs";
Expand Down Expand Up @@ -94,11 +94,8 @@ if (previewMode && !localeCodes.has(previewLocale)) {
}
const allPages = [...collectPages(locales), ...(includeElementsFixture ? [elementsFixturePage()] : [])];
const allPageByKey = new Map(allPages.map((page) => [pageKey(page.locale, page.slug), page]));
const announcedReleasePage = releaseAnnouncement
? allPageByKey.get(pageKey("en", `releases/${releaseAnnouncement.version}`))
: null;
// Never publish an announcement that leads to an absent or hidden page.
const announcement = announcedReleasePage && !announcedReleasePage.hidden ? releaseAnnouncement : null;
const announcement = latestReleaseAnnouncement(allPages);
const announcedReleasePage = announcement?.page ?? null;
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 @@ -197,6 +194,7 @@ function collectPages(localeList) {
readWhen: parsed.data.read_when ?? [],
body: parsed.content,
meta: {
hidden: parsed.data.hidden === true,
status: parsed.data.status ?? firstStatusLine(parsed.content),
appliesTo: parsed.data.applies_to ?? parsed.data.appliesTo,
since: parsed.data.since,
Expand Down
27 changes: 19 additions & 8 deletions scripts/docs-site/release-announcement.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { homeStringsForLocale } from "./home-strings.mjs";

// Update these two fields when publishing the next release. The matching
// docs/releases/<version>.md page must have arrived through source sync first.
// Set to null to disable the announcement.
export const releaseAnnouncement = {
version: "2026.9.7",
summary: "Smoother chats, safer updates, and Sign in with ChatGPT (Beta).",
};
// Source sync supplies the release pages and their approved summaries.
// Choose by numeric version, never by filesystem order or a manual version pin.
export function latestReleaseAnnouncement(pages) {
let latest = null;
for (const page of pages) {
const match = /^releases\/(\d{4})\.(\d+)\.(\d+)$/.exec(page.slug);
if (!match || page.locale !== "en" || page.hidden || page.meta?.hidden || page.meta?.beta
|| /^(draft|unreleased|prerelease)$/i.test(page.meta?.status ?? "")) continue;
const parts = match.slice(1).map(Number);
if (!latest || parts.some((part, index) => part > latest.parts[index]
&& parts.slice(0, index).every((earlier, position) => earlier === latest.parts[position]))) {
latest = { page, parts, version: match.slice(1).join("."), summary: page.summary || "" };
}
}
if (!latest) return null;
const { parts, ...announcement } = latest;
return announcement;
}

const escape = (value) => String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");

Expand All @@ -22,5 +33,5 @@ export function releaseBadgeHtml(release, locale = "en") {
export function releaseStripHtml(release, icon, locale = "en") {
if (!release) return "";
const [label, action, summary] = homeStringsForLocale(locale).release;
return `<a class="release-strip" href="${escape(release.href)}"><span class="release-strip-body"><span class="release-strip-heading"><span class="release-strip-label">${escape(label)}</span><i aria-hidden="true"></i><span class="release-strip-version">v${escape(release.version)}</span></span><span class="release-strip-summary">${escape(locale === "en" ? release.summary : summary)}</span></span><span class="release-strip-action"><span class="release-strip-action-label">${escape(action)}</span> ${icon("arrow-right")}</span></a>`;
return `<a class="release-strip" href="${escape(release.href)}"><span class="release-strip-body"><span class="release-strip-heading"><span class="release-strip-label">${escape(label)}</span><i aria-hidden="true"></i><span class="release-strip-version">v${escape(release.version)}</span></span><span class="release-strip-summary">${escape(locale === "en" ? release.summary || summary : summary)}</span></span><span class="release-strip-action"><span class="release-strip-action-label">${escape(action)}</span> ${icon("arrow-right")}</span></a>`;
}
55 changes: 44 additions & 11 deletions scripts/docs-site/release-announcement.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import path from "node:path";
import { before, after, test } from "node:test";
import { chromium } from "playwright";
import { parseDocument, DomUtils } from "htmlparser2";
import { releaseAnnouncement } from "./release-announcement.mjs";
import { latestReleaseAnnouncement } from "./release-announcement.mjs";
import { fixture, write } from "./test-helpers/redirect-fixture.mjs";

const version = releaseAnnouncement?.version ?? "2099.1.1";
const version = "2099.9.8";
const releaseRoute = `releases/${version}`;
const base = "/manual";
let browser;
Expand Down Expand Up @@ -36,7 +36,7 @@ function releaseFixture(t, includeRelease = true, includeNavigation = true, incl

const all = (html, predicate) => DomUtils.findAll(predicate, parseDocument(html).children);

test("translated release pages get the same latest indicators with localized labels", { skip: !releaseAnnouncement && "Announcement disabled" }, (t) => {
test("translated release pages get the same latest indicators with localized labels", (t) => {
const f = releaseFixture(t, true, true, true);
const html = fs.readFileSync(path.join(f.site, "fr/index.html"), "utf8");
const versions = all(html, node => node.attribs.class === "release-nav-version");
Expand All @@ -51,15 +51,13 @@ test("announcements respect the site base, localize their labels, and require an
const f = releaseFixture(t);
const home = fs.readFileSync(path.join(f.site, "index.html"), "utf8");
const strip = all(home, node => node.name === "a" && node.attribs.class === "release-strip");
assert.equal(strip.length, releaseAnnouncement ? 1 : 0);
if (releaseAnnouncement) assert.equal(strip[0].attribs.href, `${base}/${releaseRoute}`);
assert.equal(strip.length, 1);
assert.equal(strip[0].attribs.href, `${base}/${releaseRoute}`);
const translated = fs.readFileSync(path.join(f.site, "fr/index.html"), "utf8");
const translatedStrip = all(translated, node => node.attribs.class === "release-strip");
assert.equal(translatedStrip.length, releaseAnnouncement ? 1 : 0);
if (releaseAnnouncement) {
assert.equal(translatedStrip[0].attribs.href, `${base}/${releaseRoute}`);
assert.match(DomUtils.textContent(translatedStrip[0]), /Nouvelle version/);
}
assert.equal(translatedStrip.length, 1);
assert.equal(translatedStrip[0].attribs.href, `${base}/${releaseRoute}`);
assert.match(DomUtils.textContent(translatedStrip[0]), /Nouvelle version/);
assert.equal(all(translated, node => /^(release-nav-version|release-entry-badge)$/.test(node.attribs.class || "")).length, 0);
const missing = releaseFixture(t, false);
const withoutRelease = fs.readFileSync(path.join(missing.site, "index.html"), "utf8");
Expand Down Expand Up @@ -97,7 +95,7 @@ async function openFixture(t, f) {
return page;
}

test("release announcements stay unchanged after visits and reloads without reading or writing view state", { skip: !releaseAnnouncement && "Announcement disabled" }, async t => {
test("release announcements stay unchanged after visits and reloads without reading or writing view state", async t => {
const f = releaseFixture(t);
const page = await openFixture(t, f);
await page.getByRole("link", { name: /New release.*Read release notes/ }).waitFor();
Expand Down Expand Up @@ -129,3 +127,38 @@ test("release announcements stay unchanged after visits and reloads without read
await page.getByRole("link", { name: /New release.*Read release notes/ }).waitFor();
assert.deepEqual(await page.evaluate(() => window.releaseStorageAccesses), []);
});

test("the latest stable English page drives the version and approved summary", () => {
const page = (version, extras = {}) => ({ locale: "en", slug: `releases/${version}`, summary: `Summary ${version}`, ...extras });
const latest = page("2099.10.1");
const pages = [page("2099.9.9"), page("2100.1.1-beta.1"), latest,
page("2101.1.1", { hidden: true }), page("2102.1.1", { locale: "fr" }),
page("2103.1.1", { meta: { status: "draft" } }), page("2104.1.1", { meta: { beta: true } }),
page("2099.9.20"), page("index")];
assert.deepEqual(latestReleaseAnnouncement(pages), { page: latest, version: "2099.10.1", summary: latest.summary });
assert.equal(latestReleaseAnnouncement([page("2100.1.1-beta.1")]), null);
});

test("syncing a newer release updates the home strip and sidebar without a code edit", t => {
const f = releaseFixture(t);
const newerVersion = "2099.10.1";
const newerRoute = `releases/${newerVersion}`;
write(f.root, `docs/${newerRoute}.md`, `---\nsummary: The approved newer summary.\n---\n# v${newerVersion}\n`);
write(f.root, "docs/releases/2100.1.1.md", "---\nhidden: true\n---\n# Hidden release\n");
write(f.root, "docs/releases/2101.1.1.md", "---\nstatus: draft\n---\n# Draft release\n");
write(f.root, "docs/releases/2102.1.1.md", "---\nbeta: true\n---\n# Beta release\n");
const config = JSON.parse(fs.readFileSync(path.join(f.root, "docs/docs.json"), "utf8"));
config.navigation.languages[0].tabs[1].groups[0].pages.unshift(newerRoute);
write(f.root, "docs/docs.json", JSON.stringify(config));
const rebuilt = f.build();
assert.equal(rebuilt.status, 0, rebuilt.stderr);
const html = fs.readFileSync(path.join(f.site, "index.html"), "utf8");
const strip = all(html, node => node.attribs.class === "release-strip")[0];
assert.equal(strip.attribs.href, `${base}/${newerRoute}`);
assert.match(DomUtils.textContent(strip), /v2099\.10\.1.*The approved newer summary\./);
const badge = all(html, node => node.attribs.class === "release-nav-version")[0];
assert.match(DomUtils.textContent(badge), /v2099\.10\.1/);
const latestLinks = all(html, node => node.name === "a" && node.attribs.class?.startsWith("nav-link")
&& DomUtils.textContent(node).includes("Latest"));
assert.deepEqual(latestLinks.map(node => node.attribs.href), [`${base}/${newerRoute}`]);
});
Loading