From d07474b638269a78caf165c54de5f05e33673f74 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Sun, 31 May 2026 16:13:18 -0300 Subject: [PATCH 1/2] chore(docs): add lightweight preview build --- Makefile | 5 ++- package.json | 1 + scripts/docs-site/build.mjs | 65 +++++++++++++++++++++++++++++++++---- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 75c140f2fe..c3f8da919b 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ DOCS_HOST ?= 127.0.0.1 DOCS_PORT ?= 4173 DOCS_URL := http://$(DOCS_HOST):$(DOCS_PORT) -.PHONY: docs-build docs-build-shell docs-smoke docs-smoke-shell docs-check docs-check-shell docs-serve docs-elements docs-elements-open docs-health +.PHONY: docs-build docs-build-shell docs-build-preview docs-smoke docs-smoke-shell docs-check docs-check-shell docs-serve docs-elements docs-elements-open docs-health docs-build: npm run docs:build:r2 @@ -12,6 +12,9 @@ docs-build: docs-build-shell: npm run docs:build:r2:shell +docs-build-preview: + npm run docs:build:preview + docs-smoke: npm run docs:smoke diff --git a/package.json b/package.json index f6d00afc99..02e852b27c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "docs:build": "node scripts/docs-site/build.mjs && node scripts/docs-site/search-index.mjs && node scripts/docs-site/source-index.mjs && pagefind --site dist/docs-site --output-path dist/docs-site/pagefind && node scripts/docs-site/pagefind-normalize.mjs", "docs:build:shell": "DOCS_SITE_ARTIFACT_MODE=shell node scripts/docs-site/build.mjs", + "docs:build:preview": "DOCS_SITE_ARTIFACT_MODE=shell DOCS_SITE_PREVIEW_LOCALE=en DOCS_SITE_PREVIEW_PAGES_PER_GROUP=2 DOCS_SITE_PREVIEW_MAX_PAGES=30 node scripts/docs-site/build.mjs", "docs:build:cloudflare": "npm run docs:build && node scripts/docs-site/cloudflare-prune.mjs", "docs:build:r2": "npm run docs:build && node scripts/docs-site/r2-prepare.mjs", "docs:build:r2:shell": "npm run docs:build:shell && node scripts/docs-site/r2-prepare.mjs", diff --git a/scripts/docs-site/build.mjs b/scripts/docs-site/build.mjs index d90231ea3c..0e9002a9db 100644 --- a/scripts/docs-site/build.mjs +++ b/scripts/docs-site/build.mjs @@ -37,6 +37,14 @@ const defaultShellAssetVersion = createHash("sha256") const shellAssetVersion = process.env.DOCS_SITE_SHELL_ASSET_VERSION ?? defaultShellAssetVersion; const artifactMode = process.env.DOCS_SITE_ARTIFACT_MODE ?? "full"; const shellOnly = artifactMode === "shell"; +const previewPagesPerGroup = parseOptionalPositiveInt( + process.env.DOCS_SITE_PREVIEW_PAGES_PER_GROUP, + "DOCS_SITE_PREVIEW_PAGES_PER_GROUP", +); +const previewMaxPages = parseOptionalPositiveInt(process.env.DOCS_SITE_PREVIEW_MAX_PAGES, "DOCS_SITE_PREVIEW_MAX_PAGES"); +const previewLocale = process.env.DOCS_SITE_PREVIEW_LOCALE; +const previewMode = Boolean(previewPagesPerGroup || previewMaxPages || previewLocale); +const includeElementsFixture = !previewMode || process.env.DOCS_SITE_PREVIEW_INCLUDE_FIXTURE === "1"; if (!["full", "shell"].includes(artifactMode)) { throw new Error(`DOCS_SITE_ARTIFACT_MODE must be full or shell, got ${artifactMode}`); } @@ -44,9 +52,20 @@ fs.rmSync(outDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 fs.mkdirSync(outDir, { recursive: true }); const locales = buildLocales(config); -const pages = [...collectPages(locales), elementsFixturePage()]; -const pageByKey = new Map(pages.map((page) => [pageKey(page.locale, page.slug), page])); -const navByLocale = new Map(locales.map((locale) => [locale.code, buildNav(locale)])); +const allPages = [...collectPages(locales), ...(includeElementsFixture ? [elementsFixturePage()] : [])]; +let pages = allPages; +let pageByKey = new Map(pages.map((page) => [pageKey(page.locale, page.slug), page])); +let navByLocale = new Map(locales.map((locale) => [locale.code, buildNav(locale)])); +if (previewMode) { + const previewKeys = collectPreviewPageKeys(navByLocale, { + locale: previewLocale, + maxPages: previewMaxPages, + pagesPerGroup: previewPagesPerGroup || 1, + }); + pages = allPages.filter((page) => page.hidden || previewKeys.has(pageKey(page.locale, page.slug))); + pageByKey = new Map(pages.map((page) => [pageKey(page.locale, page.slug), page])); + navByLocale = new Map(locales.map((locale) => [locale.code, buildNav(locale)])); +} const localeFlags = { en: "πŸ‡ΊπŸ‡Έ", "zh-CN": "πŸ‡¨πŸ‡³", @@ -73,16 +92,19 @@ const localePickerLabels = { }; copyPublicFiles(); -await renderPageOgCards(); +if (!shellOnly) await renderPageOgCards(); for (const page of pages) writePage(page); if (!shellOnly) { writeLlmsIndex(); writeRobotsTxt(); writeSitemap(); } -writeRedirects(); +if (!previewMode) writeRedirects(); writeStaticAssets(); -console.log(`built ${pages.length} pages in ${path.relative(root, outDir)} (${artifactMode})`); +const previewLabel = previewMode + ? `, preview ${previewPagesPerGroup || 1}/group${previewMaxPages ? ` max ${previewMaxPages}` : ""}${previewLocale ? ` ${previewLocale}` : ""}` + : ""; +console.log(`built ${pages.length} pages in ${path.relative(root, outDir)} (${artifactMode}${previewLabel})`); function buildLocales(docsConfig) { const ordered = []; @@ -99,6 +121,15 @@ function buildLocales(docsConfig) { return ordered.filter((locale) => locale.root || fs.existsSync(path.join(docsDir, locale.code))); } +function parseOptionalPositiveInt(value, name) { + if (value === undefined || value === "") return 0; + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 1 || String(parsed) !== String(value).trim()) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + function collectPages(localeList) { const result = []; for (const locale of localeList) { @@ -184,6 +215,22 @@ function navGroup(locale, group) { return pages.length ? { title: group.group ?? "Docs", pages } : null; } +function collectPreviewPageKeys(navByLocale, { locale, maxPages, pagesPerGroup }) { + const keys = new Set(); + for (const [navLocale, nav] of navByLocale) { + if (locale && navLocale !== locale) continue; + for (const tab of nav) { + for (const group of tab.groups) { + for (const page of flattenNavEntries(group.pages).slice(0, pagesPerGroup)) { + keys.add(pageKey(page.locale, page.slug)); + if (maxPages && keys.size >= maxPages) return keys; + } + } + } + } + return keys; +} + function flattenPages(locale, entries) { const output = []; for (const entry of entries) { @@ -729,8 +776,12 @@ function groupForPage(nav, slug) { } } +function flattenNavEntries(entries) { + return entries.flatMap((entry) => entry.group ? flattenNavEntries(entry.pages) : [entry]); +} + function flattenNav(nav) { - return nav.flatMap((tab) => tab.groups.flatMap((group) => group.pages.flatMap((entry) => entry.group ? entry.pages : [entry]))); + return nav.flatMap((tab) => tab.groups.flatMap((group) => flattenNavEntries(group.pages))); } function firstPage(tab) { From c2bd53617a2986140934e9afdd487ea701e9b4fa Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 00:50:27 +0100 Subject: [PATCH 2/2] fix(devx): keep shell OG cards outside preview builds --- scripts/docs-site/build.mjs | 30 ++++++--- scripts/docs-site/edit-source.mjs | 80 ++++++++++++++++++++++ scripts/docs-site/smoke.mjs | 107 ++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 scripts/docs-site/edit-source.mjs diff --git a/scripts/docs-site/build.mjs b/scripts/docs-site/build.mjs index 0e9002a9db..22a0f1acdd 100644 --- a/scripts/docs-site/build.mjs +++ b/scripts/docs-site/build.mjs @@ -8,6 +8,7 @@ import matter from "gray-matter"; import { ignoredDocDirs, ignoredDocFiles, localeLabels, mintlifyLocaleToDir, rtlLocales } from "./config.mjs"; import { siteCss, siteJs } from "./assets.mjs"; import { createMarkdownRenderer, renderMdxish } from "./mdx-ish.mjs"; +import { editSourceUrlForPage, frontmatterSourcePath, readSourceMetadata } from "./edit-source.mjs"; import { elementsFixture } from "./elements-fixture.mjs"; import { renderPageOgSvg } from "./og-card-template.mjs"; @@ -16,6 +17,7 @@ const docsDir = path.join(root, "docs"); const siteAssetsDir = path.join(root, "scripts", "docs-site"); const outDir = path.join(root, "dist", "docs-site"); const config = JSON.parse(fs.readFileSync(path.join(docsDir, "docs.json"), "utf8")); +const sourceMetadata = readSourceMetadata(root); const md = createMarkdownRenderer(); const basePath = normalizeBasePath(process.env.DOCS_SITE_BASE_PATH ?? ""); const legacyBasePath = normalizeBasePath(process.env.DOCS_SITE_LEGACY_BASE_PATH ?? "/docs"); @@ -53,6 +55,7 @@ fs.mkdirSync(outDir, { recursive: true }); const locales = buildLocales(config); const allPages = [...collectPages(locales), ...(includeElementsFixture ? [elementsFixturePage()] : [])]; +const allPageByKey = new Map(allPages.map((page) => [pageKey(page.locale, page.slug), page])); let pages = allPages; let pageByKey = new Map(pages.map((page) => [pageKey(page.locale, page.slug), page])); let navByLocale = new Map(locales.map((locale) => [locale.code, buildNav(locale)])); @@ -92,7 +95,7 @@ const localePickerLabels = { }; copyPublicFiles(); -if (!shellOnly) await renderPageOgCards(); +if (!previewMode) await renderPageOgCards(); for (const page of pages) writePage(page); if (!shellOnly) { writeLlmsIndex(); @@ -147,6 +150,7 @@ function collectPages(localeList) { slug, file, rel, + sourcePath: frontmatterSourcePath(parsed.data), raw, title, summary: parsed.data.summary ?? "", @@ -368,7 +372,10 @@ function languagePicker(page) { const current = locales.find((locale) => locale.code === page.locale) ?? locales[0]; const currentLabel = localeDisplayName(current.code); const currentFlag = localeFlag(current.code); - const options = locales.map((locale) => { + const pickerLocales = previewMode + ? locales.filter((locale) => locale.code === page.locale || pageByKey.has(pageKey(locale.code, page.slug))) + : locales; + const options = pickerLocales.map((locale) => { const active = locale.code === page.locale; return `${escapeHtml(localeDisplayName(locale.code))}`; }).join(""); @@ -407,8 +414,9 @@ function breadcrumbs(page, nav) { function pageTools(page) { const canonicalUrl = `${docsOrigin()}${pageRoute(page)}`; - const editUrl = `https://github.com/openclaw/openclaw/edit/main/docs/${page.rel}`; - return `
Edit source
`; + const editUrl = editSourceUrlForPage(page, sourceMetadata); + const editLink = editUrl ? `Edit source` : ""; + return `
${editLink}
`; } function pageStatus(page) { @@ -795,6 +803,10 @@ function localeUrlForSlug(locale, slug) { return pageByKey.has(pageKey(locale, slug)) ? pageUrl(pageByKey.get(pageKey(locale, slug))) : publicPath(locale === "en" ? "/" : `/${locale}/`); } +function internalPageUrl(page) { + return pageByKey.has(pageKey(page.locale, page.slug)) ? pageUrl(page) : `${docsOrigin()}${pageRoute(page)}`; +} + function pageUrl(page) { return publicPath(pageRoute(page)); } @@ -817,12 +829,14 @@ function rewriteInternalUrls(html, locale) { } const segments = target.replace(/\/$/, "").split("/"); const maybeLocale = segments[0]; - if (pageByKey.has(pageKey(maybeLocale, normalizeSlug(segments.slice(1).join("/") || "index")))) { - return `${attr}="${pageUrl(pageByKey.get(pageKey(maybeLocale, normalizeSlug(segments.slice(1).join("/") || "index"))))}${suffix}"`; + const localizedSlug = normalizeSlug(segments.slice(1).join("/") || "index"); + const localizedPage = allPageByKey.get(pageKey(maybeLocale, localizedSlug)); + if (localizedPage) { + return `${attr}="${internalPageUrl(localizedPage)}${suffix}"`; } const slug = normalizeSlug(target.replace(/\/$/, "")); - const page = pageByKey.get(pageKey(locale, slug)) ?? pageByKey.get(pageKey("en", slug)); - return page ? `${attr}="${pageUrl(page)}${suffix}"` : `${attr}="${publicPath(`/${target}`)}${suffix}"`; + const page = allPageByKey.get(pageKey(locale, slug)) ?? allPageByKey.get(pageKey("en", slug)); + return page ? `${attr}="${internalPageUrl(page)}${suffix}"` : `${attr}="${publicPath(`/${target}`)}${suffix}"`; }); } diff --git a/scripts/docs-site/edit-source.mjs b/scripts/docs-site/edit-source.mjs new file mode 100644 index 0000000000..c97db016e4 --- /dev/null +++ b/scripts/docs-site/edit-source.mjs @@ -0,0 +1,80 @@ +import fs from "node:fs"; +import path from "node:path"; + +const defaultSourceRepositories = { + openclaw: "openclaw/openclaw", + clawhub: "openclaw/clawhub", +}; + +export function readSourceMetadata(root) { + try { + return JSON.parse(fs.readFileSync(path.join(root, ".openclaw-sync", "source.json"), "utf8")); + } catch { + return {}; + } +} + +export function frontmatterSourcePath(data) { + const sourcePath = data?.["x-i18n"]?.source_path; + return typeof sourcePath === "string" ? normalizeDocRel(sourcePath) : ""; +} + +export function editSourceUrlForPage(page, sourceMetadata = {}) { + const target = editSourceTargetForPage(page, sourceMetadata); + if (!target) return ""; + return `https://github.com/${target.repository}/edit/main/${encodePath(target.path)}`; +} + +export function editSourceTargetForPage(page, sourceMetadata = {}) { + if (page?.hidden) return null; + + const rel = normalizeDocRel(page?.sourcePath || page?.rel || ""); + if (!rel || rel.startsWith(".") || rel.includes("\0")) return null; + + if (rel === "clawhub/index.md" || rel.startsWith("clawhub/")) { + const sourcePath = clawHubSourcePath(rel); + if (!sourcePath) return null; + return { + owner: "clawhub", + repository: sourceRepository(sourceMetadata, "clawhub"), + path: `docs/${sourcePath}`, + }; + } + + return { + owner: "openclaw", + repository: sourceRepository(sourceMetadata, "openclaw"), + path: `docs/${rel}`, + }; +} + +function sourceRepository(sourceMetadata, source) { + return normalizeRepositorySlug( + sourceMetadata?.sources?.[source]?.repository || (source === "openclaw" ? sourceMetadata?.repository : ""), + defaultSourceRepositories[source], + ); +} + +function clawHubSourcePath(rel) { + const inner = rel.slice("clawhub/".length); + if (!inner) return ""; + if (/^index\.mdx?$/iu.test(inner)) return "clawhub.md"; + return inner.replace(/\/index\.mdx?$/iu, "/README.md"); +} + +function normalizeDocRel(value) { + const normalized = String(value).replaceAll("\\", "/").replace(/^\/+/, ""); + const collapsed = path.posix.normalize(normalized); + return collapsed === "." || collapsed.startsWith("../") ? "" : collapsed; +} + +function normalizeRepositorySlug(value, fallback) { + const raw = String(value || "").trim().replace(/\.git$/u, "").replace(/\/$/u, ""); + const githubMatch = raw.match(/github\.com[:/]([^/\s]+\/[^/\s]+)$/u); + const slug = githubMatch?.[1] ?? raw; + return /^[^/\s]+\/[^/\s]+$/u.test(slug) ? slug : fallback; +} + +function encodePath(value) { + return String(value).split("/").map(encodeURIComponent).join("/"); +} diff --git a/scripts/docs-site/smoke.mjs b/scripts/docs-site/smoke.mjs index 249e61957d..b24474c18b 100644 --- a/scripts/docs-site/smoke.mjs +++ b/scripts/docs-site/smoke.mjs @@ -1,9 +1,15 @@ #!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; +import matter from "gray-matter"; + +import { localeLabels } from "./config.mjs"; +import { editSourceUrlForPage, frontmatterSourcePath, readSourceMetadata } from "./edit-source.mjs"; const root = process.cwd(); const site = path.join(root, "dist", "docs-site"); +const docsDir = path.join(root, "docs"); +const sourceMetadata = readSourceMetadata(root); const expectedOrigin = (process.env.DOCS_SITE_CANONICAL_ORIGIN ?? (process.env.DOCS_SITE_CNAME ? `https://${process.env.DOCS_SITE_CNAME}` : "https://docs.openclaw.ai")) .replace(/\/$/, ""); @@ -432,4 +438,105 @@ const showcase = fs.readFileSync(path.join(site, "start/showcase/index.html"), " if (!/href="https:\/\/x\.com\/i\/status\/2010878524543131691"/.test(showcase)) { throw new Error("showcase: external card href was not rendered"); } +assertEditSourceLinks(); console.log(`docs site smoke ok: shell, routing, skin, and hidden fixture checks passed (${artifactMode})`); + +function assertEditSourceLinks() { + const htmlFiles = walkHtml(site); + let checked = 0; + let missingEdit = 0; + for (const file of htmlFiles) { + const rel = path.relative(site, file).replaceAll(path.sep, "/"); + const html = fs.readFileSync(file, "utf8"); + if (rel === "__elements/index.html") { + if (/data-page-tools|Edit source/.test(html)) { + throw new Error("__elements: hidden component fixture should not expose page tools"); + } + continue; + } + + const tools = html.match(/
/u)?.[0]; + if (!tools) continue; + checked += 1; + + const page = pageForRenderedHtml(rel); + if (!page) { + if (/Edit source/.test(tools)) throw new Error(`${rel}: edit source link has no canonical source page`); + missingEdit += 1; + continue; + } + + const expected = editSourceUrlForPage(page, sourceMetadata); + const actual = tools.match(/Edit source<\/a>/u)?.[1] ?? ""; + if (!expected) { + if (actual) throw new Error(`${rel}: unexpected edit source link ${actual}`); + missingEdit += 1; + continue; + } + if (actual !== expected) { + throw new Error(`${rel}: edit source link ${actual || "(missing)"} should be ${expected}`); + } + } + + if (checked < 100) { + throw new Error(`edit source audit: expected many rendered page tools, checked ${checked}`); + } + + assertEditSourceSample("clawhub/index.html", "https://github.com/openclaw/clawhub/edit/main/docs/clawhub.md"); + assertEditSourceSample("clawhub/publishing/index.html", "https://github.com/openclaw/clawhub/edit/main/docs/publishing.md"); + assertEditSourceSample("ar/clawhub/publishing/index.html", "https://github.com/openclaw/clawhub/edit/main/docs/publishing.md"); + assertEditSourceSample("de/channels/index.html", "https://github.com/openclaw/openclaw/edit/main/docs/channels/index.md"); + if (missingEdit > 0) { + console.log(`edit source audit: ${missingEdit} page tool(s) intentionally have no edit source link`); + } +} + +function assertEditSourceSample(rel, expected) { + const file = path.join(site, rel); + if (!fs.existsSync(file)) { + throw new Error(`edit source audit: missing sample ${rel}`); + } + const html = fs.readFileSync(file, "utf8"); + if (!html.includes(`href="${expected}"`)) { + throw new Error(`${rel}: expected edit source link ${expected}`); + } +} + +function pageForRenderedHtml(htmlRel) { + const segments = htmlRel.split("/"); + if (segments.at(-1) !== "index.html") return null; + segments.pop(); + + const locale = localeLabels[segments[0]] ? segments.shift() : "en"; + const route = segments.join("/"); + const base = locale === "en" ? docsDir : path.join(docsDir, locale); + const sourceFile = findSourcePageFile(base, route); + if (!sourceFile) return null; + + const raw = fs.readFileSync(sourceFile, "utf8"); + const parsed = matter(raw); + return { + rel: path.relative(base, sourceFile).replaceAll(path.sep, "/"), + sourcePath: frontmatterSourcePath(parsed.data), + }; +} + +function findSourcePageFile(base, route) { + const candidates = route + ? [`${route}/index.md`, `${route}/index.mdx`, `${route}.md`, `${route}.mdx`] + : ["index.md", "index.mdx"]; + for (const candidate of candidates) { + const file = path.join(base, candidate); + if (fs.existsSync(file)) return file; + } + return ""; +} + +function walkHtml(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return walkHtml(full); + return entry.isFile() && entry.name.endsWith(".html") ? [full] : []; + }); +}