Skip to content
Merged
69 changes: 63 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Internationalizer is different. It's a **CLI pipeline** that combines LLM transl
- **Per-language style guides** — control tone, formality, pluralization, and typography
- **Translation memory** — skip unchanged strings, save money on API calls
- **Deterministic validation** — catch missing or extra keys, protected-structure drift, glossary issues, and plural or ICU errors before they ship
- **Explicit approval** — keep provider or adopted provenance separate from human review
- **Fluent and pseudolocales** — preserve translator context and exercise accented or bidirectional layouts without an API call

## Installation

Expand Down Expand Up @@ -105,10 +107,13 @@ internationalizer translate --dry-run
internationalizer translate
```

5. Validate all locales:
5. Validate and approve the exact generated artifacts:

```bash
internationalizer validate
internationalizer review list --status needs_review
internationalizer review approve --locale fr --all
internationalizer validate --require-approved
```

## Commands
Expand All @@ -131,9 +136,39 @@ Translation state independently reports missing, source-stale, policy-stale,
current, and manually edited conditions, so a manual edit cannot conceal a
source or policy change. Policy-stale values are reported but only retranslated
with `--refresh-policy`. Manually edited values are never overwritten
automatically. Use `--adopt-existing` when introducing the manifest to reviewed
translations or when explicitly accepting a reviewed manual edit as the new
baseline.
automatically. Use `--adopt-existing` when introducing the manifest to existing
translations or when explicitly accepting a manual edit as the new provenance
baseline. Adoption does not imply human approval; use `review approve` for that
separate decision.

### `pseudo`

Generate deterministic test locales without a provider or translation-memory
lookup. Accented output defaults to `en-XA`; bidirectional output defaults to
`ar-XB`. ICU and Fluent runtime syntax, code, links, and markup are preserved.

```bash
internationalizer pseudo # accented en-XA
internationalizer pseudo --strategy bidi # bidi ar-XB
internationalizer pseudo --dry-run # show planned artifacts only
internationalizer pseudo --locale qps-ploc # choose another valid locale tag
```

The generator refreshes only artifacts it previously recorded as pseudo
output. Use `--force` to replace any other existing target deliberately.

### `review`

Inspect and approve the exact target content currently bound to its source and
translation policy. Generated, cached, and adopted values all begin in
`needs_review`; approval is invalidated by later source, policy, or target
changes. Pseudolocales are tracked separately as test artifacts.

```bash
internationalizer review list --status needs_review
internationalizer review approve --locale fr --bundle app --key common.save
internationalizer review approve --locale fr --all
```

### `validate`

Expand All @@ -148,6 +183,7 @@ internationalizer validate --json # machine-readable JSON
internationalizer validate -q # exit code only
internationalizer validate --strict # enforce translation quality rules
internationalizer validate --require-state # require current manifest provenance
internationalizer validate --require-approved # also require explicit approval
```

`--strict` also reports translated coverage. A linguistic value identical to
Expand All @@ -159,7 +195,9 @@ Markdown-link structure, glossary violations, and configured plural forms.

`--require-state` verifies each target against `.internationalizer.lock`. It
fails when a key is untracked, or when its recorded source, translation policy,
or target hash is stale. It can be combined with `--strict`.
or target hash is stale. `--require-approved` implies `--require-state` and also
fails if the exact current artifact has not been approved. Both can be combined
with `--strict`.

Human and JSON reports use stable finding codes:

Expand All @@ -178,6 +216,7 @@ Human and JSON reports use stable finding codes:
| `source_stale` | Source content changed after the recorded translation |
| `policy_stale` | The generated prompt or model settings changed |
| `target_modified` | Target content differs from the manifest record |
| `needs_review` | Current provenance exists, but the exact target is not approved |

### `detect`

Expand Down Expand Up @@ -231,6 +270,10 @@ bundles:
source: README.md
target: docs/i18n/{locale}.md
format: markdown
- id: browser
source: browser/locales/en-US/browser.ftl
target: browser/locales/{locale}/browser.ftl
format: fluent

# Backward compatibility: source_path still maps targets to sibling files
# such as locales/fr.json. Prefer bundles for new projects.
Expand Down Expand Up @@ -313,6 +356,14 @@ branch identity, and target-locale CLDR plural categories. Provider output that
breaks these invariants is rejected before a locale file or translation-memory
record is written.

Fluent (`.ftl`) resources are handled as semantic source documents rather than
flattened maps. Message values, terms, and attributes become independent units;
comments are passed to the provider as developer context and included in source
provenance. Serialization preserves resource comments and ordering. Validation
protects variables, references, functions, selector defaults and branches, and
`data-l10n-name` markup slots while allowing target-locale selector variants and
natural reordering of named rich-text elements.

With `i18next-v4`, recognized source plural families are expanded during
translation to the target locale's CLDR categories. A target-only category uses
the source family's `_other` value as its translation template. Strict
Expand Down Expand Up @@ -386,6 +437,8 @@ from the cache without calling the LLM. The default path is under the ignored
`.internationalizer/` directory, so it remains a local cache. Set `tm_path` to a
tracked location if your project intentionally shares translation memory. The
reviewable `.internationalizer.lock` manifest is versioned separately.
Manifest schema v2 records provenance origin and review status independently,
so “generated successfully” and “approved by a person” cannot be conflated.

## Supported Formats

Expand All @@ -394,6 +447,7 @@ 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` | Preamble and H2-level sections |
| Fluent | `.ftl` | Semantic messages, terms, attributes, comments, and selectors |

Markdown targets contain invisible `internationalizer:unit` comments before
H2 sections. These stable markers let Internationalizer add, move, or edit one
Expand All @@ -415,7 +469,8 @@ cmd/internationalizer/ CLI entry point and command definitions
internal/
config/ YAML config loading with defaults
detect/ Project type auto-detection
formats/ Format parsers (JSON, YAML, Markdown)
fluentpattern/ Fluent pattern validation and safe text transforms
formats/ Format adapters (JSON, YAML, Markdown, Fluent)
glossary/ Per-locale glossary management
llm/ LLM provider interface + implementations
anthropic.go Anthropic Claude backend
Expand All @@ -425,6 +480,8 @@ internal/
locale/ BCP 47 identity and CLDR plural categories
message/ ICU MessageFormat parser and structural comparison
policy/ Stable translation-policy hashing
pseudo/ Provider-free accented and bidi test locales
review/ Explicit artifact approval workflow
state/ Versioned translation manifest
styleguide/ Style guide loader
tm/ JSONL translation memory
Expand Down
2 changes: 2 additions & 0 deletions cmd/internationalizer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ func main() {
newGlossaryCmd(),
newTmCmd(),
newValidateCmd(),
newReviewCmd(),
newPseudoCmd(),
)

if err := rootCmd.Execute(); err != nil {
Expand Down
52 changes: 52 additions & 0 deletions cmd/internationalizer/pseudo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"fmt"

"github.com/Tom-R-Main/Internationalizer/internal/config"
"github.com/Tom-R-Main/Internationalizer/internal/pseudo"
"github.com/spf13/cobra"
)

func newPseudoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "pseudo",
Short: "Generate deterministic accented or bidi pseudolocales",
RunE: func(cmd *cobra.Command, args []string) error {
cfgPath, _ := cmd.Flags().GetString("config")
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
strategyValue, _ := cmd.Flags().GetString("strategy")
locale, _ := cmd.Flags().GetString("locale")
force, _ := cmd.Flags().GetBool("force")
dryRun, _ := cmd.Flags().GetBool("dry-run")
results, err := pseudo.Generate(cfg, pseudo.GenerateOptions{
Strategy: pseudo.Strategy(strategyValue),
Locale: locale,
Force: force,
DryRun: dryRun,
})
if err != nil {
return err
}
for _, result := range results {
action := "generated"
if dryRun {
action = "would generate"
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s/%s: %d units -> %s\n", action, result.Bundle, result.Locale, result.Units, result.TargetPath); err != nil {
return err
}
}
return nil
},
}
cmd.Flags().StringP("config", "c", "", "path to config file (default: .internationalizer.yml)")
cmd.Flags().String("strategy", string(pseudo.Accented), "pseudo strategy (accented or bidi)")
cmd.Flags().StringP("locale", "l", "", "output locale (defaults to en-XA or ar-XB)")
cmd.Flags().Bool("force", false, "overwrite an existing artifact not owned by the pseudo generator")
cmd.Flags().Bool("dry-run", false, "show outputs without writing files or manifest state")
return cmd
}
100 changes: 100 additions & 0 deletions cmd/internationalizer/review.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package main

import (
"encoding/json"
"fmt"
"time"

"github.com/Tom-R-Main/Internationalizer/internal/config"
"github.com/Tom-R-Main/Internationalizer/internal/review"
"github.com/Tom-R-Main/Internationalizer/internal/state"
"github.com/spf13/cobra"
)

func newReviewCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "review",
Short: "Inspect and approve exact translation artifacts",
}
cmd.AddCommand(newReviewListCmd(), newReviewApproveCmd())
return cmd
}

func newReviewListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List tracked translations and review status",
RunE: func(cmd *cobra.Command, args []string) error {
cfgPath, _ := cmd.Flags().GetString("config")
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
manifest, err := state.Load(cfg.ManifestPath)
if err != nil {
return err
}
locale, _ := cmd.Flags().GetString("locale")
bundle, _ := cmd.Flags().GetString("bundle")
statusValue, _ := cmd.Flags().GetString("status")
entries, err := review.List(manifest, review.Filter{Locale: locale, Bundle: bundle, Status: state.ReviewStatus(statusValue)})
if err != nil {
return err
}
asJSON, _ := cmd.Flags().GetBool("json")
if asJSON {
encoder := json.NewEncoder(cmd.OutOrStdout())
encoder.SetIndent("", " ")
return encoder.Encode(entries)
}
for _, entry := range entries {
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\t%s\t%s\n", entry.ReviewStatus, entry.Locale, entry.Bundle, entry.Key, entry.Origin); err != nil {
return err
}
}
return nil
},
}
addReviewConfigFlag(cmd)
cmd.Flags().StringP("locale", "l", "", "filter by target locale")
cmd.Flags().String("bundle", "", "filter by bundle ID")
cmd.Flags().String("status", "", "filter by review status (needs_review or approved)")
cmd.Flags().Bool("json", false, "output entries as JSON")
return cmd
}

func newReviewApproveCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "approve",
Short: "Approve current translations after validation",
RunE: func(cmd *cobra.Command, args []string) error {
cfgPath, _ := cmd.Flags().GetString("config")
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
locale, _ := cmd.Flags().GetString("locale")
bundle, _ := cmd.Flags().GetString("bundle")
keys, _ := cmd.Flags().GetStringSlice("key")
all, _ := cmd.Flags().GetBool("all")
approved, err := review.Approve(cfg, review.Filter{Locale: locale, Bundle: bundle, Keys: keys, All: all}, time.Now())
if err != nil {
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Approved %d translation(s) for %s.\n", len(approved), locale)
return err
},
}
addReviewConfigFlag(cmd)
cmd.Flags().StringP("locale", "l", "", "target locale to approve")
cmd.Flags().String("bundle", "", "bundle ID (required with --key)")
cmd.Flags().StringSlice("key", nil, "individual translation key(s) to approve")
cmd.Flags().Bool("all", false, "approve every matching current translation")
_ = cmd.MarkFlagRequired("locale")
cmd.MarkFlagsMutuallyExclusive("key", "all")
return cmd
}

func addReviewConfigFlag(cmd *cobra.Command) {
cmd.Flags().StringP("config", "c", "", "path to config file (default: .internationalizer.yml)")
}
10 changes: 7 additions & 3 deletions cmd/internationalizer/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ func newValidateCmd() *cobra.Command {

Use --strict to require translated values and enforce extra-key, protected
structure, glossary, and configured plural rules. Use --require-state to verify
that source, policy, and target content still match the translation manifest.`,
that source, policy, and target content still match the translation manifest.
Use --require-approved to additionally require explicit human approval.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfgPath, _ := cmd.Flags().GetString("config")
cfg, err := config.Load(cfgPath)
Expand All @@ -30,9 +31,11 @@ that source, policy, and target content still match the translation manifest.`,

strict, _ := cmd.Flags().GetBool("strict")
requireState, _ := cmd.Flags().GetBool("require-state")
requireApproved, _ := cmd.Flags().GetBool("require-approved")
reports, err := validate.ValidateWithOptions(cfg, validate.Options{
Strict: strict,
RequireState: requireState,
Strict: strict,
RequireState: requireState,
RequireApproved: requireApproved,
})
if err != nil {
return err
Expand Down Expand Up @@ -65,6 +68,7 @@ that source, policy, and target content still match the translation manifest.`,
cmd.Flags().BoolP("quiet", "q", false, "exit code only, no output")
cmd.Flags().Bool("strict", false, "fail on untranslated values and strict policy findings")
cmd.Flags().Bool("require-state", false, "fail when translation manifest state is missing or stale")
cmd.Flags().Bool("require-approved", false, "fail unless current translations have explicit human approval")

return cmd
}
34 changes: 25 additions & 9 deletions docs/localization-core-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,32 @@ framework runtime, translation CDN, or source-rewriting setup command.

## Review transfer

Only an exact source-and-policy match may inherit review state. Fuzzy
candidates, changed policy, or changed message structure require review.
Manifest v2 records provenance origin and human review independently. Provider,
translation-memory, adoption, and pseudo output begin in `needs_review`; only an
exact current source, policy, and target artifact may be approved. Changed
source context, policy, message structure, or target content requires review.

## Source units

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. 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.
Structured documents pass through a format-neutral semantic-unit boundary.
Each unit has a stable semantic ID, kind, translator context, value, and
adapter-owned structure signature; formatting offsets are not identity. Fluent
resources use this boundary for message values, terms, and attributes while
preserving comments and ordering during serialization.

Markdown uses the preamble and each H2 section as independent units, with
invisible target-side markers that preserve identity when sections move or are
inserted.

## Test locales and rich text

- `pseudo` creates deterministic accented (`en-XA`) and bidirectional (`ar-XB`)
artifacts without a provider or translation-memory lookup.
- ICU and Fluent runtime expressions remain intact while linguistic text is
transformed.
- `data-l10n-name` identifies semantic rich-text slots. Translators may reorder
named slots, but element identity, protected attributes, nesting, and
contained markup must remain compatible.

## Policy identity

Expand All @@ -54,6 +70,6 @@ are never generated or rewritten by a translation run.
## Compatibility boundary

Existing JSON/YAML key workflows and i18next-v4 plural suffixes remain
supported. Canonical locale comparison may reject configurations that currently
spell the same locale more than once. ICU structural failures are deterministic
supported. Canonical locale comparison may reject configurations that spell the
same locale more than once. ICU or Fluent structural failures are deterministic
validation errors and block provider output from being written.
Loading