fix(docs-i18n): preserve Max translation boundaries

This commit is contained in:
Peter Steinberger
2026-07-14 16:35:37 -04:00
parent 0a6de42292
commit 9d3de463a5
24 changed files with 146 additions and 38 deletions
+32
View File
@@ -547,6 +547,14 @@
"source": "Discord",
"target": "Discord"
},
{
"source": "Fireworks",
"target": "Fireworks"
},
{
"source": "Google Chat",
"target": "Google Chat"
},
{
"source": "Matrix",
"target": "Matrix"
@@ -555,14 +563,38 @@
"source": "Microsoft Teams",
"target": "Microsoft Teams"
},
{
"source": "Raft",
"target": "Raft"
},
{
"source": "Runway",
"target": "Runway"
},
{
"source": "Signal",
"target": "Signal"
},
{
"source": "Slack",
"target": "Slack"
},
{
"source": "Synthetic",
"target": "Synthetic"
},
{
"source": "Telegram",
"target": "Telegram"
},
{
"source": "Tokenjuice",
"target": "Tokenjuice"
},
{
"source": "WhatsApp",
"target": "WhatsApp"
},
{
"source": "Discovery + transports",
"target": "设备发现 + 传输协议"
+5 -1
View File
@@ -16,7 +16,9 @@ OpenClaw ACP runtime backend with plugin-owned session and transport management.
## Surface
skills; session catalog: pi
skills
<!-- openclaw-plugin-reference:manual-start -->
## Pi native sessions
@@ -30,6 +32,8 @@ from the directory containing their `settings.json` file.
Turn **Pi Session Catalog** off under **Config > Plugins > ACPX Runtime** to
disable discovery. It is enabled by default.
<!-- openclaw-plugin-reference:manual-end -->
## Related docs
- [acpx](/tools/acp-agents-setup)
+1 -1
View File
@@ -20,4 +20,4 @@ providers: chutes
## Related docs
- [chutes](/providers/chutes)
- [Chutes](/providers/chutes)
+1 -1
View File
@@ -20,4 +20,4 @@ channels: discord; contracts: transcriptSourceProviders; skills
## Related docs
- [discord](/channels/discord)
- [Discord](/channels/discord)
+1 -1
View File
@@ -20,4 +20,4 @@ providers: fireworks
## Related docs
- [fireworks](/providers/fireworks)
- [Fireworks](/providers/fireworks)
+1 -1
View File
@@ -20,4 +20,4 @@ channels: googlechat
## Related docs
- [googlechat](/channels/googlechat)
- [Google Chat](/channels/googlechat)
+1 -1
View File
@@ -20,4 +20,4 @@ channels: imessage; skills
## Related docs
- [imessage](/channels/imessage)
- [iMessage](/channels/imessage)
+1 -1
View File
@@ -20,4 +20,4 @@ channels: line
## Related docs
- [line](/channels/line)
- [LINE](/channels/line)
+1 -1
View File
@@ -20,4 +20,4 @@ channels: matrix
## Related docs
- [matrix](/channels/matrix)
- [Matrix](/channels/matrix)
+1 -1
View File
@@ -20,4 +20,4 @@ providers: meta
## Related docs
- [meta](/providers/meta)
- [Meta](/providers/meta)
+1 -1
View File
@@ -20,4 +20,4 @@ channels: msteams
## Related docs
- [msteams](/channels/msteams)
- [Microsoft Teams](/channels/msteams)
+7 -3
View File
@@ -1,5 +1,5 @@
---
summary: "Adds OpenCode model provider and native session catalog support to OpenClaw."
summary: "Adds OpenCode model provider support to OpenClaw."
read_when:
- You are installing, configuring, or auditing the opencode plugin
title: "OpenCode plugin"
@@ -7,7 +7,7 @@ title: "OpenCode plugin"
# OpenCode plugin
Adds OpenCode model provider and native session catalog support to OpenClaw.
Adds OpenCode model provider support to OpenClaw.
## Distribution
@@ -16,7 +16,9 @@ Adds OpenCode model provider and native session catalog support to OpenClaw.
## Surface
providers: opencode; contracts: mediaUnderstandingProviders; session catalog: opencode
providers: opencode; contracts: mediaUnderstandingProviders
<!-- openclaw-plugin-reference:manual-start -->
## Native sessions
@@ -30,6 +32,8 @@ Gateway credentials.
Turn **OpenCode Session Catalog** off under **Config > Plugins > OpenCode** to
disable discovery. It is enabled by default.
<!-- openclaw-plugin-reference:manual-end -->
## Related docs
- [opencode](/providers/opencode)
+1 -1
View File
@@ -20,4 +20,4 @@ channels: raft
## Related docs
- [raft](/channels/raft)
- [Raft](/channels/raft)
+1 -1
View File
@@ -20,4 +20,4 @@ contracts: videoGenerationProviders
## Related docs
- [runway](/providers/runway)
- [Runway](/providers/runway)
+1 -1
View File
@@ -20,4 +20,4 @@ channels: signal
## Related docs
- [signal](/channels/signal)
- [Signal](/channels/signal)
+1 -1
View File
@@ -20,4 +20,4 @@ channels: slack; skills
## Related docs
- [slack](/channels/slack)
- [Slack](/channels/slack)
+1 -1
View File
@@ -20,4 +20,4 @@ providers: synthetic
## Related docs
- [synthetic](/providers/synthetic)
- [Synthetic](/providers/synthetic)
+1 -1
View File
@@ -20,4 +20,4 @@ channels: telegram
## Related docs
- [telegram](/channels/telegram)
- [Telegram](/channels/telegram)
+1 -1
View File
@@ -20,4 +20,4 @@ contracts: agentToolResultMiddleware
## Related docs
- [tokenjuice](/tools/tokenjuice)
- [Tokenjuice](/tools/tokenjuice)
+1 -1
View File
@@ -20,4 +20,4 @@ channels: whatsapp; contracts: tools; skills
## Related docs
- [whatsapp](/channels/whatsapp)
- [WhatsApp](/channels/whatsapp)
+28
View File
@@ -135,6 +135,7 @@ func translateDocBlockGroup(ctx context.Context, translator docsTranslator, chun
}
if err == nil {
translated = sanitizeDocChunkProtocolWrappers(source, translated)
translated = preserveDocChunkBoundaryWhitespace(normalizedSource, translated)
translated = reapplyCommonIndent(translated, commonIndent)
if validationErr := validateDocChunkTranslation(source, translated); validationErr == nil {
log.Printf("docs-i18n: chunk done %s out_bytes=%d", chunkID, len(translated))
@@ -183,6 +184,7 @@ func translateDocLeafBlock(ctx context.Context, translator docsTranslator, chunk
return "", err
}
translated = sanitizeDocChunkProtocolWrappers(source, translated)
translated = preserveDocChunkBoundaryWhitespace(normalizedSource, translated)
translated = reapplyCommonIndent(translated, commonIndent)
if validationErr := validateDocChunkTranslation(source, translated); validationErr != nil {
return "", validationErr
@@ -365,6 +367,32 @@ func sanitizeDocChunkProtocolWrappers(source, translated string) string {
return body
}
func preserveDocChunkBoundaryWhitespace(source, translated string) string {
prefixEnd := 0
for prefixEnd < len(source) && isDocChunkBoundaryWhitespace(source[prefixEnd]) {
prefixEnd++
}
suffixStart := len(source)
for suffixStart > prefixEnd && isDocChunkBoundaryWhitespace(source[suffixStart-1]) {
suffixStart--
}
translatedStart := 0
for translatedStart < len(translated) && isDocChunkBoundaryWhitespace(translated[translatedStart]) {
translatedStart++
}
translatedEnd := len(translated)
for translatedEnd > translatedStart && isDocChunkBoundaryWhitespace(translated[translatedEnd-1]) {
translatedEnd--
}
return source[:prefixEnd] + translated[translatedStart:translatedEnd] + source[suffixStart:]
}
func isDocChunkBoundaryWhitespace(value byte) bool {
return value == ' ' || value == '\t' || value == '\r' || value == '\n'
}
func stripBodyOnlyWrapper(source, text string) (string, bool) {
sourceLower := strings.ToLower(source)
// When the source itself documents <body> tokens, a bare body-only payload is
+12 -8
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"testing"
@@ -1911,16 +1912,19 @@ func TestTranslateDocBodyChunkedStripsUppercaseBodyWrapper(t *testing.T) {
}
}
func TestTranslateDocBodyChunkedRejectsListCorruptionAcrossSanitizedChunkBoundary(t *testing.T) {
func TestTranslateDocBodyChunkedPreservesListStructureAcrossSanitizedChunkBoundary(t *testing.T) {
body := "Intro paragraph.\n\n1. First item\n2. Second item\n\n"
t.Setenv("OPENCLAW_DOCS_I18N_DOC_CHUNK_MAX_BYTES", "20")
_, err := translateDocBodyChunked(context.Background(), boundaryWrapperTranslator{}, "example.md", body, "en", "de")
if err == nil {
t.Fatal("expected final-document list corruption across chunk boundaries to be rejected")
translated, err := translateDocBodyChunked(context.Background(), boundaryWrapperTranslator{}, "example.md", body, "en", "de")
if err != nil {
t.Fatalf("expected chunk-boundary whitespace to be restored, got %v", err)
}
if !strings.Contains(err.Error(), "final document validation: list structure mismatch") {
t.Fatalf("expected final list structure mismatch, got %v", err)
if !slices.Equal(extractMarkdownListShapes(body), extractMarkdownListShapes(translated)) {
t.Fatalf("expected list structure to survive chunk assembly:\n%s", translated)
}
if !strings.Contains(translated, "Einleitung\n\n1. Erster Eintrag") {
t.Fatalf("expected paragraph/list boundary to survive chunk assembly:\n%s", translated)
}
}
@@ -2325,8 +2329,8 @@ func TestProcessFileDocUsesFieldLevelFrontmatterTranslation(t *testing.T) {
if !strings.Contains(text, "在 Fly.io 上部署 OpenClaw") {
t.Fatalf("expected translated read_when entry in output:\n%s", text)
}
if !strings.Contains(text, "prompt_version: 26") {
t.Fatalf("expected prompt version 26 in output metadata:\n%s", text)
if !strings.Contains(text, "prompt_version: 27") {
t.Fatalf("expected prompt version 27 in output metadata:\n%s", text)
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
const (
workflowVersion = 16
promptVersion = 26
promptVersion = 27
docsI18nEngineName = "codex"
envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER"
envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL"
+44 -8
View File
@@ -34,6 +34,26 @@ const PLUGIN_DOC_ALIASES = new Map([
const SKIPPED_REFERENCE_PAGE_IDS = new Set(["parallel"]);
const MANUAL_SECTION_START = "<!-- openclaw-plugin-reference:manual-start -->";
const MANUAL_SECTION_END = "<!-- openclaw-plugin-reference:manual-end -->";
// Generated link labels are user-visible product names and translation source.
const RELATED_DOC_PRODUCT_IDS = new Set([
"chutes",
"discord",
"fireworks",
"googlechat",
"imessage",
"line",
"matrix",
"meta",
"msteams",
"raft",
"runway",
"signal",
"slack",
"synthetic",
"telegram",
"tokenjuice",
"whatsapp",
]);
function readJson(relativePath) {
return JSON.parse(fs.readFileSync(path.join(ROOT, relativePath), "utf8"));
@@ -72,6 +92,10 @@ function docLink({ label, href }) {
return `[${label}](${href})`;
}
function relatedDocLabel(value) {
return RELATED_DOC_PRODUCT_IDS.has(value) ? humanizeId(value) : value;
}
function pluginReferencePath(id) {
return `/plugins/reference/${id}`;
}
@@ -244,7 +268,7 @@ function resolveDocs({ dirName, manifest, packageJson }) {
const links = [];
const pluginAlias = PLUGIN_DOC_ALIASES.get(manifest.id) ?? PLUGIN_DOC_ALIASES.get(dirName);
if (pluginAlias) {
const pluginAliasLabel = manifest.id ?? dirName;
const pluginAliasLabel = relatedDocLabel(manifest.id ?? dirName);
pushUniqueDocLink(links, { href: pluginAlias, label: pluginAliasLabel });
}
@@ -252,7 +276,7 @@ function resolveDocs({ dirName, manifest, packageJson }) {
if (channelDoc) {
pushUniqueDocLink(links, {
href: channelDoc,
label: channelDoc.replace(/^\/channels\//u, ""),
label: relatedDocLabel(channelDoc.replace(/^\/channels\//u, "")),
});
}
@@ -262,7 +286,7 @@ function resolveDocs({ dirName, manifest, packageJson }) {
}
const relativePath = `docs/channels/${channel}.md`;
if (fileExists(relativePath)) {
pushUniqueDocLink(links, { href: `/channels/${channel}`, label: channel });
pushUniqueDocLink(links, { href: `/channels/${channel}`, label: relatedDocLabel(channel) });
}
}
@@ -272,12 +296,15 @@ function resolveDocs({ dirName, manifest, packageJson }) {
}
const alias = PROVIDER_DOC_ALIASES.get(provider);
if (alias) {
pushUniqueDocLink(links, { href: alias, label: provider });
pushUniqueDocLink(links, { href: alias, label: relatedDocLabel(provider) });
continue;
}
const relativePath = `docs/providers/${provider}.md`;
if (fileExists(relativePath)) {
pushUniqueDocLink(links, { href: `/providers/${provider}`, label: provider });
pushUniqueDocLink(links, {
href: `/providers/${provider}`,
label: relatedDocLabel(provider),
});
}
}
@@ -286,13 +313,22 @@ function resolveDocs({ dirName, manifest, packageJson }) {
continue;
}
if (fileExists(`docs/channels/${candidate}.md`)) {
pushUniqueDocLink(links, { href: `/channels/${candidate}`, label: candidate });
pushUniqueDocLink(links, {
href: `/channels/${candidate}`,
label: relatedDocLabel(candidate),
});
}
if (fileExists(`docs/providers/${candidate}.md`)) {
pushUniqueDocLink(links, { href: `/providers/${candidate}`, label: candidate });
pushUniqueDocLink(links, {
href: `/providers/${candidate}`,
label: relatedDocLabel(candidate),
});
}
if (fileExists(`docs/plugins/${candidate}.md`)) {
pushUniqueDocLink(links, { href: `/plugins/${candidate}`, label: candidate });
pushUniqueDocLink(links, {
href: `/plugins/${candidate}`,
label: relatedDocLabel(candidate),
});
}
}