Files
zeekayandHanzo Dev e4e7ff52a6 core owns what a slot is and where a strings file lives
Two facts were about to be answered separately by four CLIs.

Slot parity first. {{amount}} surviving translation is what stands between a
person and a screen with a blank where a number goes, and the translator that
writes a locale and the gate that blocks the PR both have to mean the same thing
by "the slots match". They did not: translate compared with length plus
every(includes), which reads as a set, so a translation rendering {{amount}}
twice and dropping {{token}} passed the translator and failed the gate — a file
written by one tool that the other refuses.

parity() is the one definition. Sorted, because word order is the first thing a
translation changes and "{{amount}} of {{token}}" legitimately comes back as
"{{token}} im Wert von {{amount}}". Compared as a multiset, because same names in
the wrong number is still a wrong message. Names trimmed, because i18next trims
them and a translator who wrote {{ count }} has broken nothing. The regex refuses
to cross a brace, or "{{a}} }} {{b}}" reads as one enormous slot and two real ones
become none.

Then the layout. source(), translation() and contents() put the paths and the
file shape in one place instead of a template literal in each CLI, where one of
them eventually looks in a directory the others never fill and reports the whole
product untranslated. contents() also sorts: two tools write these files and a
person reviews the diff, so an unsorted write reorders forty strings to add one
and the review that catches a bad translation does not happen.

Strings, not node:path, so core still runs in a browser.

Mutations checked: set-instead-of-multiset fails the repeated-slot test, dropping
trim fails the spacing test, dropping sort fails the word-order test, a greedy
regex fails the stray-brace test, and dropping the sort or the trailing newline in
contents fails the file-shape tests.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-17 12:34:37 -07:00

35 lines
1.3 KiB
TypeScript

import assert from 'node:assert/strict'
import { test } from 'node:test'
import { contents, source, translation } from '../src/files.ts'
test('the layout is the same one three tools use', () => {
assert.equal(source('/p/strings', 'exchange'), '/p/strings/exchange/en-US.json')
assert.equal(translation('/p/out', 'ja-JP', 'exchange'), '/p/out/ja-JP/exchange.json')
})
test('a strings file is sorted', () => {
// Two tools write these and a person reviews the diff. Unsorted, adding one
// string reorders forty and the review that catches a bad translation does
// not happen.
assert.equal(contents({ z: 'Z', a: 'A' }), '{\n "a": "A",\n "z": "Z"\n}\n')
assert.equal(contents({ a: 'A', z: 'Z' }), contents({ z: 'Z', a: 'A' }))
})
test('a strings file ends in a newline', () => {
// Or every one of them is a "\ No newline at end of file" line in the diff.
assert.ok(contents({ a: 'A' }).endsWith('}\n'))
assert.equal(contents({}), '{}\n')
})
test('keys that look like numbers still sort as keys', () => {
// JavaScript hoists integer-like keys to the front of an object regardless of
// insertion order, so `10` sorting before `2` is the engine, not the compare.
assert.deepEqual(Object.keys(JSON.parse(contents({ b: 'B', '10': 'T', '2': 'W', a: 'A' }))), [
'2',
'10',
'a',
'b',
])
})