Skip to content

Keep a YAML plural's existing flat or nested shape when writing - #133

Merged
arvida merged 6 commits into
mainfrom
fix/yaml-keep-plural-shape
Oct 11, 2026
Merged

arvida merged 6 commits into
mainfrom
fix/yaml-keep-plural-shape

Conversation

@arvida

@arvida arvida commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Part of localheroai/localhero-ai#916 (see the "Plural shape" comment there).

Bug

The YAML writer replaced an existing plural's shape with whatever arrived. A flat count: "%{count} 件" (the rails-i18n convention for ja/zh/th/vi/ko) became count:\n other: … when the sync API sent count.other, and a nested plural became flat when a flat value arrived, dropping sibling forms. Either way the writer fell back to a full-document rewrite, so comments, quoting and blank lines could change. BeTrained keeps these plurals flat, so any PR-page sync or Export touching one would have hit this.

Fix

  • A lone incoming form is written into the shape the file already has: B.other into a flat B, a flat B into B.other of a plural map. This goes through the splice path, so the rest of the file stays byte-identical.
  • Only for a plural the source confirms: B must be a plural group in the source file (same rule as yamlPluralGroupBasesFromTree). A plural map must hold only CLDR categories with scalar values. When the source is unknown or ambiguous, the write behaves as before.
  • CI sync and pull find the source by exact path pairing: multi-language file, generateTargetPath, or a locale-folder swap. A source listed twice counts once; two different matches mean no source.
  • A key that is itself a source plural base (messages.other with one/other) is not read as the .other of its parent.

Tests

New yaml-plural-shape.test.ts, plus source-pairing tests in translation-utils, ci and sync-service. Lint, build and 1547 tests pass. Codex reviewed it over six rounds; the last was clean.

Follow-up, not here

findTargetFile, used by translate, has a loose fallback: it pairs by prefix and basename, so en/views/common.yml can match ja/models/common.yml. This PR doesn't depend on it.

- A lone incoming `x.other` is written into a flat scalar at `x`, and a lone
  flat `x` into `x.other` of a map holding only CLDR plural categories
- Shape-preserving writes go through the splice path, so the rest of the
  file stays byte-identical instead of falling back to a full rewrite
- New keys, several forms arriving for one key, and maps with non-plural
  children keep today's behavior
- Covers multi-language files and deeply nested keys

Refs localheroai/localhero-ai#916
- Rename a lone `x.other` to flat `x`, or flat `x` to `x.other`, only when
  the source file holds `x` as a Rails plural group (yamlPluralGroupBasesFromTree);
  with no source or a non-plural key, write the keys as they arrive
- A target map counts as a plural only when every child is a CLDR category
  holding a scalar, so a namespace like `errors.one.title` is left alone
- CI sync passes each target's source file, paired via generateTargetPath
  (or the same file for multi-language files) instead of no source
- A source file with repeated keys is still read

Refs localheroai/localhero-ai#916
- findSourceFileForTarget now asks findTargetFile whether each source
  pairs with the target, instead of comparing generated target paths
- Locale-folder layouts such as locales/en/en.yml -> locales/ja/ja.yml and
  locales/en/models/lessons.yml -> locales/ja/models/lessons.yml now find
  their source, so CI sync keeps their plural shape too

Refs localheroai/localhero-ai#916
- findSourceFileForTarget no longer uses findTargetFile's loose fallback,
  which could pair locales/ja/models/common.yml with locales/en/views/common.yml
- A source matches when its multi-language path, generated target path, or
  path with a locale folder swapped equals the target's full path
- No match, or more than one, gives no source, so the write keeps the keys
  as they arrive

Refs localheroai/localhero-ai#916
- pull passes a YAML target the source it is exactly translated from, via
  findSourceFileForTarget, instead of the last source in its folder; JSON and
  PO keep the folder lookup they use for new files
- A key the source holds as a plural base is never read as `.other` of its
  parent, so a flat `messages.other` writes into `messages.other.other`
  instead of replacing the plural

Refs localheroai/localhero-ai#916
- Overlapping translationFiles.paths can list the same source file twice;
  findSourceFileForTarget now compares resolved paths, so that is not
  mistaken for two sources and the target still gets its source
- Two different sources pairing with one target still find nothing

Refs localheroai/localhero-ai#916
@arvida
arvida merged commit fc8bb93 into main Oct 11, 2026
1 check passed
@arvida arvida mentioned this pull request Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant