diff --git a/scripts/docs-i18n/doc_mode_test.go b/scripts/docs-i18n/doc_mode_test.go index 8b89a16fcf35..cb25d5de9d13 100644 --- a/scripts/docs-i18n/doc_mode_test.go +++ b/scripts/docs-i18n/doc_mode_test.go @@ -773,6 +773,21 @@ func TestValidateDocChunkTranslationRejectsChangedTripleBacktickCodeSpan(t *test } } +func TestValidateDocChunkTranslationRejectsChangedLineStartTripleBacktickCodeSpan(t *testing.T) { + t.Parallel() + + source := "```foo``` is the value.\n```\ncode\n```\n" + translated := "```bar``` es el valor.\n```\ncode\n```\n" + + err := validateDocChunkTranslation(source, translated) + if err == nil { + t.Fatal("expected changed line-start triple-backtick code span to be rejected") + } + if !strings.Contains(err.Error(), "inline code mismatch") { + t.Fatalf("expected inline code mismatch, got %v", err) + } +} + func TestValidateDocChunkTranslationRejectsChangedMultilineCodeSpan(t *testing.T) { t.Parallel() @@ -896,6 +911,20 @@ func TestValidateDocChunkTranslationRejectsCodeAfterComponentFence(t *testing.T) } } +func TestValidateDocChunkTranslationAllowsTranslatedProseInIsolatedIndentedFence(t *testing.T) { + t.Parallel() + + source := " ```json5\n provider: \"firecrawl\", // optional; omit for auto-detect\n ```\n" + translated := " ```json5\n provider: \"firecrawl\", // необязательно; опустите для автоопределения\n ```\n" + + if values := extractMarkdownInlineCodeValues(source); len(values) != 0 { + t.Fatalf("expected custom indented fence to be excluded from inline code, got %q", values) + } + if err := validateDocChunkTranslation(source, translated); err != nil { + t.Fatalf("expected translated fence prose to validate, got %v", err) + } +} + func TestValidateDocChunkTranslationRejectsChangedCodeInSplitComponentBody(t *testing.T) { t.Parallel() @@ -2296,8 +2325,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: 23") { - t.Fatalf("expected prompt version 23 in output metadata:\n%s", text) + if !strings.Contains(text, "prompt_version: 24") { + t.Fatalf("expected prompt version 24 in output metadata:\n%s", text) } } diff --git a/scripts/docs-i18n/markdown_segments.go b/scripts/docs-i18n/markdown_segments.go index c3089e4b2521..f5eea18b6c3a 100644 --- a/scripts/docs-i18n/markdown_segments.go +++ b/scripts/docs-i18n/markdown_segments.go @@ -183,6 +183,7 @@ func markdownListParentItemPath(list *ast.List) string { func extractMarkdownInlineCodeValues(body string) []string { parseSource := []byte(normalizeDocComponentsForMarkdownParse(body)) + fencedRanges := markdownClosedLiteralFenceByteRanges(string(parseSource)) doc := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(parseSource)) values := []string{} _ = ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) { @@ -191,6 +192,9 @@ func extractMarkdownInlineCodeValues(body string) []string { } span, ok := node.(*ast.CodeSpan) if ok { + if byteRange, found := markdownCodeSpanContentRange(span); found && rangeOverlapsAny(byteRange, fencedRanges) { + return ast.WalkContinue, nil + } values = append(values, string(span.Text(parseSource))) } return ast.WalkContinue, nil @@ -319,8 +323,12 @@ func parseMarkdownLiteralFenceOpening(line string) (markdownLiteralFenceState, b if delimiter == "" { return markdownLiteralFenceState{}, false } + infoText := strings.TrimSpace(remaining[len(delimiter):]) + if delimiter[0] == '`' && strings.Contains(infoText, "`") { + return markdownLiteralFenceState{}, false + } info := "" - if fields := strings.Fields(strings.TrimSpace(remaining[len(delimiter):])); len(fields) > 0 { + if fields := strings.Fields(infoText); len(fields) > 0 { info = strings.ToLower(fields[0]) } return markdownLiteralFenceState{delimiter: delimiter, quoteDepth: quoteDepth, info: info, containerIndent: containerIndent}, true @@ -620,7 +628,7 @@ func fencedMarkerName(value string) (string, bool) { } func extractFallbackBacktickValues(body string) []string { - fenced := markdownFencedCodeRanges(body) + fenced := append(markdownFencedCodeRanges(body), markdownClosedLiteralFenceByteRanges(body)...) values := []string{} for _, span := range markdownBlockBacktickRanges(body) { if rangeOverlapsAny(span, fenced) { @@ -638,6 +646,27 @@ func extractFallbackBacktickValues(body string) []string { return values } +func markdownCodeSpanContentRange(span *ast.CodeSpan) ([2]int, bool) { + start, end := -1, -1 + for child := span.FirstChild(); child != nil; child = child.NextSibling() { + textNode, ok := child.(*ast.Text) + if !ok { + continue + } + segment := textNode.Segment + if start < 0 || segment.Start < start { + start = segment.Start + } + if segment.Stop > end { + end = segment.Stop + } + } + if start < 0 || end < start { + return [2]int{}, false + } + return [2]int{start, end}, true +} + func markdownFencedCodeRanges(body string) [][2]int { source := []byte(body) doc := goldmark.New(goldmark.WithExtensions(extension.GFM)).Parser().Parse(text.NewReader(source)) @@ -869,22 +898,8 @@ func markdownCodeSpanRanges(body string) [][2]int { if !ok { return ast.WalkContinue, nil } - start, end := -1, -1 - for child := span.FirstChild(); child != nil; child = child.NextSibling() { - textNode, ok := child.(*ast.Text) - if !ok { - continue - } - segment := textNode.Segment - if start < 0 || segment.Start < start { - start = segment.Start - } - if segment.Stop > end { - end = segment.Stop - } - } - if start >= 0 && end >= start { - ranges = append(ranges, [2]int{start, end}) + if byteRange, found := markdownCodeSpanContentRange(span); found { + ranges = append(ranges, byteRange) } return ast.WalkContinue, nil }) diff --git a/scripts/docs-i18n/masking.go b/scripts/docs-i18n/masking.go index 0f1a1b32e10b..a1f3069bab50 100644 --- a/scripts/docs-i18n/masking.go +++ b/scripts/docs-i18n/masking.go @@ -114,6 +114,14 @@ func maskMarkdownDocSyntax(text string, nextPlaceholder func() string, placehold } func markdownLiteralFenceByteRanges(text string) [][2]int { + return markdownLiteralFenceByteRangesWithMode(text, true) +} + +func markdownClosedLiteralFenceByteRanges(text string) [][2]int { + return markdownLiteralFenceByteRangesWithMode(text, false) +} + +func markdownLiteralFenceByteRangesWithMode(text string, includeUnclosed bool) [][2]int { ranges := make([][2]int, 0) state := markdownLiteralFenceState{} start := -1 @@ -129,7 +137,9 @@ func markdownLiteralFenceByteRanges(text string) [][2]int { offset += len(line) continue } - ranges = append(ranges, [2]int{start, offset}) + if includeUnclosed { + ranges = append(ranges, [2]int{start, offset}) + } state = markdownLiteralFenceState{} start = -1 } @@ -139,7 +149,7 @@ func markdownLiteralFenceByteRanges(text string) [][2]int { } offset += len(line) } - if state.delimiter != "" { + if includeUnclosed && state.delimiter != "" { ranges = append(ranges, [2]int{start, len(text)}) } return ranges diff --git a/scripts/docs-i18n/util.go b/scripts/docs-i18n/util.go index f2b485ac323c..f2d93bc639b1 100644 --- a/scripts/docs-i18n/util.go +++ b/scripts/docs-i18n/util.go @@ -12,7 +12,7 @@ import ( const ( workflowVersion = 16 - promptVersion = 23 + promptVersion = 24 docsI18nEngineName = "codex" envDocsI18nProvider = "OPENCLAW_DOCS_I18N_PROVIDER" envDocsI18nModel = "OPENCLAW_DOCS_I18N_MODEL" diff --git a/scripts/docs-i18n/util_test.go b/scripts/docs-i18n/util_test.go index dc6d4f1f1288..a1d7887a71cd 100644 --- a/scripts/docs-i18n/util_test.go +++ b/scripts/docs-i18n/util_test.go @@ -8,7 +8,7 @@ import ( func TestCacheNamespaceIncludesPromptVersion(t *testing.T) { t.Parallel() - if want := "prompt=23"; !strings.Contains(cacheNamespace(), want) { + if want := "prompt=24"; !strings.Contains(cacheNamespace(), want) { t.Fatalf("expected cache namespace to contain %q, got %q", want, cacheNamespace()) } }