docs: tighten docs i18n source workflow

This commit is contained in:
Peter Steinberger 2026-04-05 10:29:22 +01:00
parent 4954d025e2
commit 219afbc2cc
No known key found for this signature in database
8 changed files with 638 additions and 18 deletions

View File

@ -124,6 +124,14 @@
"source": "/context",
"destination": "/concepts/context"
},
{
"source": "/zh-CN",
"destination": "/zh-CN/index"
},
{
"source": "/zh-CN/",
"destination": "/zh-CN/index"
},
{
"source": "/compaction",
"destination": "/concepts/compaction"

View File

@ -13,6 +13,10 @@ channels, tools, providers, or other capabilities. They are built and maintained
by the community, published on [ClawHub](/tools/clawhub) or npm, and
installable with a single command.
ClawHub is the canonical discovery surface for community plugins. Do not open
docs-only PRs just to add your plugin here for discoverability; publish it on
ClawHub instead.
```bash
openclaw plugins install <package-name>
```
@ -116,14 +120,13 @@ We welcome community plugins that are useful, documented, and safe to operate.
</Step>
<Step title="Open a PR">
Add your plugin to this page with:
<Step title="Use docs PRs only for source-doc changes">
You do not need a docs PR just to make your plugin discoverable. Publish it
on ClawHub instead.
- Plugin name
- npm package name
- GitHub repository URL
- One-line description
- Install command
Open a docs PR only when OpenClaw's source docs need an actual content
change, such as correcting install guidance or adding cross-repo
documentation that belongs in the main docs set.
</Step>
</Steps>

View File

@ -88,6 +88,38 @@ Need to install Node? See [Node setup](/install/node).
</Step>
</Steps>
<Accordion title="Advanced: mount a custom Control UI build">
If you maintain a localized or customized dashboard build, point
`gateway.controlUi.root` to a directory that contains your built static
assets and `index.html`.
```bash
mkdir -p "$HOME/.openclaw/control-ui-custom"
# Copy your built static files into that directory.
```
Then set:
```json
{
"gateway": {
"controlUi": {
"enabled": true,
"root": "$HOME/.openclaw/control-ui-custom"
}
}
}
```
Restart the gateway and reopen the dashboard:
```bash
openclaw gateway restart
openclaw dashboard
```
</Accordion>
## What to do next
<Columns>

View File

@ -17,7 +17,7 @@ const (
bodyTagEnd = "</body>"
)
func processFileDoc(ctx context.Context, translator *PiTranslator, docsRoot, filePath, srcLang, tgtLang string, overwrite bool) (bool, error) {
func processFileDoc(ctx context.Context, translator *PiTranslator, docsRoot, filePath, srcLang, tgtLang string, overwrite bool, routes *routeIndex) (bool, error) {
absPath, relPath, err := resolveDocsPath(docsRoot, filePath)
if err != nil {
return false, err
@ -65,6 +65,7 @@ func processFileDoc(ctx context.Context, translator *PiTranslator, docsRoot, fil
if err := applyFrontmatterTranslations(frontData, markers, translatedFront); err != nil {
return false, fmt.Errorf("frontmatter translation failed for %s: %w", relPath, err)
}
translatedBody = routes.localizeBodyLinks(translatedBody)
updatedFront, err := encodeFrontMatter(frontData, relPath, content)
if err != nil {

View File

@ -0,0 +1,398 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
type routeIndex struct {
targetLang string
redirects map[string]string
sourceRoutes map[string]struct{}
localizedRoutes map[string]struct{}
localePrefixes map[string]struct{}
}
type docsConfig struct {
Redirects []docsRedirect `json:"redirects"`
}
type docsRedirect struct {
Source string `json:"source"`
Destination string `json:"destination"`
}
var (
localeDirRe = regexp.MustCompile(`^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$`)
fencedBacktickCodeBlock = regexp.MustCompile("(?ms)(^|\\n)[ \\t]*```[^\\n]*\\n.*?\\n[ \\t]*```[ \\t]*(?:\\n|$)")
fencedTildeCodeBlock = regexp.MustCompile("(?ms)(^|\\n)[ \\t]*~~~[^\\n]*\\n.*?\\n[ \\t]*~~~[ \\t]*(?:\\n|$)")
markdownLinkTargetRe = regexp.MustCompile(`!?\[[^\]]*\]\(([^)]+)\)`)
hrefDoubleQuotedValueRe = regexp.MustCompile(`\bhref\s*=\s*"([^"]*)"`)
hrefSingleQuotedValueRe = regexp.MustCompile(`\bhref\s*=\s*'([^']*)'`)
)
func loadRouteIndex(docsRoot, targetLang string) (*routeIndex, error) {
index := &routeIndex{
targetLang: strings.TrimSpace(targetLang),
redirects: map[string]string{},
sourceRoutes: map[string]struct{}{},
localizedRoutes: map[string]struct{}{},
localePrefixes: map[string]struct{}{},
}
if err := index.loadRedirects(filepath.Join(docsRoot, "docs.json")); err != nil {
return nil, err
}
if err := index.loadRoutes(docsRoot); err != nil {
return nil, err
}
return index, nil
}
func (ri *routeIndex) loadRedirects(configPath string) error {
data, err := os.ReadFile(configPath)
if err != nil {
return err
}
var config docsConfig
if err := json.Unmarshal(data, &config); err != nil {
return err
}
for _, item := range config.Redirects {
source := normalizeRoute(item.Source)
destination := normalizeRoute(item.Destination)
if source == "" || destination == "" {
continue
}
ri.redirects[source] = destination
}
return nil
}
func (ri *routeIndex) loadRoutes(docsRoot string) error {
localePrefixes, err := discoverLocalePrefixes(docsRoot)
if err != nil {
return err
}
if ri.targetLang != "" {
localePrefixes[ri.targetLang] = struct{}{}
}
ri.localePrefixes = localePrefixes
return filepath.WalkDir(docsRoot, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
if !isMarkdownFile(path) {
return nil
}
relPath, err := filepath.Rel(docsRoot, path)
if err != nil {
return err
}
relPath = normalizeSlashes(relPath)
firstSegment := firstPathSegment(relPath)
content, err := os.ReadFile(path)
if err != nil {
return err
}
permalinks := extractPermalinks(content)
switch {
case firstSegment == ri.targetLang:
trimmedRel := strings.TrimPrefix(relPath, firstSegment+"/")
addRouteCandidates(ri.localizedRoutes, trimmedRel, permalinks)
case ri.isLocalePrefix(firstSegment):
return nil
default:
addRouteCandidates(ri.sourceRoutes, relPath, permalinks)
}
return nil
})
}
func discoverLocalePrefixes(docsRoot string) (map[string]struct{}, error) {
entries, err := os.ReadDir(docsRoot)
if err != nil {
return nil, err
}
locales := map[string]struct{}{}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if !localeDirRe.MatchString(name) {
continue
}
locales[name] = struct{}{}
}
return locales, nil
}
func isMarkdownFile(path string) bool {
return strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".mdx")
}
func normalizeSlashes(path string) string {
return strings.ReplaceAll(path, "\\", "/")
}
func firstPathSegment(relPath string) string {
if relPath == "" {
return ""
}
parts := strings.SplitN(relPath, "/", 2)
return parts[0]
}
func addRouteCandidates(routes map[string]struct{}, relPath string, permalinks []string) {
base := strings.TrimSuffix(strings.TrimSuffix(relPath, ".md"), ".mdx")
if base != relPath {
addRoute(routes, normalizeRoute(base))
switch {
case base == "index":
addRoute(routes, "/")
case strings.HasSuffix(base, "/index"):
addRoute(routes, normalizeRoute(strings.TrimSuffix(base, "/index")))
}
}
for _, permalink := range permalinks {
addRoute(routes, normalizeRoute(permalink))
}
}
func addRoute(routes map[string]struct{}, route string) {
if route == "" {
return
}
routes[route] = struct{}{}
}
func extractPermalinks(content []byte) []string {
frontMatter, _ := splitFrontMatter(string(content))
if strings.TrimSpace(frontMatter) == "" {
return nil
}
data := map[string]any{}
if err := yaml.Unmarshal([]byte(frontMatter), &data); err != nil {
return nil
}
raw, ok := data["permalink"].(string)
if !ok {
return nil
}
permalink := strings.TrimSpace(raw)
if permalink == "" {
return nil
}
return []string{permalink}
}
func normalizeRoute(path string) string {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return ""
}
stripped := strings.Trim(trimmed, "/")
if stripped == "" {
return "/"
}
return "/" + stripped
}
func (ri *routeIndex) localizeBodyLinks(body string) string {
if ri == nil || ri.targetLang == "" || strings.EqualFold(ri.targetLang, "en") {
return body
}
state := NewPlaceholderState(body)
placeholders := make([]string, 0, 8)
mapping := map[string]string{}
masked := maskMatches(body, fencedBacktickCodeBlock, state.Next, &placeholders, mapping)
masked = maskMatches(masked, fencedTildeCodeBlock, state.Next, &placeholders, mapping)
masked = maskMatches(masked, inlineCodeRe, state.Next, &placeholders, mapping)
masked = rewriteMarkdownLinkTargets(masked, ri)
masked = rewriteHrefTargets(masked, ri)
return unmaskMarkdown(masked, placeholders, mapping)
}
func rewriteMarkdownLinkTargets(text string, ri *routeIndex) string {
matches := markdownLinkTargetRe.FindAllStringSubmatchIndex(text, -1)
if len(matches) == 0 {
return text
}
var out strings.Builder
pos := 0
for _, span := range matches {
fullStart, targetStart, targetEnd := span[0], span[2], span[3]
if fullStart < pos {
continue
}
out.WriteString(text[pos:targetStart])
target := text[targetStart:targetEnd]
if text[fullStart] == '!' {
out.WriteString(target)
} else {
out.WriteString(ri.localizeURL(target))
}
pos = targetEnd
}
out.WriteString(text[pos:])
return out.String()
}
func rewriteHrefTargets(text string, ri *routeIndex) string {
text = rewriteCapturedTargets(text, hrefDoubleQuotedValueRe, 2, ri)
text = rewriteCapturedTargets(text, hrefSingleQuotedValueRe, 2, ri)
return text
}
func rewriteCapturedTargets(text string, re *regexp.Regexp, groupIndex int, ri *routeIndex) string {
matches := re.FindAllStringSubmatchIndex(text, -1)
if len(matches) == 0 {
return text
}
var out strings.Builder
pos := 0
for _, span := range matches {
start, end := span[groupIndex], span[groupIndex+1]
if start < pos || start < 0 || end < 0 {
continue
}
out.WriteString(text[pos:start])
out.WriteString(ri.localizeURL(text[start:end]))
pos = end
}
out.WriteString(text[pos:])
return out.String()
}
func (ri *routeIndex) localizeURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return raw
}
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") {
return raw
}
if hasURLScheme(trimmed) {
return raw
}
pathPart, suffix := splitURLSuffix(trimmed)
if !strings.HasPrefix(pathPart, "/") {
return raw
}
normalized := normalizeRoute(pathPart)
if ri.routeHasLocalePrefix(normalized) {
return raw
}
canonical, ok := ri.resolveRoute(normalized)
if !ok {
return raw
}
if _, ok := ri.localizedRoutes[canonical]; !ok {
return raw
}
return prefixLocaleRoute(ri.targetLang, canonical) + suffix
}
func hasURLScheme(raw string) bool {
switch {
case strings.HasPrefix(raw, "http://"), strings.HasPrefix(raw, "https://"):
return true
case strings.HasPrefix(raw, "mailto:"), strings.HasPrefix(raw, "tel:"):
return true
case strings.HasPrefix(raw, "data:"), strings.HasPrefix(raw, "javascript:"):
return true
default:
return false
}
}
func splitURLSuffix(raw string) (string, string) {
index := strings.IndexAny(raw, "?#")
if index == -1 {
return raw, ""
}
return raw[:index], raw[index:]
}
func prefixLocaleRoute(lang, route string) string {
if route == "/" {
return "/" + lang
}
return "/" + lang + route
}
func (ri *routeIndex) routeHasLocalePrefix(route string) bool {
if route == "/" {
return false
}
firstSegment := strings.TrimPrefix(route, "/")
firstSegment = strings.SplitN(firstSegment, "/", 2)[0]
return ri.isLocalePrefix(firstSegment)
}
func (ri *routeIndex) isLocalePrefix(segment string) bool {
if segment == "" {
return false
}
_, ok := ri.localePrefixes[segment]
return ok
}
func (ri *routeIndex) resolveRoute(route string) (string, bool) {
current := normalizeRoute(route)
if current == "" {
return "", false
}
seen := map[string]struct{}{current: {}}
for {
next, ok := ri.redirects[current]
if !ok {
break
}
current = next
if _, ok := seen[current]; ok {
return "", false
}
seen[current] = struct{}{}
}
if current == "/" {
_, ok := ri.localizedRoutes[current]
return current, ok
}
if _, ok := ri.sourceRoutes[current]; ok {
return current, true
}
if _, ok := ri.localizedRoutes[current]; ok {
return current, true
}
return "", false
}

View File

@ -0,0 +1,172 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLocalizeBodyLinks(t *testing.T) {
docsRoot := setupDocsTree(t)
routes, err := loadRouteIndex(docsRoot, "zh-CN")
if err != nil {
t.Fatalf("loadRouteIndex failed: %v", err)
}
tests := []struct {
name string
input string
want string
}{
{
name: "markdown link",
input: `See [Config](/gateway/configuration).`,
want: `See [Config](/zh-CN/gateway/configuration).`,
},
{
name: "href attribute",
input: `<Card href="/gateway/configuration" title="Config" />`,
want: `<Card href="/zh-CN/gateway/configuration" title="Config" />`,
},
{
name: "redirect source resolves to canonical localized page",
input: `See [Sandbox](/sandboxing).`,
want: `See [Sandbox](/zh-CN/gateway/sandboxing).`,
},
{
name: "fragment is preserved",
input: `See [Hooks](/gateway/configuration#hooks).`,
want: `See [Hooks](/zh-CN/gateway/configuration#hooks).`,
},
{
name: "images stay unchanged",
input: `![Diagram](/images/diagram.svg)`,
want: `![Diagram](/images/diagram.svg)`,
},
{
name: "already localized stays unchanged",
input: `See [Config](/zh-CN/gateway/configuration).`,
want: `See [Config](/zh-CN/gateway/configuration).`,
},
{
name: "missing localized page stays unchanged",
input: `See [FAQ](/help/faq).`,
want: `See [FAQ](/help/faq).`,
},
{
name: "permalink route localizes",
input: `See [Formal verification](/security/formal-verification).`,
want: `See [Formal verification](/zh-CN/security/formal-verification).`,
},
{
name: "inline code stays unchanged",
input: "Use `[Config](/gateway/configuration)` in examples.\n\n" +
"See [Config](/gateway/configuration).",
want: "Use `[Config](/gateway/configuration)` in examples.\n\n" +
"See [Config](/zh-CN/gateway/configuration).",
},
{
name: "fenced code block stays unchanged",
input: "```md\n[Config](/gateway/configuration)\n```\n\n" +
"See [Config](/gateway/configuration).",
want: "```md\n[Config](/gateway/configuration)\n```\n\n" +
"See [Config](/zh-CN/gateway/configuration).",
},
{
name: "inline code does not swallow later paragraphs",
input: strings.Join([]string{
"Use `channels.matrix.accounts` and `name`.",
"",
"See [Config](/gateway/configuration).",
"",
"Then review [Troubleshooting](/channels/troubleshooting).",
}, "\n"),
want: strings.Join([]string{
"Use `channels.matrix.accounts` and `name`.",
"",
"See [Config](/zh-CN/gateway/configuration).",
"",
"Then review [Troubleshooting](/zh-CN/channels/troubleshooting).",
}, "\n"),
},
{
name: "indented fenced code block does not swallow later paragraphs",
input: strings.Join([]string{
"1. Setup:",
"",
" ```bash",
" echo hi",
" ```",
"",
"Use `channels.matrix.accounts` and `name`.",
"",
"For triage: [/channels/troubleshooting](/channels/troubleshooting).",
"See [Config](/gateway/configuration).",
}, "\n"),
want: strings.Join([]string{
"1. Setup:",
"",
" ```bash",
" echo hi",
" ```",
"",
"Use `channels.matrix.accounts` and `name`.",
"",
"For triage: [/channels/troubleshooting](/zh-CN/channels/troubleshooting).",
"See [Config](/zh-CN/gateway/configuration).",
}, "\n"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := routes.localizeBodyLinks(tt.input)
if got != tt.want {
t.Fatalf("unexpected rewrite\nwant: %q\ngot: %q", tt.want, got)
}
})
}
}
func setupDocsTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
writeFile(t, filepath.Join(root, "docs.json"), `{
"redirects": [
{ "source": "/sandboxing", "destination": "/gateway/sandboxing" }
]
}`)
files := map[string]string{
"index.md": "# Home\n",
"channels/troubleshooting.md": "# Troubleshooting\n",
"gateway/configuration.md": "# Config\n",
"gateway/sandboxing.md": "# Sandboxing\n",
"security/formal-verification.md": "---\npermalink: /security/formal-verification/\n---\n\n# Formal verification\n",
"help/faq.md": "# FAQ\n",
"zh-CN/index.md": "# Home\n",
"zh-CN/channels/troubleshooting.md": "# Troubleshooting\n",
"zh-CN/gateway/configuration.md": "# Config\n",
"zh-CN/gateway/sandboxing.md": "# Sandboxing\n",
"zh-CN/security/formal-verification.md": "---\npermalink: /security/formal-verification/\n---\n\n# Formal verification\n",
"ja-JP/index.md": "# Home\n",
}
for relPath, content := range files {
writeFile(t, filepath.Join(root, relPath), content)
}
return root
}
func writeFile(t *testing.T, path string, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir failed for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write failed for %s: %v", path, err)
}
}

View File

@ -58,6 +58,11 @@ func main() {
fatal(err)
}
routes, err := loadRouteIndex(resolvedDocsRoot, *targetLang)
if err != nil {
fatal(err)
}
translator, err := NewPiTranslator(*sourceLang, *targetLang, glossary, *thinking)
if err != nil {
fatal(err)
@ -100,14 +105,14 @@ func main() {
switch *mode {
case "doc":
if *parallel > 1 {
proc, skip, err := runDocParallel(context.Background(), ordered, resolvedDocsRoot, *sourceLang, *targetLang, *overwrite, *parallel, glossary, *thinking)
proc, skip, err := runDocParallel(context.Background(), ordered, resolvedDocsRoot, *sourceLang, *targetLang, *overwrite, *parallel, glossary, *thinking, routes)
if err != nil {
fatal(err)
}
processed += proc
skipped += skip
} else {
proc, skip, err := runDocSequential(context.Background(), ordered, translator, resolvedDocsRoot, *sourceLang, *targetLang, *overwrite)
proc, skip, err := runDocSequential(context.Background(), ordered, translator, resolvedDocsRoot, *sourceLang, *targetLang, *overwrite, routes)
if err != nil {
fatal(err)
}
@ -118,7 +123,7 @@ func main() {
if *parallel > 1 {
fatal(fmt.Errorf("parallel processing is only supported in doc mode"))
}
proc, err := runSegmentSequential(context.Background(), ordered, translator, tm, resolvedDocsRoot, *sourceLang, *targetLang)
proc, err := runSegmentSequential(context.Background(), ordered, translator, tm, resolvedDocsRoot, *sourceLang, *targetLang, routes)
if err != nil {
fatal(err)
}
@ -134,14 +139,14 @@ func main() {
log.Printf("docs-i18n: completed processed=%d skipped=%d elapsed=%s", processed, skipped, elapsed)
}
func runDocSequential(ctx context.Context, ordered []string, translator *PiTranslator, docsRoot, srcLang, tgtLang string, overwrite bool) (int, int, error) {
func runDocSequential(ctx context.Context, ordered []string, translator *PiTranslator, docsRoot, srcLang, tgtLang string, overwrite bool, routes *routeIndex) (int, int, error) {
processed := 0
skipped := 0
for index, file := range ordered {
relPath := resolveRelPath(docsRoot, file)
log.Printf("docs-i18n: [%d/%d] start %s", index+1, len(ordered), relPath)
start := time.Now()
skip, err := processFileDoc(ctx, translator, docsRoot, file, srcLang, tgtLang, overwrite)
skip, err := processFileDoc(ctx, translator, docsRoot, file, srcLang, tgtLang, overwrite, routes)
if err != nil {
return processed, skipped, err
}
@ -156,7 +161,7 @@ func runDocSequential(ctx context.Context, ordered []string, translator *PiTrans
return processed, skipped, nil
}
func runDocParallel(ctx context.Context, ordered []string, docsRoot, srcLang, tgtLang string, overwrite bool, parallel int, glossary []GlossaryEntry, thinking string) (int, int, error) {
func runDocParallel(ctx context.Context, ordered []string, docsRoot, srcLang, tgtLang string, overwrite bool, parallel int, glossary []GlossaryEntry, thinking string, routes *routeIndex) (int, int, error) {
jobs := make(chan docJob)
results := make(chan docResult, len(ordered))
ctx, cancel := context.WithCancel(ctx)
@ -179,7 +184,7 @@ func runDocParallel(ctx context.Context, ordered []string, docsRoot, srcLang, tg
}
log.Printf("docs-i18n: [w%d %d/%d] start %s", workerID, job.index, len(ordered), job.rel)
start := time.Now()
skip, err := processFileDoc(ctx, translator, docsRoot, job.path, srcLang, tgtLang, overwrite)
skip, err := processFileDoc(ctx, translator, docsRoot, job.path, srcLang, tgtLang, overwrite, routes)
results <- docResult{
index: job.index,
rel: job.rel,
@ -222,13 +227,13 @@ func runDocParallel(ctx context.Context, ordered []string, docsRoot, srcLang, tg
return processed, skipped, nil
}
func runSegmentSequential(ctx context.Context, ordered []string, translator *PiTranslator, tm *TranslationMemory, docsRoot, srcLang, tgtLang string) (int, error) {
func runSegmentSequential(ctx context.Context, ordered []string, translator *PiTranslator, tm *TranslationMemory, docsRoot, srcLang, tgtLang string, routes *routeIndex) (int, error) {
processed := 0
for index, file := range ordered {
relPath := resolveRelPath(docsRoot, file)
log.Printf("docs-i18n: [%d/%d] start %s", index+1, len(ordered), relPath)
start := time.Now()
if _, err := processFile(ctx, translator, tm, docsRoot, file, srcLang, tgtLang); err != nil {
if _, err := processFile(ctx, translator, tm, docsRoot, file, srcLang, tgtLang, routes); err != nil {
return processed, err
}
processed++

View File

@ -11,7 +11,7 @@ import (
"gopkg.in/yaml.v3"
)
func processFile(ctx context.Context, translator *PiTranslator, tm *TranslationMemory, docsRoot, filePath, srcLang, tgtLang string) (bool, error) {
func processFile(ctx context.Context, translator *PiTranslator, tm *TranslationMemory, docsRoot, filePath, srcLang, tgtLang string, routes *routeIndex) (bool, error) {
absPath, relPath, err := resolveDocsPath(docsRoot, filePath)
if err != nil {
return false, err
@ -74,6 +74,7 @@ func processFile(ctx context.Context, translator *PiTranslator, tm *TranslationM
}
translatedBody := applyTranslations(body, segments)
translatedBody = routes.localizeBodyLinks(translatedBody)
updatedFront, err := encodeFrontMatter(frontData, relPath, content)
if err != nil {
return false, err