diff --git a/.github/scripts/i18n/apply_artifacts.py b/.github/scripts/i18n/apply_artifacts.py index ee49b6650b..ca5f3a1d7d 100644 --- a/.github/scripts/i18n/apply_artifacts.py +++ b/.github/scripts/i18n/apply_artifacts.py @@ -41,7 +41,7 @@ DEFAULT_EXPECTED_LOCALES = ( "zh-cn=zh-CN zh-tw=zh-TW ja-jp=ja-JP es=es pt-br=pt-BR ko=ko de=de fr=fr " - "hi=hi ar=ar it=it vi=vi nl=nl fa=fa ru=ru tr=tr uk=uk id=id pl=pl th=th" + "hi=hi ar=ar it=it vi=vi nl=nl sv=sv fa=fa ru=ru tr=tr uk=uk id=id pl=pl th=th" ) SOURCE_HASH_RE = re.compile(r"^x-i18n:\n(?:[ \t]+.*\n)*?[ \t]+source_hash: ([0-9a-f]{64})$", re.M) diff --git a/.github/scripts/i18n/build_pending_manifest.py b/.github/scripts/i18n/build_pending_manifest.py index e705255dfe..ed2139969a 100644 --- a/.github/scripts/i18n/build_pending_manifest.py +++ b/.github/scripts/i18n/build_pending_manifest.py @@ -14,7 +14,7 @@ Environment: LOCALE, LOCALE_SLUG, SHARD_INDEX, SHARD_TOTAL, optional FORCE_RETRANSLATE, - PENDING_LIMIT, CANARY_SOURCE_PATH, and GITHUB_OUTPUT. + PENDING_LIMIT, CANARY_SOURCE_PATH, CANARY_PUBLISH_REQUIRED, and GITHUB_OUTPUT. Outputs: Writes docs-i18n--sof.txt under --openclaw-sync-dir. @@ -39,6 +39,7 @@ SOURCE_HASH_RE = re.compile(r"^x-i18n:\n(?:[ \t]+.*\n)*?[ \t]+source_hash: ([0-9a-f]{64})$", re.M) MODEL_METADATA_RE = re.compile(r"^x-i18n:\n(?:[ \t]+.*\n)*?[ \t]+(?:model|provider):", re.M) +LOCALE_ENTRY_SOURCES = ("index.md", "start/getting-started.md") @dataclass(frozen=True) @@ -48,6 +49,7 @@ class PendingResult: pending_count: int pending_path: Path shard_files: list[Path] + canary_bootstrap: bool = False def translation_matches_source(path: Path, source_hash: str) -> bool: @@ -104,6 +106,7 @@ def build_pending_manifest( pending_limit: int = 0, canary_source_path: str = "", force_retranslate: bool = False, + canary_publish_required: bool = False, ) -> PendingResult: locale_dirs = {path.name for path in docs_root.iterdir() if is_locale_dir(path)} pending_path = openclaw_sync_dir / f"docs-i18n-{locale_slug}-s{shard_index}of{shard_total}.txt" @@ -131,10 +134,20 @@ def build_pending_manifest( pending_files = sorted(pending_files) shard_files = [file for index, file in enumerate(pending_files) if index % shard_total == shard_index] + canary_bootstrap = False if pending_limit: if canary_source_path: canary_sources: list[Path] = [] - for source_path in parse_canary_source_paths(canary_source_path): + source_paths = parse_canary_source_paths(canary_source_path) + if canary_publish_required: + # Publishing a locale's first page activates it in the renderer. + # Include missing entry pages so its homepage and smoke sample work. + for entry in LOCALE_ENTRY_SOURCES: + if not (docs_root / locale / entry).is_file(): + canary_bootstrap = True + if entry not in source_paths: + source_paths.append(entry) + for source_path in source_paths: canary_source = (docs_root / source_path).resolve() try: canary_source.relative_to(docs_root.resolve()) @@ -145,8 +158,8 @@ def build_pending_manifest( if canary_source in canary_sources: raise SystemExit("configured canary sources must resolve to unique paths") canary_sources.append(canary_source) - # A diagnostic canary may exercise a bounded explicit page set in - # one non-publishing job; every path still passes the normal gates. + # Explicit diagnostics and first-locale publication may sample a + # bounded page set; every path still passes the normal hash gates. shard_files = canary_sources else: # Routine canaries probe pending pages without publishing. Choose the @@ -165,6 +178,7 @@ def build_pending_manifest( pending_count=len(shard_files), pending_path=pending_path, shard_files=shard_files, + canary_bootstrap=canary_bootstrap, ) @@ -176,6 +190,7 @@ def append_output(result: PendingResult) -> None: fh.write(f"all_count={result.all_count}\n") fh.write(f"total_pending_count={result.total_pending_count}\n") fh.write(f"pending_count={result.pending_count}\n") + fh.write(f"canary_bootstrap={str(result.canary_bootstrap).lower()}\n") def parse_args() -> argparse.Namespace: @@ -209,6 +224,7 @@ def main() -> None: pending_limit=pending_limit, canary_source_path=os.environ.get("CANARY_SOURCE_PATH", ""), force_retranslate=os.environ.get("FORCE_RETRANSLATE", "false") == "true", + canary_publish_required=os.environ.get("CANARY_PUBLISH_REQUIRED", "false") == "true", ) append_output(result) diff --git a/.github/scripts/i18n/prepare.py b/.github/scripts/i18n/prepare.py index aeceb0ae64..c40f903572 100644 --- a/.github/scripts/i18n/prepare.py +++ b/.github/scripts/i18n/prepare.py @@ -58,6 +58,7 @@ "pl", "pt-BR", "ru", + "sv", "th", "tr", "uk", diff --git a/.github/scripts/i18n/tests/test_i18n_scripts.py b/.github/scripts/i18n/tests/test_i18n_scripts.py index 9d4803ff6d..f6adfdd869 100644 --- a/.github/scripts/i18n/tests/test_i18n_scripts.py +++ b/.github/scripts/i18n/tests/test_i18n_scripts.py @@ -247,6 +247,11 @@ def test_full_workflow_keeps_only_weekly_and_manual_triggers(self) -> None: self.assertIn("schedule:", text) self.assertIn("workflow_dispatch:", text) self.assertIn("target_locale:", text) + options = re.search(r"target_locale:.*?options:\n((?: - [^\n]*\n)+)", text, re.S).group(1) + self.assertEqual( + {"all", *(locale.locale_slug for locale in translation_plan.all_locales())}, + set(re.findall(r"- (\S+)", options)), + ) self.assertIn("resume_run_id:", text) self.assertIn("canary_only:", text) self.assertRegex(text, r"force_retranslate:[\s\S]*?default: false") @@ -298,7 +303,11 @@ def test_full_workflow_gates_batches_after_canary(self) -> None: reusable, ) self.assertNotIn("inputs.artifact_role == 'canary' || steps.apply.outputs.changed_count != '0'", reusable) - self.assertIn("inputs.artifact_role != 'canary' && steps.apply.outputs.changed_count != '0'", reusable) + self.assertEqual(5, reusable.count("(inputs.artifact_role != 'canary' || needs.translate.outputs.canary_bootstrap == 'true') && steps.apply.outputs.changed_count != '0'")) + self.assertIn("canary_bootstrap: ${{ steps.pending.outputs.canary_bootstrap }}", reusable) + self.assertIn("CANARY_PUBLISH_REQUIRED: ${{ inputs.canary_publish_required }}", reusable) + self.assertIn("if: steps.pending.outputs.canary_bootstrap == 'true' && github.ref != 'refs/heads/main'", reusable) + self.assertLess(reusable.index("Require main for locale bootstrap publication"), reusable.index("Translate changed docs into locale")) self.assertIn("inputs.commit_locale && steps.apply.outputs.changed_count != '0'", reusable) self.assertIn("Fail uncommitted locale refresh", reusable) self.assertIn( @@ -578,9 +587,13 @@ def test_translation_workflows_pin_latest_codex_and_tier_effort(self) -> None: self.assertNotIn('thinking_effort: "max"', incremental) def test_prepare_path_selection_matches_incremental_rules(self) -> None: + self.assertEqual(translation_plan.LOCALE_NAMES, prepare.LOCALES) self.assertTrue(prepare.is_translatable_doc_path("docs/guide/setup.mdx")) self.assertTrue(prepare.is_translatable_doc_path("docs/reference/test.md")) self.assertFalse(prepare.is_translatable_doc_path("docs/fr/guide/setup.mdx")) + for locale in translation_plan.all_locales(): + with self.subTest(locale=locale.locale): + self.assertFalse(prepare.is_translatable_doc_path(f"docs/{locale.locale}/guide/setup.mdx")) self.assertFalse(prepare.is_translatable_doc_path("docs/.i18n/glossary.fr.json")) self.assertFalse(prepare.is_translatable_doc_path("docs/.generated/api.md")) self.assertEqual("3600", prepare.default_cooldown("incremental", "push", "", "3600")) @@ -905,11 +918,11 @@ def test_supported_locale_dirs_are_never_source_docs_without_markers(self) -> No def test_full_plan_all_uses_canary_and_small_batches(self) -> None: result = plan_full.plan_full("all", 4, FIXTURES / "pending-docs" / "docs") self.assertEqual("es", result["canary"]["locale"]) - self.assertEqual(5, len(result["batches"])) + self.assertEqual(6, len(result["batches"])) self.assertEqual(1, result["shard_total"]) - self.assertEqual(20, len(result["expected_locales"].split())) + self.assertEqual(21, len(result["expected_locales"].split())) self.assertLessEqual(max(len(batch) for batch in result["batches"]), 4) - self.assertEqual(20, sum(len(batch) for batch in result["batches"])) + self.assertEqual(21, sum(len(batch) for batch in result["batches"])) def test_translation_plan_shared_shard_policy(self) -> None: self.assertEqual(1, translation_plan.shard_total_for_doc_count(0, 250, 4)) @@ -1111,10 +1124,10 @@ def test_full_plan_excludes_supported_locale_dirs_without_marker(self) -> None: def test_incremental_plan_reuses_shared_locale_and_shard_policy(self) -> None: result = plan_incremental.plan_incremental(FIXTURES / "pending-docs" / "docs", target_docs_per_shard=1, max_shards=4) - self.assertEqual(20, result["locale_count"]) + self.assertEqual(21, result["locale_count"]) self.assertEqual(2, result["source_doc_count"]) self.assertEqual(2, result["shard_total"]) - self.assertEqual(40, len(result["matrix"]["include"])) + self.assertEqual(42, len(result["matrix"]["include"])) self.assertEqual( [ {"locale": "es", "locale_slug": "es", "shard_index": "0", "shard_total": "2"}, @@ -1139,6 +1152,7 @@ def test_incremental_plan_reuses_shared_locale_and_shard_policy(self) -> None: "vi", "pl", "nl", + "sv", "uk", "th", "ar", @@ -1162,12 +1176,14 @@ def test_incremental_plan_excludes_supported_locale_dirs_without_marker(self) -> self.assertEqual(1, result["source_doc_count"]) self.assertEqual(1, result["shard_total"]) - self.assertEqual(20, len(result["matrix"]["include"])) + self.assertEqual(21, len(result["matrix"]["include"])) def test_full_plan_manual_single_locale_only_selects_target(self) -> None: - result = plan_full.plan_full("fr", 3, FIXTURES / "pending-docs" / "docs") - self.assertEqual({"locale": "fr", "locale_slug": "fr"}, result["canary"]) - self.assertEqual([[{"locale": "fr", "locale_slug": "fr", "shard_index": "0", "shard_total": "1"}]], result["batches"]) + for locale in ("fr", "sv"): + with self.subTest(locale=locale): + result = plan_full.plan_full(locale, 3, FIXTURES / "pending-docs" / "docs") + self.assertEqual({"locale": locale, "locale_slug": locale}, result["canary"]) + self.assertEqual([[{"locale": locale, "locale_slug": locale, "shard_index": "0", "shard_total": "1"}]], result["batches"]) with self.assertRaises(SystemExit): plan_full.plan_full("xx", 3, FIXTURES / "pending-docs" / "docs") @@ -1456,6 +1472,81 @@ def test_pending_manifest_canary_supports_multiple_configured_source_pages(self) [file.relative_to((tmp_path / "docs").resolve()).as_posix() for file in result.shard_files], ) + def test_publishing_canary_bootstraps_only_missing_locale_entry_pages(self) -> None: + for present, expected, bootstrap in ( + ((), ["channels/line.md", "index.md", "start/getting-started.md"], True), + (("index.md",), ["channels/line.md", "start/getting-started.md"], True), + (("index.md", "start/getting-started.md"), ["channels/line.md"], False), + ): + with self.subTest(present=present), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp).resolve() + docs = root / "docs" + for relative in ("channels/line.md", *pending.LOCALE_ENTRY_SOURCES, "unrelated.md"): + source = docs / relative + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text(f"# {relative}\n") + for relative in present: + translated = docs / "sv" / relative + translated.parent.mkdir(parents=True, exist_ok=True) + translated.write_text(self._translated_page(docs / relative, "# Befintlig sida\n")) + result = pending.build_pending_manifest( + docs, root / ".openclaw-sync", "sv", "sv", 0, 1, + pending_limit=1, canary_source_path="channels/line.md", + force_retranslate=True, canary_publish_required=True, + ) + self.assertEqual(expected, [file.relative_to(docs).as_posix() for file in result.shard_files]) + self.assertEqual(bootstrap, result.canary_bootstrap) + self.assertEqual(4, result.total_pending_count) + output = root / "output" + with env({"GITHUB_OUTPUT": str(output)}): + pending.append_output(result) + self.assertIn(f"canary_bootstrap={str(bootstrap).lower()}\n", output.read_text()) + allowed = package_artifact.read_pending_allowed(root, "sv", "sv", 0, 1) + self.assertEqual({f"docs/sv/{page}" for page in expected} | {"docs/.i18n/sv.tm.jsonl"}, allowed) + for source in result.shard_files: + relative = source.relative_to(docs) + translated = root / "translated.md" + translated.write_text(self._translated_page(source, "# Svensk sida\n")) + with chdir(root): + self.assertEqual((True, ""), apply_artifacts.should_apply_changed(True, "sv", f"docs/sv/{relative.as_posix()}", translated)) + source.write_text(source.read_text() + "Changed source\n") + self.assertFalse(apply_artifacts.should_apply_changed(True, "sv", f"docs/sv/{relative.as_posix()}", translated)[0]) + + def test_publishing_canary_requires_bootstrap_sources_and_diagnostics_do_not_expand(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + docs = root / "docs" + (docs / "channels").mkdir(parents=True) + (docs / "channels/line.md").write_text("# LINE\n") + args = dict(docs_root=docs, openclaw_sync_dir=root / ".openclaw-sync", locale="sv", locale_slug="sv", shard_index=0, shard_total=1, pending_limit=1, canary_source_path="channels/line.md") + diagnostic = pending.build_pending_manifest(**args) + self.assertEqual(1, diagnostic.pending_count) + self.assertFalse(diagnostic.canary_bootstrap) + with self.assertRaisesRegex(SystemExit, "not pending in this shard: index.md"): + pending.build_pending_manifest(**args, canary_publish_required=True) + + def test_publishing_canary_deploy_scope_refreshes_new_locale_menus(self) -> None: + workflow = (REPO_ROOT / ".github/workflows/translate-locale-reusable.yml").read_text() + block = workflow.split(" - name: Dispatch locale docs deploy\n", 1)[1].split(" - name:", 1)[0] + script = "\n".join(line[10:] for line in block.split(" run: |\n", 1)[1].splitlines()) + values = {"github.ref_name": "main", "inputs.locale": "sv", "inputs.canary_live_path": "channels/line", "inputs.canary_publish_required": "true"} + script = workflow_shell_check.GITHUB_EXPRESSION_RE.sub(lambda match: values[match.group()[3:-2].strip()], script) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "python").symlink_to(sys.executable) + (root / "dispatch_r2_pages.py").write_text("import json, os, sys\nfrom pathlib import Path\nPath(os.environ['CAPTURE']).write_text(json.dumps(sys.argv[1:]))\n") + for bootstrap, scope in (("false", "page"), ("true", "full")): + with self.subTest(bootstrap=bootstrap): + process_env = {**os.environ, "PATH": f"{root}{os.pathsep}{os.environ['PATH']}", "I18N_SCRIPT_DIR": str(root), "CAPTURE": str(root / "args.json"), "CANARY_BOOTSTRAP": bootstrap, "ARTIFACT_ROLE": "canary", "CANARY_LIVE_URL": "https://docs.openclaw.ai/sv/channels/line", "CANARY_EXPECTED_H1": "LINE"} + result = subprocess.run(["/bin/bash", "-eu", "-c", script], env=process_env, text=True, capture_output=True) + self.assertEqual(0, result.returncode, result.stderr) + args = json.loads((root / "args.json").read_text()) + self.assertEqual(scope, args[args.index("--artifact-scope") + 1]) + self.assertEqual("sv", args[args.index("--locale") + 1]) + self.assertEqual(scope == "page", "--page-path" in args) + self.assertEqual("https://docs.openclaw.ai/sv/channels/line", args[args.index("--live-url") + 1]) + self.assertNotIn("--no-wait", args) + def test_pending_manifest_canary_rejects_duplicate_configured_source_pages(self) -> None: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) diff --git a/.github/scripts/i18n/translation_plan.py b/.github/scripts/i18n/translation_plan.py index 2bd379c86d..9f93b8d1b9 100644 --- a/.github/scripts/i18n/translation_plan.py +++ b/.github/scripts/i18n/translation_plan.py @@ -46,6 +46,7 @@ ("vi", "vi"), ("pl", "pl"), ("nl", "nl"), + ("sv", "sv"), ("uk", "uk"), ("th", "th"), ("ar", "ar"), diff --git a/.github/workflows/translate-all.yml b/.github/workflows/translate-all.yml index ff49ff32b3..8666c3c90c 100644 --- a/.github/workflows/translate-all.yml +++ b/.github/workflows/translate-all.yml @@ -30,6 +30,7 @@ on: - it - vi - nl + - sv - fa - ru - tr diff --git a/.github/workflows/translate-incremental.yml b/.github/workflows/translate-incremental.yml index 03c213caa4..95d6492569 100644 --- a/.github/workflows/translate-incremental.yml +++ b/.github/workflows/translate-incremental.yml @@ -20,6 +20,7 @@ on: - "!docs/pl/**" - "!docs/pt-BR/**" - "!docs/ru/**" + - "!docs/sv/**" - "!docs/th/**" - "!docs/tr/**" - "!docs/uk/**" diff --git a/.github/workflows/translate-locale-reusable.yml b/.github/workflows/translate-locale-reusable.yml index 07d10a0c29..8ddcd7efd3 100644 --- a/.github/workflows/translate-locale-reusable.yml +++ b/.github/workflows/translate-locale-reusable.yml @@ -80,6 +80,8 @@ jobs: name: Translate ${{ inputs.locale }} shard ${{ inputs.shard_index }}/${{ inputs.shard_total }} runs-on: ubuntu-latest timeout-minutes: 360 + outputs: + canary_bootstrap: ${{ steps.pending.outputs.canary_bootstrap }} permissions: contents: read concurrency: @@ -174,9 +176,16 @@ jobs: SHARD_TOTAL: ${{ inputs.shard_total }} PENDING_LIMIT: ${{ inputs.pending_limit }} CANARY_SOURCE_PATH: ${{ inputs.canary_source_path }} + CANARY_PUBLISH_REQUIRED: ${{ inputs.canary_publish_required }} run: | python "${I18N_SCRIPT_DIR}/build_pending_manifest.py" + - name: Require main for locale bootstrap publication + if: steps.pending.outputs.canary_bootstrap == 'true' && github.ref != 'refs/heads/main' + run: | + echo "Publish a new locale from main after its source and publisher support have landed. Use diagnostic_canary_only for branch previews." >&2 + exit 1 + - name: Translate changed docs into locale id: translate_docs if: steps.stale.outputs.skip != 'true' && steps.pending.outputs.pending_count != '0' @@ -448,26 +457,26 @@ jobs: --expected-locales "${EXPECTED_LOCALES}" - name: Set up Node for locale validation - if: inputs.artifact_role != 'canary' && steps.apply.outputs.changed_count != '0' + if: (inputs.artifact_role != 'canary' || needs.translate.outputs.canary_bootstrap == 'true') && steps.apply.outputs.changed_count != '0' uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 26 cache: npm - name: Install docs dependencies - if: inputs.artifact_role != 'canary' && steps.apply.outputs.changed_count != '0' + if: (inputs.artifact_role != 'canary' || needs.translate.outputs.canary_bootstrap == 'true') && steps.apply.outputs.changed_count != '0' run: npm ci - name: Install validation system dependencies - if: inputs.artifact_role != 'canary' && steps.apply.outputs.changed_count != '0' + if: (inputs.artifact_role != 'canary' || needs.translate.outputs.canary_bootstrap == 'true') && steps.apply.outputs.changed_count != '0' run: sudo apt-get update && sudo apt-get install -y librsvg2-bin - name: Install Playwright browser - if: inputs.artifact_role != 'canary' && steps.apply.outputs.changed_count != '0' + if: (inputs.artifact_role != 'canary' || needs.translate.outputs.canary_bootstrap == 'true') && steps.apply.outputs.changed_count != '0' run: npx playwright install --with-deps chromium - name: Check docs before artifact finalization - if: inputs.artifact_role != 'canary' && steps.apply.outputs.changed_count != '0' + if: (inputs.artifact_role != 'canary' || needs.translate.outputs.canary_bootstrap == 'true') && steps.apply.outputs.changed_count != '0' run: npm run docs:check - name: Commit locale refresh @@ -502,6 +511,7 @@ jobs: ARTIFACT_ROLE: ${{ inputs.artifact_role }} CANARY_LIVE_URL: https://docs.openclaw.ai/${{ inputs.locale }}/${{ inputs.canary_live_path }} CANARY_EXPECTED_H1: ${{ inputs.canary_expected_h1 }} + CANARY_BOOTSTRAP: ${{ needs.translate.outputs.canary_bootstrap }} run: | set -euo pipefail @@ -509,11 +519,15 @@ jobs: if [ "${ARTIFACT_ROLE}" = "canary" ]; then args+=( --ref "${{ github.ref_name }}" - --artifact-scope page --locale "${{ inputs.locale }}" - --page-path "${{ inputs.canary_live_path }}" --no-force-upload ) + if [ "${CANARY_BOOTSTRAP}" = "true" ]; then + # New locales must appear in existing pages' language menus too. + args+=(--artifact-scope full) + else + args+=(--artifact-scope page --page-path "${{ inputs.canary_live_path }}") + fi if [ "${{ inputs.canary_publish_required }}" = "true" ]; then args+=( --live-url "${CANARY_LIVE_URL}" diff --git a/README.md b/README.md index 352dae98d9..820dce7134 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ English content and navigation are authored in [`openclaw/openclaw`](https://git - Locale outputs are uploaded as artifacts first, then committed together by the finalizer. - Incremental and full translation use separate concurrency lanes, so small docs edits do not cancel weekly or glossary-triggered full reconciliation. - The weekly scheduled run uses full reconciliation mode to repair missed or flaky locale updates. +- To start a new locale with a bounded publish, run **Translate Full** from `main` after its source and publisher support land, with that locale and `canary_only=true`. The LINE canary also generates a missing homepage and getting-started page, validates the site, and publishes the language menus. Existing locales keep the single-page canary. Use `diagnostic_canary_only=true` for explicit non-publishing page samples. ## Editing rules diff --git a/scripts/docs-site/chrome-strings.mjs b/scripts/docs-site/chrome-strings.mjs index 6341d0a38d..361c62ab80 100644 --- a/scripts/docs-site/chrome-strings.mjs +++ b/scripts/docs-site/chrome-strings.mjs @@ -9,6 +9,16 @@ export const chromeStrings = { communityFollowLabel: "Follow OpenClaw on {platform}", onThisPage: "On this page", }, + sv: { + communityDismissLabel: "Stäng och visa inte igen", + communityTitle: "Slå dig ner.", + communityBody: "Det finns en plats för dig i AI-framtiden. Gå med i gemenskapen på Reddit, Discord och X.", + communityJoin: "Gå med", + communityFollow: "Följ", + communityJoinLabel: "Gå med i OpenClaws gemenskap på {platform}", + communityFollowLabel: "Följ OpenClaw på {platform}", + onThisPage: "På den här sidan", + }, "zh-CN": { communityDismissLabel: "关闭且不再显示", communityTitle: "来坐坐吧。", diff --git a/scripts/docs-site/config.mjs b/scripts/docs-site/config.mjs index 2b9159b7c8..e9559529ba 100644 --- a/scripts/docs-site/config.mjs +++ b/scripts/docs-site/config.mjs @@ -13,6 +13,7 @@ export const localeLabels = { it: "Italiano", vi: "Tiếng Việt", nl: "Nederlands", + sv: "Svenska", fa: "فارسی", tr: "Türkçe", uk: "Українська", @@ -37,6 +38,7 @@ export const localeFlags = { it: "🇮🇹", vi: "🇻🇳", nl: "🇳🇱", + sv: "🇸🇪", fa: "🇮🇷", tr: "🇹🇷", uk: "🇺🇦", @@ -61,6 +63,7 @@ export const navigationLocaleToDir = { it: "it", vi: "vi", nl: "nl", + sv: "sv", fa: "fa", tr: "tr", uk: "uk", diff --git a/scripts/docs-site/home-layout.test.mjs b/scripts/docs-site/home-layout.test.mjs index 1917f4d9f0..1b8940cd21 100644 --- a/scripts/docs-site/home-layout.test.mjs +++ b/scripts/docs-site/home-layout.test.mjs @@ -14,6 +14,25 @@ const original = parseDocument(homeContentHtml(rendered)); const result = parseDocument(homeLayoutHtml(rendered, () => "")); const elements = (document, predicate) => DomUtils.findAll(predicate, document.children); const ids = new Set(elements(result, (node) => node.attribs.id).map((node) => node.attribs.id)); +// Before the first Swedish translation, use a synthetic Swedish-heading fixture +// with the complete source structure. Generated locale sources take precedence. +const swedishHeadings = { + "Browse docs": "Bläddra i dokumentationen", + "What is OpenClaw?": "Vad är OpenClaw?", + "How it works": "Så fungerar det", + "Key capabilities": "Viktiga funktioner", + "Quick start": "Snabbstart", + Dashboard: "Kontrollpanel", + "Configuration (optional)": "Konfiguration (valfritt)", + "Start here": "Börja här", + "Learn more": "Läs mer", +}; +const newLocaleSources = { + sv: source.replace(/^## (.+)$/gm, (_, heading) => { + assert.ok(swedishHeadings[heading], `Swedish fixture heading required: ${heading}`); + return `## ${swedishHeadings[heading]}`; + }), +}; test("the composed homepage retains source text, links, and anchors without a disclosure gate", () => { const remaining = elements(result, (node) => ["p", "h1", "h2", "h3", "pre"].includes(node.name)) @@ -51,7 +70,9 @@ test("every locale supplies the same complete homepage and navigation copy", () for (const locale of Object.keys(localeLabels)) { test(`${locale} homepage uses the shared section layout and preserves its translated source`, () => { const file = new URL(`../../docs/${locale === "en" ? "" : locale + "/"}index.md`, import.meta.url); - const html = renderMdxish(fs.readFileSync(file, "utf8"), createMarkdownRenderer()); + const source = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : newLocaleSources[locale]; + assert.equal(typeof source, "string", `${locale}: generated homepage or explicit onboarding fixture required`); + const html = renderMdxish(source, createMarkdownRenderer()); const before = parseDocument(homeContentHtml(html)); const after = parseDocument(homeLayoutHtml(html, () => "", null, locale)); const root = elements(after, (node) => node.attribs.class === "home-layout")[0]; diff --git a/scripts/docs-site/home-strings.mjs b/scripts/docs-site/home-strings.mjs index 1961599327..8103808d57 100644 --- a/scripts/docs-site/home-strings.mjs +++ b/scripts/docs-site/home-strings.mjs @@ -11,6 +11,16 @@ export const homeStrings = { controls: ["Search documentation", "Toggle theme", "Toggle navigation menu", "Close menu", "Back to all documentation"], release: ["New release", "Read release notes", "See what’s new in this release.", "Latest", "Latest release:"], }, + sv: { + tagline: ["AI som faktiskt får saker gjorda.", "Alla operativsystem. Alla plattformar. På hummerns vis. 🦞"], + channels: ["Anslut en kanal", "Använd OpenClaw från din chattapp.", "Visa alla kanaler"], + guides: ["Utvalda guider", "Alla guider"], + guideCards: [["Schemalägg uppgifter", "Kör återkommande jobb och få resultaten i din chatt."], ["Ge din agent en webbläsare", "Konfigurera en egen webbläsare för din agent."], ["Kör flera agenter", "Håll arbetsytor, konversationer och ansvarsområden åtskilda."]], + community: ["Gemenskap", "Var med och bygg", "Ställ frågor, dela det du skapar eller säg bara hej.", "Gå med på Discord", "Gemenskapens forum", "Ställ frågor och dela det du har byggt.", "Läs källkoden, rapportera ett problem eller bidra.", "Följ OpenClaw för nyheter och uppdateringar om projektet."], + navigation: ["Kom igång", "Översikt", "Snabbstart", "Installation", "Första konfigurationen", "Konfiguration", "Dokumentation"], + controls: ["Sök i dokumentationen", "Växla tema", "Visa eller dölj navigeringsmenyn", "Stäng menyn", "Tillbaka till all dokumentation"], + release: ["Ny version", "Läs versionsinformationen", "Se vad som är nytt i den här versionen.", "Senaste", "Senaste version:"], + }, de: { tagline: ["Die KI, die wirklich etwas tut.", "Jedes Betriebssystem. Jede Plattform. Auf Hummer-Art. 🦞"], channels: ["Einen Kanal verbinden", "Nutze OpenClaw in deiner Chat-App.", "Alle Kanäle ansehen"], diff --git a/scripts/docs-site/locale-collection-owner.test.mjs b/scripts/docs-site/locale-collection-owner.test.mjs index 9451a2df88..99f9aa1173 100644 --- a/scripts/docs-site/locale-collection-owner.test.mjs +++ b/scripts/docs-site/locale-collection-owner.test.mjs @@ -23,11 +23,13 @@ const translations = { "ja-JP/target.md": "# 日本語の対象\n", "fr/index.md": "# Français\n", "fr/target.mdx": "# Cible française\n", + "sv/index.md": "# Svenska\n", + "sv/target.mdx": "# Svenskt mål\n\n## Del\nSvensk text\n\n[Engelsk reservsida](/fallback)\n", "eo/index.md": "# Esperanto\n", "eo/target.mdx": "# Celo\n", }; const englishRoutes = ["/", "/target", "/fallback", "/guide/de/topic", "/guide/ja/topic", "/guide/eo/topic", "/unknown/topic", "/ja/topic"]; -const localeRoutes = ["/de", "/de/target", "/de/guide/de/topic", "/ja-JP", "/ja-JP/target", "/fr", "/fr/target", "/eo", "/eo/target"]; +const localeRoutes = ["/de", "/de/target", "/de/guide/de/topic", "/ja-JP", "/ja-JP/target", "/fr", "/fr/target", "/sv", "/sv/target", "/eo", "/eo/target"]; const nav = (language, tab, pages) => ({ language, tabs: [{ tab, groups: [{ group: tab, pages }] }] }); for (const base of ["", "/manual"]) { @@ -39,6 +41,7 @@ for (const base of ["", "/manual"]) { nav("en", "English navigation", ["index", "target", "fallback", "guide/de/topic"]), nav("de", "Deutsche Navigation", ["de/index", "de/target", "de/fallback", { group: "Nested", pages: ["de/guide/de/topic"] }]), nav("ja", "日本語ナビ", ["ja-JP/index", "ja-JP/target"]), + nav("sv", "Svensk navigering", ["sv/index", "sv/target", "sv/fallback"]), nav("eo", "Esperanta navigado", ["eo/index", "eo/target"]), nav("es", "Absent locale", ["index", "target"]), // French is discovered from the tree and inherits the first navigation. @@ -51,7 +54,7 @@ for (const base of ["", "/manual"]) { { source: "/old-root", destination: "/" }, ], })); - for (const prefix of ["", "de/", "eo/"]) { + for (const prefix of ["", "de/", "sv/", "eo/"]) { for (const rel of ["AGENTS.md", ".i18n/hidden.md", ".generated/hidden.mdx", "assets/hidden.md", ".hidden/page.md"]) { write(f.root, `docs/${prefix}${rel}`, "# Ignored source\n"); } @@ -61,7 +64,7 @@ for (const base of ["", "/manual"]) { // surviving files after duplicate HTML writes have overwritten one another. const built = f.build({ DOCS_SITE_ARTIFACT_MODE: "full" }); assert.equal(built.status, 0, built.stderr); - assert.equal(Number(built.stdout.match(/built (\d+) pages/)?.[1]), 18, built.stdout); + assert.equal(Number(built.stdout.match(/built (\d+) pages/)?.[1]), 20, built.stdout); const site = path.join(f.root, "dist/docs-site"); const read = (rel) => fs.readFileSync(path.join(site, rel), "utf8"); @@ -81,7 +84,7 @@ for (const base of ["", "/manual"]) { assert.ok(html(route).includes(``), route); } - for (const locale of ["de", "fr"]) { + for (const locale of ["de", "fr", "sv"]) { const quickNav = html(`/${locale}/target`).match(/