Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Translation policy and localized public documentation require owner review.
/style-guides/ @Tom-R-Main
/docs/i18n/ @Tom-R-Main
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,13 @@ Define rules that apply to all languages: interpolation syntax, HTML preservatio

Define language-specific rules: formality register (tu vs. vous), punctuation (guillemets, inverted question marks), plural forms, date/number formatting, and a terminology glossary.

Style guides are durable policy inputs, not generated output. Internationalizer
reads them but never rewrites them. Their content is hashed separately from the
glossary and prompt contract, so an application code change does not make a
translation stale. Editing a guide intentionally marks that locale for policy
review; changing internal prompt wording does not, unless the prompt contract
version also changes.

See [`examples/react-app/style-guides/`](examples/react-app/style-guides/) for a working example.

## Glossary Format
Expand Down Expand Up @@ -370,7 +377,7 @@ findings; it does not exempt a longer value merely containing `API`.
Translation memory is stored as a JSONL file (one JSON record per line). Each record contains:

- The bundle, key, source value, translated value, and canonical target locale
- Source and translation-policy hashes
- Source, style-guide, glossary, prompt-contract, and combined policy hashes
- The provider and model that produced the translation
- A timestamp

Expand All @@ -386,7 +393,12 @@ reviewable `.internationalizer.lock` manifest is versioned separately.
|--------|-----------|------|
| JSON | `.json` | Key-value (nested, dot-notation flattened) |
| YAML | `.yml`, `.yaml` | Key-value (preserves comments and ordering) |
| Markdown | `.md`, `.mdx` | Whole-document translation |
| Markdown | `.md`, `.mdx` | Preamble and H2-level sections |

Markdown targets contain invisible `internationalizer:unit` comments before
H2 sections. These stable markers let Internationalizer add, move, or edit one
source section without retranslating unrelated sections. Existing unmarked
documents receive markers on their next successful update.

## Project Type Detection

Expand Down
331 changes: 214 additions & 117 deletions docs/i18n/ar.md

Large diffs are not rendered by default.

319 changes: 208 additions & 111 deletions docs/i18n/bn.md

Large diffs are not rendered by default.

228 changes: 159 additions & 69 deletions docs/i18n/cs.md

Large diffs are not rendered by default.

191 changes: 141 additions & 50 deletions docs/i18n/da.md

Large diffs are not rendered by default.

306 changes: 209 additions & 97 deletions docs/i18n/de.md

Large diffs are not rendered by default.

205 changes: 155 additions & 50 deletions docs/i18n/el.md

Large diffs are not rendered by default.

325 changes: 211 additions & 114 deletions docs/i18n/es.md

Large diffs are not rendered by default.

211 changes: 158 additions & 53 deletions docs/i18n/fi.md

Large diffs are not rendered by default.

291 changes: 194 additions & 97 deletions docs/i18n/fr.md

Large diffs are not rendered by default.

242 changes: 166 additions & 76 deletions docs/i18n/he.md

Large diffs are not rendered by default.

308 changes: 210 additions & 98 deletions docs/i18n/hi.md

Large diffs are not rendered by default.

309 changes: 203 additions & 106 deletions docs/i18n/id.md

Large diffs are not rendered by default.

211 changes: 158 additions & 53 deletions docs/i18n/it.md

Large diffs are not rendered by default.

284 changes: 144 additions & 140 deletions docs/i18n/ja.md

Large diffs are not rendered by default.

228 changes: 167 additions & 61 deletions docs/i18n/ko.md

Large diffs are not rendered by default.

199 changes: 145 additions & 54 deletions docs/i18n/ms.md

Large diffs are not rendered by default.

208 changes: 157 additions & 51 deletions docs/i18n/nl.md

Large diffs are not rendered by default.

248 changes: 180 additions & 68 deletions docs/i18n/pa.md

Large diffs are not rendered by default.

282 changes: 194 additions & 88 deletions docs/i18n/pl.md

Large diffs are not rendered by default.

311 changes: 204 additions & 107 deletions docs/i18n/pt-BR.md

Large diffs are not rendered by default.

213 changes: 159 additions & 54 deletions docs/i18n/ro.md

Large diffs are not rendered by default.

323 changes: 210 additions & 113 deletions docs/i18n/ru.md

Large diffs are not rendered by default.

189 changes: 140 additions & 49 deletions docs/i18n/sv.md

Large diffs are not rendered by default.

310 changes: 211 additions & 99 deletions docs/i18n/te.md

Large diffs are not rendered by default.

339 changes: 218 additions & 121 deletions docs/i18n/th.md

Large diffs are not rendered by default.

198 changes: 152 additions & 46 deletions docs/i18n/tr.md

Large diffs are not rendered by default.

330 changes: 221 additions & 109 deletions docs/i18n/uk.md

Large diffs are not rendered by default.

199 changes: 152 additions & 47 deletions docs/i18n/vi.md

Large diffs are not rendered by default.

302 changes: 207 additions & 95 deletions docs/i18n/yue.md

Large diffs are not rendered by default.

322 changes: 164 additions & 158 deletions docs/i18n/zh-CN.md

Large diffs are not rendered by default.

283 changes: 152 additions & 131 deletions docs/i18n/zh-TW.md

Large diffs are not rendered by default.

20 changes: 15 additions & 5 deletions docs/localization-core-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,25 @@ framework runtime, translation CDN, or source-rewriting setup command.

## Review transfer

Manifest v2 will represent generated, needs-review, approved, and manual
origins explicitly. Only an exact source-and-policy match may inherit approval.
Fuzzy candidates, changed policy, or changed message structure require review.
Only an exact source-and-policy match may inherit review state. Fuzzy
candidates, changed policy, or changed message structure require review.

## Source units

Structured documents and source adapters will identify a unit by adapter,
Structured documents and source adapters identify a unit by adapter,
normalized project-relative path, semantic location, and source structure.
Formatting offsets alone are not stable identity.
Formatting offsets alone are not stable identity. Markdown uses the preamble
and each H2 section as independent units, with invisible target-side markers to
preserve identity when sections move or are inserted.

## Policy identity

The manifest records style-guide, glossary, and prompt-contract components
separately. The combined policy hash includes those components and the provider
settings, but not the rendered prompt text. Refactoring prompt construction
therefore leaves current translations alone; a semantic prompt change requires
an explicit prompt-contract version bump. Style guides are read-only inputs and
are never generated or rewritten by a translation run.

## Compatibility boundary

Expand Down
8 changes: 8 additions & 0 deletions internal/formats/formats.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ type Format interface {
Serialize(entries map[string]string, original []byte) ([]byte, error)
}

// PairedFormat uses source structure when reading and writing a translated
// target. This lets document formats retain stable semantic unit identities
// even when headings and prose are translated.
type PairedFormat interface {
ParseTarget(source, target []byte) (map[string]string, error)
SerializeTarget(entries map[string]string, source, target []byte) ([]byte, error)
}

// EntryRemover is implemented by structured formats that can remove selected
// string leaves while preserving the rest of the original document shape.
type EntryRemover interface {
Expand Down
227 changes: 220 additions & 7 deletions internal/formats/markdown.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,232 @@
package formats

// MarkdownFormat treats a whole markdown file as a single translation unit.
// The key is always "_content" and the value is the full document text.
import (
"fmt"
"regexp"
"strings"
"unicode"
)

const markdownPreambleKey = "markdown:preamble"

var (
markdownFencePattern = regexp.MustCompile(`^[ \t]{0,3}(` + "`{3,}" + `|~{3,})`)
markdownH2Pattern = regexp.MustCompile(`^[ \t]{0,3}##[ \t]+(.+?)[ \t]*#*[ \t]*(?:\r?\n)?$`)
markdownMarkerPattern = regexp.MustCompile(`^[ \t]*<!--[ \t]*internationalizer:unit[ \t]+([a-z0-9][a-z0-9:._-]*)[ \t]*-->[ \t]*(?:\r?\n)?$`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept the Unicode keys emitted for Markdown sections

When a source H2 contains non-ASCII letters, such as ## 配置 or ## Café, markdownSlug preserves those characters and serialization emits a marker like <!-- internationalizer:unit markdown:配置 -->. This ASCII-only pattern cannot recognize that marker on the immediate verification parse, so it is treated as preamble/preceding-section content and a new-document translation fails with “key changed during serialization.” Encode generated keys to the accepted alphabet or allow the same Unicode key space in the marker parser.

Useful? React with 👍 / 👎.

)

// MarkdownFormat translates the document preamble and each level-two section
// independently. Paired serialization writes stable, invisible unit markers
// into targets so later source-section insertions do not remap existing prose.
type MarkdownFormat struct{}

func (f *MarkdownFormat) Name() string { return "markdown" }
func (f *MarkdownFormat) Extensions() []string { return []string{".md", ".mdx"} }

func (f *MarkdownFormat) Parse(data []byte) (map[string]string, error) {
return map[string]string{"_content": string(data)}, nil
sections, err := parseMarkdownSections(data)
if err != nil {
return nil, err
}
assignSourceKeys(sections)
return markdownEntries(sections), nil
}

// ParseTarget maps a translated document to the source's stable section keys.
// Existing unmarked documents are accepted only when their section count still
// matches, which provides a safe one-time migration path.
func (f *MarkdownFormat) ParseTarget(source, target []byte) (map[string]string, error) {
sourceSections, err := parseMarkdownSections(source)
if err != nil {
return nil, fmt.Errorf("parsing markdown source: %w", err)
}
assignSourceKeys(sourceSections)
targetSections, err := parseMarkdownSections(target)
if err != nil {
return nil, fmt.Errorf("parsing markdown target: %w", err)
}

marked := 0
for _, section := range targetSections[1:] {
if section.marker != "" {
marked++
}
}
if marked != 0 && marked != len(targetSections)-1 {
return nil, fmt.Errorf("markdown target mixes marked and unmarked sections")
}
if marked == 0 {
if len(targetSections) != len(sourceSections) {
return nil, fmt.Errorf("unmarked markdown target has %d sections; source has %d", len(targetSections)-1, len(sourceSections)-1)
}
for i := range targetSections {
targetSections[i].key = sourceSections[i].key
}
} else {
targetSections[0].key = markdownPreambleKey
seen := map[string]struct{}{markdownPreambleKey: {}}
for _, section := range targetSections[1:] {
if _, duplicate := seen[section.marker]; duplicate {
return nil, fmt.Errorf("duplicate markdown unit marker %q", section.marker)
}
seen[section.marker] = struct{}{}
section.key = section.marker
}
}
return markdownEntries(targetSections), nil
}

func (f *MarkdownFormat) Serialize(entries map[string]string, original []byte) ([]byte, error) {
sections, err := parseMarkdownSections(original)
if err != nil {
return nil, err
}
assignSourceKeys(sections)
return serializeMarkdownSections(entries, sections, false)
}

// SerializeTarget follows source-section order and emits target-side markers.
func (f *MarkdownFormat) SerializeTarget(entries map[string]string, source, _ []byte) ([]byte, error) {
sections, err := parseMarkdownSections(source)
if err != nil {
return nil, fmt.Errorf("parsing markdown source: %w", err)
}
assignSourceKeys(sections)
return serializeMarkdownSections(entries, sections, true)
}

type markdownSection struct {
key string
marker string
heading string
value string
}

func (f *MarkdownFormat) Serialize(entries map[string]string, _ []byte) ([]byte, error) {
if content, ok := entries["_content"]; ok {
return []byte(content), nil
func parseMarkdownSections(data []byte) ([]*markdownSection, error) {
sections := []*markdownSection{{key: markdownPreambleKey}}
current := sections[0]
var value strings.Builder
var pendingMarker string
var fence byte
var fenceLength int

flush := func() {
current.value = value.String()
value.Reset()
}
for _, line := range splitLinesAfter(string(data)) {
if match := markdownFencePattern.FindStringSubmatch(line); match != nil {
marker := match[1]
if fence == 0 {
fence = marker[0]
fenceLength = len(marker)
} else if marker[0] == fence && len(marker) >= fenceLength {
fence = 0
fenceLength = 0
}
Comment on lines +123 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require valid closing syntax before ending a Markdown fence

Inside a fenced code block, any line beginning with enough matching fence characters currently closes the parser's fence, even when text follows them. For example, a literal code line such as ```javascript is not a CommonMark closing fence, but this branch ends the fence anyway; a subsequent ## ... line in the code block is then parsed as a translation section, which can translate protected code or create spurious keys. Only close when the remainder of the line is whitespace, as the validator's closing-fence logic already does.

Useful? React with 👍 / 👎.

value.WriteString(line)
continue
}
if fence == 0 {
if match := markdownMarkerPattern.FindStringSubmatch(line); match != nil {
if pendingMarker != "" {
return nil, fmt.Errorf("markdown unit marker %q is not followed by a level-two heading", pendingMarker)
}
pendingMarker = match[1]
continue
}
if match := markdownH2Pattern.FindStringSubmatch(line); match != nil {
flush()
current = &markdownSection{marker: pendingMarker, heading: strings.TrimSpace(match[1])}
sections = append(sections, current)
pendingMarker = ""
value.WriteString(line)
continue
}
if pendingMarker != "" {
return nil, fmt.Errorf("markdown unit marker %q is not immediately followed by a level-two heading", pendingMarker)
}
}
value.WriteString(line)
}
if pendingMarker != "" {
return nil, fmt.Errorf("markdown unit marker %q has no section", pendingMarker)
}
flush()
return sections, nil
}

func assignSourceKeys(sections []*markdownSection) {
seen := make(map[string]int)
sections[0].key = markdownPreambleKey
for index, section := range sections[1:] {
if section.marker != "" {
section.key = section.marker
continue
}
slug := markdownSlug(section.heading)
if slug == "" {
slug = fmt.Sprintf("section-%d", index+1)
}
seen[slug]++
if seen[slug] > 1 {
slug = fmt.Sprintf("%s-%d", slug, seen[slug])
}
section.key = "markdown:" + slug
}
}

func markdownEntries(sections []*markdownSection) map[string]string {
entries := make(map[string]string, len(sections))
for _, section := range sections {
entries[section.key] = section.value
}
return entries
}

func serializeMarkdownSections(entries map[string]string, order []*markdownSection, markers bool) ([]byte, error) {
var output strings.Builder
for index, section := range order {
value, ok := entries[section.key]
if !ok {
return nil, fmt.Errorf("markdown entry %q is missing", section.key)
}
if index > 0 && markers {
if output.Len() > 0 && !strings.HasSuffix(output.String(), "\n") {
output.WriteByte('\n')
}
fmt.Fprintf(&output, "<!-- internationalizer:unit %s -->\n", section.key)
}
output.WriteString(value)
}
return []byte(output.String()), nil
}

func markdownSlug(heading string) string {
var slug strings.Builder
separator := false
for _, r := range strings.ToLower(heading) {
switch {
case unicode.IsLetter(r) || unicode.IsNumber(r):
if separator && slug.Len() > 0 {
slug.WriteByte('-')
}
separator = false
slug.WriteRune(r)
default:
separator = true
}
}
return slug.String()
}

func splitLinesAfter(value string) []string {
if value == "" {
return nil
}
lines := strings.SplitAfter(value, "\n")
if lines[len(lines)-1] == "" {
return lines[:len(lines)-1]
}
return nil, nil
return lines
}
Loading