mirror of https://github.com/openclaw/openclaw.git
docs: tighten docs i18n source workflow
This commit is contained in:
parent
4954d025e2
commit
219afbc2cc
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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: ``,
|
||||
want: ``,
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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++
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue