The core of Loco is a keyed string store with a locale matrix on top, and that part is genuinely a one-sitting build: SQLite, a table view, an import and an export. What eats the weekend is the format layer, because gettext PO with plural headers, Apple .strings and .stringsdict, Android plurals XML, ICU messages and flat JSON all disagree about pluralization and escaping in ways that only show up in production. Machine translation fill-in is a single API call if you already have a DeepL or provider key. If you are the only translator, a local build is fine and arguably faster to work with. If you send links to freelance translators and rely on the hosted API in CI, you are rebuilding a service, not a tool.
You are building a lean indie version of Loco. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Loco · indie build A local translation manager whose hard problems are a plural model that comes from locale data rather than from English, making translator context mandatory instead of optional, and export adapters whose escaping and plural indexing are verified by round trip rather than by hope. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 20 and TypeScript with a local web editor, one folder | if you are your own translator this should be faster than a hosted grid | | Storage | SQLite, one row per message per plural category | the shape of the table is the whole argument of this build | | Locale data | CLDR plural rules from a maintained package, version recorded per project | hand-written plural rules are wrong for the languages you do not speak | | Internal form | ICU MessageFormat as the stored representation for every message | one representation means escaping is implemented once per adapter, not per pair | | Adapters | One module per format, each with an import, an export and a round-trip test | the adapters are the product and the test is what makes them trustworthy | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node 20 or newer with TypeScript** · free - Why: Runs the editor, the adapters and the command line. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v20 or higher - [ ] **A maintained package providing CLDR plural rules** · free - Why: Every category set in the project comes from it, and writing these rules by hand is wrong for exactly the languages you cannot check. - Get it: Install a package that exposes cardinal plural categories per locale and exposes its data version. - Verify: Querying a Slavic locale returns four categories and Japanese returns one - [ ] **Your existing locale files, including the messy ones** · free - Why: Adapter correctness is only demonstrable against files that real tools wrote, with the comments, escapes and obsolete entries they leave behind. - Get it: Copy your current locale files into a fixtures directory, including at least one PO with plurals and one Android resource file. - Verify: Each imports and round trips without entries in the skipped list - [ ] **Which formats you actually ship** · free - Why: Each adapter is a small ongoing commitment and the long tail is where a hosted product earns its money, so supporting three well beats supporting eight badly. - Get it: List the platforms you ship to and write down the formats that follows, and which you are deliberately not supporting. - Verify: The list is in the README and matches the adapters in the source - [ ] **Whether you need the Apple string catalogue format** (optional) · free - Why: Apple's recommended format changed and the older files are still produced during a build, so the adapter you need depends on where in that chain you plug in. - Get it: Read Apple's current localisation documentation, decide which artefact your project consumes, and record the date you checked. - Verify: The decision and the date are in the README ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - A hosted editor with translator accounts. - Machine translation as a silent default. - Translation memory and glossary suggestions. - The long tail of platform formats. - Format breadth: Loco reads and writes a long tail of formats, including the awkward Apple and Qt ones, with plural rules per locale already correct - A shareable editor for non-technical translators, with roles, comments and progress tracking - A stable public API and CLI that CI pipelines can pull from at build time - Translation history, who changed what, and the ability to roll a string back - Glossary and translation memory suggestions that improve as the project grows If one of those is essential to you, that is the reason to keep paying for Loco, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Loco The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a translation store whose exports get plurals, context and escaping right for each platform to replace Loco. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The database is not the hard part. Plurals are, and getting them wrong ships mangled text to people who cannot tell you about it. The Unicode locale data defines six plural category tags, zero one two few many other, and only 'other' is required. Its own specification warns that these are mnemonics: a common mistake is to think 'one' is only for the number 1, when it is the category for any number that behaves like 1. Languages use different subsets: English two, the Slavic languages typically four, Arabic and Welsh six. So there is no singular column and no plural column in this schema. A counted message has one row per category the target locale actually uses, and that set comes from the locale data, never from your source language. The formats then disagree about how to say it. Android's string resources use the same category names but select by grammatical necessity, so its documentation notes a 'zero' string in English is ignored even when the quantity is 0, and that in Korean only 'other' is ever used. gettext uses no names at all: the header carries nplurals and a C expression over n, and translations are msgstr[0] upwards in whatever order that expression produces. Apple's current recommended format is the string catalogue, which replaced the older pair of files; check Apple's own documentation for what it compiles to before writing that adapter. Converting between these is a per-locale mapping, not a rename. Derive it, verify it, or refuse. ### Stack (fixed, do not substitute) - Runtime: Node 20 and TypeScript with a local web editor, one folder · if you are your own translator this should be faster than a hosted grid - Storage: SQLite, one row per message per plural category · the shape of the table is the whole argument of this build - Locale data: CLDR plural rules from a maintained package, version recorded per project · hand-written plural rules are wrong for the languages you do not speak - Internal form: ICU MessageFormat as the stored representation for every message · one representation means escaping is implemented once per adapter, not per pair - Adapters: One module per format, each with an import, an export and a round-trip test · the adapters are the product and the test is what makes them trustworthy ### Data model (create this before Phase 1) - projects(id, name, source_locale, cldr_version, created_at) · the locale data version is recorded because the rules change - locales(project_id, code, plural_categories_json, is_source, required) · categories come from the locale data, never typed by a human - keys(id, project_id, key, context, description, screenshot_path, placeholders_json, max_length, created_at) - messages(id, key_id, locale, category, text, state, updated_at, updated_by) · category is a CLDR tag, 'other' for anything uncounted - state is one of untranslated, machine, needs_review or approved, and machine is never silently promoted - imports(id, project_id, locale, format, file_sha256, at, added, changed, unchanged, skipped_json) · skipped is what the adapter did not understand - history(message_id, ordinal, text, state, at, by) · rolling one string back is most of why a translation manager exists - There is no singular column and no plural column anywhere, only a category whose allowed values come from the locale's own rules. ### Phase 1 · Plural categories from the locale data Build: Load plural rules from a maintained locale data package and store the category set per locale with the data version. Render a counted message as one box per category in that set. Reject any message row whose category is not in its locale's set, with a database constraint rather than a check in the form handler. Trap: Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. Trap: Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. Done when: Adding a locale with four categories produces four editable boxes for a counted message with no code change, tested with a Slavic locale. A locale whose rules give it only 'other' shows one box, tested. A message row carrying a category outside its locale's set is rejected by a constraint, tested by inserting directly. The locale data version is stored per project and displayed in the interface. Do not build yet: No context rules, adapters, gettext handling or command line yet. ### Phase 2 · Context is a required field Build: Require a description and the list of placeholders when a key is created. Reject a translation whose placeholder set differs from the key's declaration. Carry the description into each format's comment mechanism on export. Allow a screenshot per key and show it beside the editing boxes. Trap: Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. Trap: A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. Done when: Creating a key without a description is refused. A translation whose placeholder set differs from the key's declaration is rejected at save, tested with a missing placeholder and with an extra one. An exported gettext file carries the description as a translator comment and an exported Android resource carries it as a comment above the string, both checked against fixtures. A key with a screenshot shows it beside the editing boxes. Do not build yet: No format adapters, gettext plural mapping or command line yet. ### Phase 3 · One internal form, adapters at the edges Build: Store messages as ICU MessageFormat. Each adapter converts in both directions and does its escaping in exactly one place. Test every adapter by importing a fixture, exporting it and importing the result, comparing messages. Record anything an adapter could not represent in the import's skipped list and show it after the import instead of dropping it. Trap: Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. Trap: Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. Done when: For every supported format, import then export then import produces identical messages, tested with a fixture containing straight and curly quotes, an apostrophe, a newline, a leading at sign and a literal percent. Anything an adapter cannot represent appears in the import's skipped list with its source line and is shown after the import. Exporting a locale whose categories the target format cannot express fails with the key named rather than writing an approximation. No escaping logic appears in more than one place per adapter, verified by review. Do not build yet: No gettext plural index mapping or command line yet. ### Phase 4 · gettext, where the categories have no names Build: Parse the Plural-Forms header, evaluate its expression over a range of integers for the locale, and derive which index corresponds to which named category. Compare that mapping against the locale data's categories and refuse the import when they disagree, quoting the header. Store the derived mapping with the import so an export can reproduce it. Trap: Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. Trap: Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. Done when: Importing a PO for a language with three forms assigns each index to the correct named category, tested against fixtures for at least two such languages. A PO whose header contradicts the locale data is refused with the header quoted and both category sets shown. Exporting writes an nplurals and expression consistent with the categories stored, and reimporting the exported file reproduces the same mapping. A PO with no Plural-Forms header is refused rather than defaulted. Do not build yet: No command line, history or completeness reporting yet. ### Phase 5 · Into a build, and back out of a mistake Build: A command that exports a locale to a path, exits non-zero when a required locale has untranslated keys and names them. Make exports deterministic: sorted, normalised line endings, byte-identical between two runs with no change. Per-string history with rollback that appends rather than deletes. A completeness report counting by category so a locale missing only one form is visible. Trap: An export whose key ordering or whitespace shifts between runs produces a diff on every build, people stop reading those diffs, and the bad string ships inside one of them. Sort deterministically, normalise line endings, and put a test in the suite that exports twice and compares bytes. Trap: A completeness percentage computed over keys rather than over category rows reports a locale as finished while its 'few' forms are all empty, which is exactly the failure this whole build exists to prevent. Count rows per category per locale, and show the missing categories by name. Done when: Two exports with no intervening change produce byte-identical files, tested. The command exits non-zero when a required locale has untranslated keys and names them, tested in a pipeline-like invocation. Rolling a message back appends a history entry rather than deleting one, and the previous value remains visible. The completeness report counts by category and a locale missing only its 'few' forms is reported as incomplete with that category named. Do not build yet: Nothing after this. ### Out of scope (and why) - A hosted editor with accounts for external translators, which is the point where this stops being a tool and becomes a service with uptime and access control to run. - Machine translation as a default fill, which is fine as an explicitly marked state a human must promote, and dishonest as a silent one that reports a locale as complete. - Translation memory and glossary suggestions, which need a corpus and are worth little on a project small enough to run from a folder. - The long tail of formats, including the awkward desktop toolkit ones; support the platforms you actually ship to and say which. ### README must contain - That there is no singular or plural column and that category sets come from the locale data, with the version recorded - That a description and a placeholder list are required on every key and why a bare word is untranslatable - That gettext plural indices are derived from the file's own header expression and a contradiction is refused - That exports are byte-deterministic, and the command that fails a build on an incomplete required locale - That machine-filled strings carry their own state and are never counted as translated ===== AGENTS.md ===== # Agent instructions · Loco indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 20 and TypeScript with a local web editor, one folder, SQLite, one row per message per plural category, CLDR plural rules from a maintained package, version recorded per project, ICU MessageFormat as the stored representation for every message, One module per format, each with an import, an export and a round-trip test. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. - Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. - Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. - A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. - Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. - Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. - Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. - Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. ===== BUILD_PLAN.md ===== # Build plan · Loco A local translation manager whose hard problems are a plural model that comes from locale data rather than from English, making translator context mandatory instead of optional, and export adapters whose escaping and plural indexing are verified by round trip rather than by hope. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Plural categories from the locale data Adding a locale produces exactly the editing boxes its plural rules require, with no code change. ### Steps 1. Take plural rules from a maintained package Your hand-written table is wrong for languages you cannot check. Files: `src/locales/plural-rules.ts`, `package.json` 2. Store one message row per category The schema is where this either works or does not. Files: `src/db/schema.sql` 3. Enforce the category set with a constraint Form validation gets bypassed by the import path. Files: `tests/categories.test.ts` ### Done when - [ ] A four-category locale renders four boxes with no code change - [ ] An other-only locale renders one box - [ ] An out-of-set category is rejected by a constraint - [ ] The locale data version is stored and shown ### Watch out - Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. - Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. ## Phase 2 · Context is a required field No key can be published without a description and a declared placeholder set, and both travel into every export. ### Steps 1. Require description and placeholders at creation A key with no context is a key that comes back wrong. Files: `src/keys/create.ts` 2. Reject placeholder mismatches at save A dropped placeholder is a crash, not a typo. Files: `src/messages/validate.ts`, `tests/placeholders.test.ts` 3. Carry the description into every export comment The context is useless if it stays in your database. Files: `src/adapters/comments.ts` ### Done when - [ ] A key without a description cannot be created - [ ] A placeholder mismatch is rejected with both sets shown - [ ] Descriptions appear as comments in exported files - [ ] Screenshots render beside the editing boxes ### Watch out - Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. - A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. ## Phase 3 · One internal form, adapters at the edges Every format converts to and from one stored representation, and every adapter round trips a hostile fixture. ### Steps 1. Store one internal representation Pairwise conversion between formats is a combinatorial trap. Files: `src/messages/icu.ts` 2. Put escaping in one function per adapter Two copies means one of them is stale. Files: `src/adapters/android.ts`, `src/adapters/json.ts` 3. Round trip a hostile fixture per format Quotes and apostrophes are where adapters actually break. Files: `tests/roundtrip.test.ts`, `tests/fixtures/` ### Done when - [ ] Every format round trips the hostile fixture identically - [ ] Unrepresentable constructs are recorded and shown - [ ] An impossible category set fails the export by key name - [ ] Escaping lives in one place per adapter ### Watch out - Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. - Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. ## Phase 4 · gettext, where the categories have no names PO files import and export with their plural indices correctly mapped to named categories. ### Steps 1. Evaluate the header expression to derive the mapping The file's own rule is the only thing that defines the order. Files: `src/adapters/po/plural-forms.ts`, `tests/po-plurals.test.ts` 2. Refuse when the header contradicts the locale data Guessing here puts wrong text in front of real readers. Files: `src/adapters/po/import.ts` 3. Store the derived mapping with the import Export has to reproduce what the project already uses. Files: `src/adapters/po/export.ts` ### Done when - [ ] Three-form PO indices map to the correct categories - [ ] A contradicting header is refused with both sets shown - [ ] Export writes a consistent header and reimports identically - [ ] A missing Plural-Forms header is refused ### Watch out - Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. - Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. ## Not in this build - A hosted editor with translator accounts. - Machine translation as a silent default. - Translation memory and glossary suggestions. - The long tail of platform formats. ## After v1, if you want it - Ordinal and range plural support, which are separate rule sets in the same locale data and matter for anything with a leaderboard - A read-only review link for one locale, which is the smallest useful step towards a translator who will not install anything - Pseudolocalisation that expands strings and marks placeholders, which finds truncation before a real translation does ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Projects, keys, messages and history. This is the translation memory you have. DATABASE_PATH=./data/loco.db # Required. Per-key screenshots shown beside the editing boxes. Back up with the database. SCREENSHOT_DIR=./data/screenshots # Required. Recorded on each project so a changed category set is explainable. CLDR_PACKAGE_VERSION=46.0.0 # Required. The command line exits non-zero when any of these has untranslated keys. REQUIRED_LOCALES=en,fr,pl # Required. Where the command line writes. Exports are deterministic and safe to commit. EXPORT_DIR=./locales # Optional · secret. Optional machine translation. Filled cells are marked machine and never counted as translated. MT_API_KEY=redacted
You are building a lean indie version of Loco. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Loco · indie build A local translation manager whose hard problems are a plural model that comes from locale data rather than from English, making translator context mandatory instead of optional, and export adapters whose escaping and plural indexing are verified by round trip rather than by hope. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 20 and TypeScript with a local web editor, one folder | if you are your own translator this should be faster than a hosted grid | | Storage | SQLite, one row per message per plural category | the shape of the table is the whole argument of this build | | Locale data | CLDR plural rules from a maintained package, version recorded per project | hand-written plural rules are wrong for the languages you do not speak | | Internal form | ICU MessageFormat as the stored representation for every message | one representation means escaping is implemented once per adapter, not per pair | | Adapters | One module per format, each with an import, an export and a round-trip test | the adapters are the product and the test is what makes them trustworthy | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node 20 or newer with TypeScript** · free - Why: Runs the editor, the adapters and the command line. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v20 or higher - [ ] **A maintained package providing CLDR plural rules** · free - Why: Every category set in the project comes from it, and writing these rules by hand is wrong for exactly the languages you cannot check. - Get it: Install a package that exposes cardinal plural categories per locale and exposes its data version. - Verify: Querying a Slavic locale returns four categories and Japanese returns one - [ ] **Your existing locale files, including the messy ones** · free - Why: Adapter correctness is only demonstrable against files that real tools wrote, with the comments, escapes and obsolete entries they leave behind. - Get it: Copy your current locale files into a fixtures directory, including at least one PO with plurals and one Android resource file. - Verify: Each imports and round trips without entries in the skipped list - [ ] **Which formats you actually ship** · free - Why: Each adapter is a small ongoing commitment and the long tail is where a hosted product earns its money, so supporting three well beats supporting eight badly. - Get it: List the platforms you ship to and write down the formats that follows, and which you are deliberately not supporting. - Verify: The list is in the README and matches the adapters in the source - [ ] **Whether you need the Apple string catalogue format** (optional) · free - Why: Apple's recommended format changed and the older files are still produced during a build, so the adapter you need depends on where in that chain you plug in. - Get it: Read Apple's current localisation documentation, decide which artefact your project consumes, and record the date you checked. - Verify: The decision and the date are in the README ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - A hosted editor with translator accounts. - Machine translation as a silent default. - Translation memory and glossary suggestions. - The long tail of platform formats. - Format breadth: Loco reads and writes a long tail of formats, including the awkward Apple and Qt ones, with plural rules per locale already correct - A shareable editor for non-technical translators, with roles, comments and progress tracking - A stable public API and CLI that CI pipelines can pull from at build time - Translation history, who changed what, and the ability to roll a string back - Glossary and translation memory suggestions that improve as the project grows If one of those is essential to you, that is the reason to keep paying for Loco, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Loco The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a translation store whose exports get plurals, context and escaping right for each platform to replace Loco. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The database is not the hard part. Plurals are, and getting them wrong ships mangled text to people who cannot tell you about it. The Unicode locale data defines six plural category tags, zero one two few many other, and only 'other' is required. Its own specification warns that these are mnemonics: a common mistake is to think 'one' is only for the number 1, when it is the category for any number that behaves like 1. Languages use different subsets: English two, the Slavic languages typically four, Arabic and Welsh six. So there is no singular column and no plural column in this schema. A counted message has one row per category the target locale actually uses, and that set comes from the locale data, never from your source language. The formats then disagree about how to say it. Android's string resources use the same category names but select by grammatical necessity, so its documentation notes a 'zero' string in English is ignored even when the quantity is 0, and that in Korean only 'other' is ever used. gettext uses no names at all: the header carries nplurals and a C expression over n, and translations are msgstr[0] upwards in whatever order that expression produces. Apple's current recommended format is the string catalogue, which replaced the older pair of files; check Apple's own documentation for what it compiles to before writing that adapter. Converting between these is a per-locale mapping, not a rename. Derive it, verify it, or refuse. ### Stack (fixed, do not substitute) - Runtime: Node 20 and TypeScript with a local web editor, one folder · if you are your own translator this should be faster than a hosted grid - Storage: SQLite, one row per message per plural category · the shape of the table is the whole argument of this build - Locale data: CLDR plural rules from a maintained package, version recorded per project · hand-written plural rules are wrong for the languages you do not speak - Internal form: ICU MessageFormat as the stored representation for every message · one representation means escaping is implemented once per adapter, not per pair - Adapters: One module per format, each with an import, an export and a round-trip test · the adapters are the product and the test is what makes them trustworthy ### Data model (create this before Phase 1) - projects(id, name, source_locale, cldr_version, created_at) · the locale data version is recorded because the rules change - locales(project_id, code, plural_categories_json, is_source, required) · categories come from the locale data, never typed by a human - keys(id, project_id, key, context, description, screenshot_path, placeholders_json, max_length, created_at) - messages(id, key_id, locale, category, text, state, updated_at, updated_by) · category is a CLDR tag, 'other' for anything uncounted - state is one of untranslated, machine, needs_review or approved, and machine is never silently promoted - imports(id, project_id, locale, format, file_sha256, at, added, changed, unchanged, skipped_json) · skipped is what the adapter did not understand - history(message_id, ordinal, text, state, at, by) · rolling one string back is most of why a translation manager exists - There is no singular column and no plural column anywhere, only a category whose allowed values come from the locale's own rules. ### Phase 1 · Plural categories from the locale data Build: Load plural rules from a maintained locale data package and store the category set per locale with the data version. Render a counted message as one box per category in that set. Reject any message row whose category is not in its locale's set, with a database constraint rather than a check in the form handler. Trap: Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. Trap: Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. Done when: Adding a locale with four categories produces four editable boxes for a counted message with no code change, tested with a Slavic locale. A locale whose rules give it only 'other' shows one box, tested. A message row carrying a category outside its locale's set is rejected by a constraint, tested by inserting directly. The locale data version is stored per project and displayed in the interface. Do not build yet: No context rules, adapters, gettext handling or command line yet. ### Phase 2 · Context is a required field Build: Require a description and the list of placeholders when a key is created. Reject a translation whose placeholder set differs from the key's declaration. Carry the description into each format's comment mechanism on export. Allow a screenshot per key and show it beside the editing boxes. Trap: Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. Trap: A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. Done when: Creating a key without a description is refused. A translation whose placeholder set differs from the key's declaration is rejected at save, tested with a missing placeholder and with an extra one. An exported gettext file carries the description as a translator comment and an exported Android resource carries it as a comment above the string, both checked against fixtures. A key with a screenshot shows it beside the editing boxes. Do not build yet: No format adapters, gettext plural mapping or command line yet. ### Phase 3 · One internal form, adapters at the edges Build: Store messages as ICU MessageFormat. Each adapter converts in both directions and does its escaping in exactly one place. Test every adapter by importing a fixture, exporting it and importing the result, comparing messages. Record anything an adapter could not represent in the import's skipped list and show it after the import instead of dropping it. Trap: Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. Trap: Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. Done when: For every supported format, import then export then import produces identical messages, tested with a fixture containing straight and curly quotes, an apostrophe, a newline, a leading at sign and a literal percent. Anything an adapter cannot represent appears in the import's skipped list with its source line and is shown after the import. Exporting a locale whose categories the target format cannot express fails with the key named rather than writing an approximation. No escaping logic appears in more than one place per adapter, verified by review. Do not build yet: No gettext plural index mapping or command line yet. ### Phase 4 · gettext, where the categories have no names Build: Parse the Plural-Forms header, evaluate its expression over a range of integers for the locale, and derive which index corresponds to which named category. Compare that mapping against the locale data's categories and refuse the import when they disagree, quoting the header. Store the derived mapping with the import so an export can reproduce it. Trap: Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. Trap: Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. Done when: Importing a PO for a language with three forms assigns each index to the correct named category, tested against fixtures for at least two such languages. A PO whose header contradicts the locale data is refused with the header quoted and both category sets shown. Exporting writes an nplurals and expression consistent with the categories stored, and reimporting the exported file reproduces the same mapping. A PO with no Plural-Forms header is refused rather than defaulted. Do not build yet: No command line, history or completeness reporting yet. ### Phase 5 · Into a build, and back out of a mistake Build: A command that exports a locale to a path, exits non-zero when a required locale has untranslated keys and names them. Make exports deterministic: sorted, normalised line endings, byte-identical between two runs with no change. Per-string history with rollback that appends rather than deletes. A completeness report counting by category so a locale missing only one form is visible. Trap: An export whose key ordering or whitespace shifts between runs produces a diff on every build, people stop reading those diffs, and the bad string ships inside one of them. Sort deterministically, normalise line endings, and put a test in the suite that exports twice and compares bytes. Trap: A completeness percentage computed over keys rather than over category rows reports a locale as finished while its 'few' forms are all empty, which is exactly the failure this whole build exists to prevent. Count rows per category per locale, and show the missing categories by name. Done when: Two exports with no intervening change produce byte-identical files, tested. The command exits non-zero when a required locale has untranslated keys and names them, tested in a pipeline-like invocation. Rolling a message back appends a history entry rather than deleting one, and the previous value remains visible. The completeness report counts by category and a locale missing only its 'few' forms is reported as incomplete with that category named. Do not build yet: Nothing after this. ### Out of scope (and why) - A hosted editor with accounts for external translators, which is the point where this stops being a tool and becomes a service with uptime and access control to run. - Machine translation as a default fill, which is fine as an explicitly marked state a human must promote, and dishonest as a silent one that reports a locale as complete. - Translation memory and glossary suggestions, which need a corpus and are worth little on a project small enough to run from a folder. - The long tail of formats, including the awkward desktop toolkit ones; support the platforms you actually ship to and say which. ### README must contain - That there is no singular or plural column and that category sets come from the locale data, with the version recorded - That a description and a placeholder list are required on every key and why a bare word is untranslatable - That gettext plural indices are derived from the file's own header expression and a contradiction is refused - That exports are byte-deterministic, and the command that fails a build on an incomplete required locale - That machine-filled strings carry their own state and are never counted as translated ===== AGENTS.md ===== # Agent instructions · Loco indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 20 and TypeScript with a local web editor, one folder, SQLite, one row per message per plural category, CLDR plural rules from a maintained package, version recorded per project, ICU MessageFormat as the stored representation for every message, One module per format, each with an import, an export and a round-trip test. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. - Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. - Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. - A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. - Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. - Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. - Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. - Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. ===== BUILD_PLAN.md ===== # Build plan · Loco A local translation manager whose hard problems are a plural model that comes from locale data rather than from English, making translator context mandatory instead of optional, and export adapters whose escaping and plural indexing are verified by round trip rather than by hope. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Plural categories from the locale data Adding a locale produces exactly the editing boxes its plural rules require, with no code change. ### Steps 1. Take plural rules from a maintained package Your hand-written table is wrong for languages you cannot check. Files: `src/locales/plural-rules.ts`, `package.json` 2. Store one message row per category The schema is where this either works or does not. Files: `src/db/schema.sql` 3. Enforce the category set with a constraint Form validation gets bypassed by the import path. Files: `tests/categories.test.ts` ### Done when - [ ] A four-category locale renders four boxes with no code change - [ ] An other-only locale renders one box - [ ] An out-of-set category is rejected by a constraint - [ ] The locale data version is stored and shown ### Watch out - Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. - Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. ## Phase 2 · Context is a required field No key can be published without a description and a declared placeholder set, and both travel into every export. ### Steps 1. Require description and placeholders at creation A key with no context is a key that comes back wrong. Files: `src/keys/create.ts` 2. Reject placeholder mismatches at save A dropped placeholder is a crash, not a typo. Files: `src/messages/validate.ts`, `tests/placeholders.test.ts` 3. Carry the description into every export comment The context is useless if it stays in your database. Files: `src/adapters/comments.ts` ### Done when - [ ] A key without a description cannot be created - [ ] A placeholder mismatch is rejected with both sets shown - [ ] Descriptions appear as comments in exported files - [ ] Screenshots render beside the editing boxes ### Watch out - Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. - A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. ## Phase 3 · One internal form, adapters at the edges Every format converts to and from one stored representation, and every adapter round trips a hostile fixture. ### Steps 1. Store one internal representation Pairwise conversion between formats is a combinatorial trap. Files: `src/messages/icu.ts` 2. Put escaping in one function per adapter Two copies means one of them is stale. Files: `src/adapters/android.ts`, `src/adapters/json.ts` 3. Round trip a hostile fixture per format Quotes and apostrophes are where adapters actually break. Files: `tests/roundtrip.test.ts`, `tests/fixtures/` ### Done when - [ ] Every format round trips the hostile fixture identically - [ ] Unrepresentable constructs are recorded and shown - [ ] An impossible category set fails the export by key name - [ ] Escaping lives in one place per adapter ### Watch out - Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. - Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. ## Phase 4 · gettext, where the categories have no names PO files import and export with their plural indices correctly mapped to named categories. ### Steps 1. Evaluate the header expression to derive the mapping The file's own rule is the only thing that defines the order. Files: `src/adapters/po/plural-forms.ts`, `tests/po-plurals.test.ts` 2. Refuse when the header contradicts the locale data Guessing here puts wrong text in front of real readers. Files: `src/adapters/po/import.ts` 3. Store the derived mapping with the import Export has to reproduce what the project already uses. Files: `src/adapters/po/export.ts` ### Done when - [ ] Three-form PO indices map to the correct categories - [ ] A contradicting header is refused with both sets shown - [ ] Export writes a consistent header and reimports identically - [ ] A missing Plural-Forms header is refused ### Watch out - Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. - Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. ## Not in this build - A hosted editor with translator accounts. - Machine translation as a silent default. - Translation memory and glossary suggestions. - The long tail of platform formats. ## After v1, if you want it - Ordinal and range plural support, which are separate rule sets in the same locale data and matter for anything with a leaderboard - A read-only review link for one locale, which is the smallest useful step towards a translator who will not install anything - Pseudolocalisation that expands strings and marks placeholders, which finds truncation before a real translation does ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Projects, keys, messages and history. This is the translation memory you have. DATABASE_PATH=./data/loco.db # Required. Per-key screenshots shown beside the editing boxes. Back up with the database. SCREENSHOT_DIR=./data/screenshots # Required. Recorded on each project so a changed category set is explainable. CLDR_PACKAGE_VERSION=46.0.0 # Required. The command line exits non-zero when any of these has untranslated keys. REQUIRED_LOCALES=en,fr,pl # Required. Where the command line writes. Exports are deterministic and safe to commit. EXPORT_DIR=./locales # Optional · secret. Optional machine translation. Filled cells are marked machine and never counted as translated. MT_API_KEY=redacted
You are building a production product version of Loco. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Loco · product brief ## Problem The core of Loco is a keyed string store with a locale matrix on top, and that part is genuinely a one-sitting build: SQLite, a table view, an import and an export. What eats the weekend is the format layer, because gettext PO with plural headers, Apple .strings and .stringsdict, Android plurals XML, ICU messages and flat JSON all disagree about pluralization and escaping in ways that only show up in production. Machine translation fill-in is a single API call if you already have a DeepL or provider key. If you are the only translator, a local build is fine and arguably faster to work with. If you send links to freelance translators and rely on the hosted API in CI, you are rebuilding a service, not a tool. ## Product outcome A translation store whose plural model comes from locale data rather than English, whose keys cannot ship without context, whose adapters are proven by round trip, and whose exports are deterministic enough to commit. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node 20 and a local folder to run in - Optional machine translation API key (DeepL or similar) in .env - Your existing locale files to import as a starting point ## Explicit non-goals for v1 - A hosted editor with translator accounts. - Machine translation as a silent default. - Translation memory and glossary suggestions. - The long tail of platform formats. - Format breadth: Loco reads and writes a long tail of formats, including the awkward Apple and Qt ones, with plural rules per locale already correct - A shareable editor for non-technical translators, with roles, comments and progress tracking - A stable public API and CLI that CI pipelines can pull from at build time - Translation history, who changed what, and the ability to roll a string back - Glossary and translation memory suggestions that improve as the project grows ## Success criteria - A four-category locale is confirmed to render four boxes with no code change - A placeholder mismatch is confirmed rejected at save - Every adapter is confirmed to round trip the hostile fixture - A contradicting Plural-Forms header is confirmed refused - Two unchanged exports are confirmed byte-identical ===== BRIEF.md ===== # Build brief · Loco The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a translation store whose exports get plurals, context and escaping right for each platform to replace Loco. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The database is not the hard part. Plurals are, and getting them wrong ships mangled text to people who cannot tell you about it. The Unicode locale data defines six plural category tags, zero one two few many other, and only 'other' is required. Its own specification warns that these are mnemonics: a common mistake is to think 'one' is only for the number 1, when it is the category for any number that behaves like 1. Languages use different subsets: English two, the Slavic languages typically four, Arabic and Welsh six. So there is no singular column and no plural column in this schema. A counted message has one row per category the target locale actually uses, and that set comes from the locale data, never from your source language. The formats then disagree about how to say it. Android's string resources use the same category names but select by grammatical necessity, so its documentation notes a 'zero' string in English is ignored even when the quantity is 0, and that in Korean only 'other' is ever used. gettext uses no names at all: the header carries nplurals and a C expression over n, and translations are msgstr[0] upwards in whatever order that expression produces. Apple's current recommended format is the string catalogue, which replaced the older pair of files; check Apple's own documentation for what it compiles to before writing that adapter. Converting between these is a per-locale mapping, not a rename. Derive it, verify it, or refuse. ### Stack (fixed, do not substitute) - Runtime: Node 20 and TypeScript with a local web editor, one folder · if you are your own translator this should be faster than a hosted grid - Storage: SQLite, one row per message per plural category · the shape of the table is the whole argument of this build - Locale data: CLDR plural rules from a maintained package, version recorded per project · hand-written plural rules are wrong for the languages you do not speak - Internal form: ICU MessageFormat as the stored representation for every message · one representation means escaping is implemented once per adapter, not per pair - Adapters: One module per format, each with an import, an export and a round-trip test · the adapters are the product and the test is what makes them trustworthy ### Data model (create this before Phase 1) - projects(id, name, source_locale, cldr_version, created_at) · the locale data version is recorded because the rules change - locales(project_id, code, plural_categories_json, is_source, required) · categories come from the locale data, never typed by a human - keys(id, project_id, key, context, description, screenshot_path, placeholders_json, max_length, created_at) - messages(id, key_id, locale, category, text, state, updated_at, updated_by) · category is a CLDR tag, 'other' for anything uncounted - state is one of untranslated, machine, needs_review or approved, and machine is never silently promoted - imports(id, project_id, locale, format, file_sha256, at, added, changed, unchanged, skipped_json) · skipped is what the adapter did not understand - history(message_id, ordinal, text, state, at, by) · rolling one string back is most of why a translation manager exists - There is no singular column and no plural column anywhere, only a category whose allowed values come from the locale's own rules. ### Phase 1 · Plural categories from the locale data Build: Load plural rules from a maintained locale data package and store the category set per locale with the data version. Render a counted message as one box per category in that set. Reject any message row whose category is not in its locale's set, with a database constraint rather than a check in the form handler. Trap: Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. Trap: Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. Done when: Adding a locale with four categories produces four editable boxes for a counted message with no code change, tested with a Slavic locale. A locale whose rules give it only 'other' shows one box, tested. A message row carrying a category outside its locale's set is rejected by a constraint, tested by inserting directly. The locale data version is stored per project and displayed in the interface. Do not build yet: No context rules, adapters, gettext handling or command line yet. ### Phase 2 · Context is a required field Build: Require a description and the list of placeholders when a key is created. Reject a translation whose placeholder set differs from the key's declaration. Carry the description into each format's comment mechanism on export. Allow a screenshot per key and show it beside the editing boxes. Trap: Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. Trap: A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. Done when: Creating a key without a description is refused. A translation whose placeholder set differs from the key's declaration is rejected at save, tested with a missing placeholder and with an extra one. An exported gettext file carries the description as a translator comment and an exported Android resource carries it as a comment above the string, both checked against fixtures. A key with a screenshot shows it beside the editing boxes. Do not build yet: No format adapters, gettext plural mapping or command line yet. ### Phase 3 · One internal form, adapters at the edges Build: Store messages as ICU MessageFormat. Each adapter converts in both directions and does its escaping in exactly one place. Test every adapter by importing a fixture, exporting it and importing the result, comparing messages. Record anything an adapter could not represent in the import's skipped list and show it after the import instead of dropping it. Trap: Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. Trap: Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. Done when: For every supported format, import then export then import produces identical messages, tested with a fixture containing straight and curly quotes, an apostrophe, a newline, a leading at sign and a literal percent. Anything an adapter cannot represent appears in the import's skipped list with its source line and is shown after the import. Exporting a locale whose categories the target format cannot express fails with the key named rather than writing an approximation. No escaping logic appears in more than one place per adapter, verified by review. Do not build yet: No gettext plural index mapping or command line yet. ### Phase 4 · gettext, where the categories have no names Build: Parse the Plural-Forms header, evaluate its expression over a range of integers for the locale, and derive which index corresponds to which named category. Compare that mapping against the locale data's categories and refuse the import when they disagree, quoting the header. Store the derived mapping with the import so an export can reproduce it. Trap: Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. Trap: Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. Done when: Importing a PO for a language with three forms assigns each index to the correct named category, tested against fixtures for at least two such languages. A PO whose header contradicts the locale data is refused with the header quoted and both category sets shown. Exporting writes an nplurals and expression consistent with the categories stored, and reimporting the exported file reproduces the same mapping. A PO with no Plural-Forms header is refused rather than defaulted. Do not build yet: No command line, history or completeness reporting yet. ### Phase 5 · Into a build, and back out of a mistake Build: A command that exports a locale to a path, exits non-zero when a required locale has untranslated keys and names them. Make exports deterministic: sorted, normalised line endings, byte-identical between two runs with no change. Per-string history with rollback that appends rather than deletes. A completeness report counting by category so a locale missing only one form is visible. Trap: An export whose key ordering or whitespace shifts between runs produces a diff on every build, people stop reading those diffs, and the bad string ships inside one of them. Sort deterministically, normalise line endings, and put a test in the suite that exports twice and compares bytes. Trap: A completeness percentage computed over keys rather than over category rows reports a locale as finished while its 'few' forms are all empty, which is exactly the failure this whole build exists to prevent. Count rows per category per locale, and show the missing categories by name. Done when: Two exports with no intervening change produce byte-identical files, tested. The command exits non-zero when a required locale has untranslated keys and names them, tested in a pipeline-like invocation. Rolling a message back appends a history entry rather than deleting one, and the previous value remains visible. The completeness report counts by category and a locale missing only its 'few' forms is reported as incomplete with that category named. Do not build yet: Nothing after this. ### Out of scope (and why) - A hosted editor with accounts for external translators, which is the point where this stops being a tool and becomes a service with uptime and access control to run. - Machine translation as a default fill, which is fine as an explicitly marked state a human must promote, and dishonest as a silent one that reports a locale as complete. - Translation memory and glossary suggestions, which need a corpus and are worth little on a project small enough to run from a folder. - The long tail of formats, including the awkward desktop toolkit ones; support the platforms you actually ship to and say which. ### README must contain - That there is no singular or plural column and that category sets come from the locale data, with the version recorded - That a description and a placeholder list are required on every key and why a bare word is untranslatable - That gettext plural indices are derived from the file's own header expression and a contradiction is refused - That exports are byte-deterministic, and the command that fails a build on an incomplete required locale - That machine-filled strings carry their own state and are never counted as translated ===== ARCHITECTURE.md ===== # Architecture · Loco ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 20 and TypeScript with a local web editor, one folder | if you are your own translator this should be faster than a hosted grid | | Storage | SQLite, one row per message per plural category | the shape of the table is the whole argument of this build | | Locale data | CLDR plural rules from a maintained package, version recorded per project | hand-written plural rules are wrong for the languages you do not speak | | Internal form | ICU MessageFormat as the stored representation for every message | one representation means escaping is implemented once per adapter, not per pair | | Adapters | One module per format, each with an import, an export and a round-trip test | the adapters are the product and the test is what makes them trustworthy | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Locale data | Category sets per locale and the data version. | Any source, provided it is maintained and versioned. | | Keys | Context, descriptions, placeholders and screenshots. | Any editor, provided description and placeholders stay mandatory. | | Messages | Rows per category, states and history. | Any store, provided no singular or plural column appears. | | Adapters | Per-format import, export and escaping. | Any format, provided it ships with a round-trip test. | | Command line | Deterministic export and the completeness gate. | Any invocation, provided it fails the build on an incomplete required locale. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Projects, keys, messages and history. This is the translation memory you have. - `SCREENSHOT_DIR` · required · Per-key screenshots shown beside the editing boxes. Back up with the database. - `CLDR_PACKAGE_VERSION` · required · Recorded on each project so a changed category set is explainable. - `REQUIRED_LOCALES` · required · The command line exits non-zero when any of these has untranslated keys. - `EXPORT_DIR` · required · Where the command line writes. Exports are deterministic and safe to commit. - `MT_API_KEY` · optional, secret · Optional machine translation. Filled cells are marked machine and never counted as translated. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Loco product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 20 and TypeScript with a local web editor, one folder, SQLite, one row per message per plural category, CLDR plural rules from a maintained package, version recorded per project, ICU MessageFormat as the stored representation for every message, One module per format, each with an import, an export and a round-trip test. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. - Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. - Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. - A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. - Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. - Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. - Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. - Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. - An export whose key ordering or whitespace shifts between runs produces a diff on every build, people stop reading those diffs, and the bad string ships inside one of them. Sort deterministically, normalise line endings, and put a test in the suite that exports twice and compares bytes. - A completeness percentage computed over keys rather than over category rows reports a locale as finished while its 'few' forms are all empty, which is exactly the failure this whole build exists to prevent. Count rows per category per locale, and show the missing categories by name. ===== MILESTONES.md ===== # Delivery milestones · Loco Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Plural categories from the locale data Adding a locale produces exactly the editing boxes its plural rules require, with no code change. ### Steps 1. Take plural rules from a maintained package Your hand-written table is wrong for languages you cannot check. Files: `src/locales/plural-rules.ts`, `package.json` 2. Store one message row per category The schema is where this either works or does not. Files: `src/db/schema.sql` 3. Enforce the category set with a constraint Form validation gets bypassed by the import path. Files: `tests/categories.test.ts` ### Done when - [ ] A four-category locale renders four boxes with no code change - [ ] An other-only locale renders one box - [ ] An out-of-set category is rejected by a constraint - [ ] The locale data version is stored and shown ### Watch out - Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. - Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. ## M2 · Context is a required field No key can be published without a description and a declared placeholder set, and both travel into every export. ### Steps 1. Require description and placeholders at creation A key with no context is a key that comes back wrong. Files: `src/keys/create.ts` 2. Reject placeholder mismatches at save A dropped placeholder is a crash, not a typo. Files: `src/messages/validate.ts`, `tests/placeholders.test.ts` 3. Carry the description into every export comment The context is useless if it stays in your database. Files: `src/adapters/comments.ts` ### Done when - [ ] A key without a description cannot be created - [ ] A placeholder mismatch is rejected with both sets shown - [ ] Descriptions appear as comments in exported files - [ ] Screenshots render beside the editing boxes ### Watch out - Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. - A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. ## M3 · One internal form, adapters at the edges Every format converts to and from one stored representation, and every adapter round trips a hostile fixture. ### Steps 1. Store one internal representation Pairwise conversion between formats is a combinatorial trap. Files: `src/messages/icu.ts` 2. Put escaping in one function per adapter Two copies means one of them is stale. Files: `src/adapters/android.ts`, `src/adapters/json.ts` 3. Round trip a hostile fixture per format Quotes and apostrophes are where adapters actually break. Files: `tests/roundtrip.test.ts`, `tests/fixtures/` ### Done when - [ ] Every format round trips the hostile fixture identically - [ ] Unrepresentable constructs are recorded and shown - [ ] An impossible category set fails the export by key name - [ ] Escaping lives in one place per adapter ### Watch out - Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. - Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. ## M4 · gettext, where the categories have no names PO files import and export with their plural indices correctly mapped to named categories. ### Steps 1. Evaluate the header expression to derive the mapping The file's own rule is the only thing that defines the order. Files: `src/adapters/po/plural-forms.ts`, `tests/po-plurals.test.ts` 2. Refuse when the header contradicts the locale data Guessing here puts wrong text in front of real readers. Files: `src/adapters/po/import.ts` 3. Store the derived mapping with the import Export has to reproduce what the project already uses. Files: `src/adapters/po/export.ts` ### Done when - [ ] Three-form PO indices map to the correct categories - [ ] A contradicting header is refused with both sets shown - [ ] Export writes a consistent header and reimports identically - [ ] A missing Plural-Forms header is refused ### Watch out - Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. - Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. ## M5 · Into a build, and back out of a mistake (production only) A command line feeds the build deterministically, and any string can be rolled back. ### Steps 1. Make every export byte-deterministic A noisy diff is a diff nobody reads. Files: `src/export/deterministic.ts`, `tests/determinism.test.ts` 2. Exit non-zero on an incomplete required locale This is the only thing that stops a bad build shipping. Files: `src/cli/export.ts` 3. Count completeness by category, not by key A key with three of four forms is not a translated key. Files: `src/report/completeness.ts` ### Done when - [ ] Two unchanged exports are byte-identical - [ ] An incomplete required locale exits non-zero and names keys - [ ] Rollback appends history rather than deleting - [ ] Completeness is counted by category and names what is missing ### Watch out - An export whose key ordering or whitespace shifts between runs produces a diff on every build, people stop reading those diffs, and the bad string ships inside one of them. Sort deterministically, normalise line endings, and put a test in the suite that exports twice and compares bytes. - A completeness percentage computed over keys rather than over category rows reports a locale as finished while its 'few' forms are all empty, which is exactly the failure this whole build exists to prevent. Count rows per category per locale, and show the missing categories by name. ===== OPERATIONS.md ===== # Operations · Loco ## Backup The database and the screenshot directory together. The translations themselves usually exist in the repository as exported files, but the history, the descriptions and the states do not, and those are what make the next translation round cheap. ## Restore Restore both, export every required locale, and compare the output against the files committed in your application repository. Byte-identical output is the proof that the restore is complete, which is a cleaner check than counting rows. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Completeness by category per required locale, the count of machine-filled strings not yet promoted, and whether the last export matched what is committed. The last of those catches somebody editing a locale file by hand, which is the usual way a translation store drifts out of date. ## Incident checklist The incident is mangled text in a language nobody on the team reads, and it is usually a plural index mapped wrongly or an escape applied twice. The round-trip tests and the refusal to guess a plural mapping are what stop it reaching an export, and the per-string history is what gets it back. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A four-category locale is confirmed to render four boxes with no code change - [ ] A placeholder mismatch is confirmed rejected at save - [ ] Every adapter is confirmed to round trip the hostile fixture - [ ] A contradicting Plural-Forms header is confirmed refused - [ ] Two unchanged exports are confirmed byte-identical ## Launch constraint Do not market omitted Loco capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Projects, keys, messages and history. This is the translation memory you have. DATABASE_PATH=./data/loco.db # Required. Per-key screenshots shown beside the editing boxes. Back up with the database. SCREENSHOT_DIR=./data/screenshots # Required. Recorded on each project so a changed category set is explainable. CLDR_PACKAGE_VERSION=46.0.0 # Required. The command line exits non-zero when any of these has untranslated keys. REQUIRED_LOCALES=en,fr,pl # Required. Where the command line writes. Exports are deterministic and safe to commit. EXPORT_DIR=./locales # Optional · secret. Optional machine translation. Filled cells are marked machine and never counted as translated. MT_API_KEY=redacted
# Loco · indie build A local translation manager whose hard problems are a plural model that comes from locale data rather than from English, making translator context mandatory instead of optional, and export adapters whose escaping and plural indexing are verified by round trip rather than by hope. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 20 and TypeScript with a local web editor, one folder | if you are your own translator this should be faster than a hosted grid | | Storage | SQLite, one row per message per plural category | the shape of the table is the whole argument of this build | | Locale data | CLDR plural rules from a maintained package, version recorded per project | hand-written plural rules are wrong for the languages you do not speak | | Internal form | ICU MessageFormat as the stored representation for every message | one representation means escaping is implemented once per adapter, not per pair | | Adapters | One module per format, each with an import, an export and a round-trip test | the adapters are the product and the test is what makes them trustworthy | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node 20 or newer with TypeScript** · free - Why: Runs the editor, the adapters and the command line. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v20 or higher - [ ] **A maintained package providing CLDR plural rules** · free - Why: Every category set in the project comes from it, and writing these rules by hand is wrong for exactly the languages you cannot check. - Get it: Install a package that exposes cardinal plural categories per locale and exposes its data version. - Verify: Querying a Slavic locale returns four categories and Japanese returns one - [ ] **Your existing locale files, including the messy ones** · free - Why: Adapter correctness is only demonstrable against files that real tools wrote, with the comments, escapes and obsolete entries they leave behind. - Get it: Copy your current locale files into a fixtures directory, including at least one PO with plurals and one Android resource file. - Verify: Each imports and round trips without entries in the skipped list - [ ] **Which formats you actually ship** · free - Why: Each adapter is a small ongoing commitment and the long tail is where a hosted product earns its money, so supporting three well beats supporting eight badly. - Get it: List the platforms you ship to and write down the formats that follows, and which you are deliberately not supporting. - Verify: The list is in the README and matches the adapters in the source - [ ] **Whether you need the Apple string catalogue format** (optional) · free - Why: Apple's recommended format changed and the older files are still produced during a build, so the adapter you need depends on where in that chain you plug in. - Get it: Read Apple's current localisation documentation, decide which artefact your project consumes, and record the date you checked. - Verify: The decision and the date are in the README ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - A hosted editor with translator accounts. - Machine translation as a silent default. - Translation memory and glossary suggestions. - The long tail of platform formats. - Format breadth: Loco reads and writes a long tail of formats, including the awkward Apple and Qt ones, with plural rules per locale already correct - A shareable editor for non-technical translators, with roles, comments and progress tracking - A stable public API and CLI that CI pipelines can pull from at build time - Translation history, who changed what, and the ability to roll a string back - Glossary and translation memory suggestions that improve as the project grows If one of those is essential to you, that is the reason to keep paying for Loco, and the README should say so rather than pretend.
# Build brief · Loco The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a translation store whose exports get plurals, context and escaping right for each platform to replace Loco. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The database is not the hard part. Plurals are, and getting them wrong ships mangled text to people who cannot tell you about it. The Unicode locale data defines six plural category tags, zero one two few many other, and only 'other' is required. Its own specification warns that these are mnemonics: a common mistake is to think 'one' is only for the number 1, when it is the category for any number that behaves like 1. Languages use different subsets: English two, the Slavic languages typically four, Arabic and Welsh six. So there is no singular column and no plural column in this schema. A counted message has one row per category the target locale actually uses, and that set comes from the locale data, never from your source language. The formats then disagree about how to say it. Android's string resources use the same category names but select by grammatical necessity, so its documentation notes a 'zero' string in English is ignored even when the quantity is 0, and that in Korean only 'other' is ever used. gettext uses no names at all: the header carries nplurals and a C expression over n, and translations are msgstr[0] upwards in whatever order that expression produces. Apple's current recommended format is the string catalogue, which replaced the older pair of files; check Apple's own documentation for what it compiles to before writing that adapter. Converting between these is a per-locale mapping, not a rename. Derive it, verify it, or refuse. ### Stack (fixed, do not substitute) - Runtime: Node 20 and TypeScript with a local web editor, one folder · if you are your own translator this should be faster than a hosted grid - Storage: SQLite, one row per message per plural category · the shape of the table is the whole argument of this build - Locale data: CLDR plural rules from a maintained package, version recorded per project · hand-written plural rules are wrong for the languages you do not speak - Internal form: ICU MessageFormat as the stored representation for every message · one representation means escaping is implemented once per adapter, not per pair - Adapters: One module per format, each with an import, an export and a round-trip test · the adapters are the product and the test is what makes them trustworthy ### Data model (create this before Phase 1) - projects(id, name, source_locale, cldr_version, created_at) · the locale data version is recorded because the rules change - locales(project_id, code, plural_categories_json, is_source, required) · categories come from the locale data, never typed by a human - keys(id, project_id, key, context, description, screenshot_path, placeholders_json, max_length, created_at) - messages(id, key_id, locale, category, text, state, updated_at, updated_by) · category is a CLDR tag, 'other' for anything uncounted - state is one of untranslated, machine, needs_review or approved, and machine is never silently promoted - imports(id, project_id, locale, format, file_sha256, at, added, changed, unchanged, skipped_json) · skipped is what the adapter did not understand - history(message_id, ordinal, text, state, at, by) · rolling one string back is most of why a translation manager exists - There is no singular column and no plural column anywhere, only a category whose allowed values come from the locale's own rules. ### Phase 1 · Plural categories from the locale data Build: Load plural rules from a maintained locale data package and store the category set per locale with the data version. Render a counted message as one box per category in that set. Reject any message row whose category is not in its locale's set, with a database constraint rather than a check in the form handler. Trap: Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. Trap: Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. Done when: Adding a locale with four categories produces four editable boxes for a counted message with no code change, tested with a Slavic locale. A locale whose rules give it only 'other' shows one box, tested. A message row carrying a category outside its locale's set is rejected by a constraint, tested by inserting directly. The locale data version is stored per project and displayed in the interface. Do not build yet: No context rules, adapters, gettext handling or command line yet. ### Phase 2 · Context is a required field Build: Require a description and the list of placeholders when a key is created. Reject a translation whose placeholder set differs from the key's declaration. Carry the description into each format's comment mechanism on export. Allow a screenshot per key and show it beside the editing boxes. Trap: Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. Trap: A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. Done when: Creating a key without a description is refused. A translation whose placeholder set differs from the key's declaration is rejected at save, tested with a missing placeholder and with an extra one. An exported gettext file carries the description as a translator comment and an exported Android resource carries it as a comment above the string, both checked against fixtures. A key with a screenshot shows it beside the editing boxes. Do not build yet: No format adapters, gettext plural mapping or command line yet. ### Phase 3 · One internal form, adapters at the edges Build: Store messages as ICU MessageFormat. Each adapter converts in both directions and does its escaping in exactly one place. Test every adapter by importing a fixture, exporting it and importing the result, comparing messages. Record anything an adapter could not represent in the import's skipped list and show it after the import instead of dropping it. Trap: Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. Trap: Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. Done when: For every supported format, import then export then import produces identical messages, tested with a fixture containing straight and curly quotes, an apostrophe, a newline, a leading at sign and a literal percent. Anything an adapter cannot represent appears in the import's skipped list with its source line and is shown after the import. Exporting a locale whose categories the target format cannot express fails with the key named rather than writing an approximation. No escaping logic appears in more than one place per adapter, verified by review. Do not build yet: No gettext plural index mapping or command line yet. ### Phase 4 · gettext, where the categories have no names Build: Parse the Plural-Forms header, evaluate its expression over a range of integers for the locale, and derive which index corresponds to which named category. Compare that mapping against the locale data's categories and refuse the import when they disagree, quoting the header. Store the derived mapping with the import so an export can reproduce it. Trap: Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. Trap: Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. Done when: Importing a PO for a language with three forms assigns each index to the correct named category, tested against fixtures for at least two such languages. A PO whose header contradicts the locale data is refused with the header quoted and both category sets shown. Exporting writes an nplurals and expression consistent with the categories stored, and reimporting the exported file reproduces the same mapping. A PO with no Plural-Forms header is refused rather than defaulted. Do not build yet: No command line, history or completeness reporting yet. ### Phase 5 · Into a build, and back out of a mistake Build: A command that exports a locale to a path, exits non-zero when a required locale has untranslated keys and names them. Make exports deterministic: sorted, normalised line endings, byte-identical between two runs with no change. Per-string history with rollback that appends rather than deletes. A completeness report counting by category so a locale missing only one form is visible. Trap: An export whose key ordering or whitespace shifts between runs produces a diff on every build, people stop reading those diffs, and the bad string ships inside one of them. Sort deterministically, normalise line endings, and put a test in the suite that exports twice and compares bytes. Trap: A completeness percentage computed over keys rather than over category rows reports a locale as finished while its 'few' forms are all empty, which is exactly the failure this whole build exists to prevent. Count rows per category per locale, and show the missing categories by name. Done when: Two exports with no intervening change produce byte-identical files, tested. The command exits non-zero when a required locale has untranslated keys and names them, tested in a pipeline-like invocation. Rolling a message back appends a history entry rather than deleting one, and the previous value remains visible. The completeness report counts by category and a locale missing only its 'few' forms is reported as incomplete with that category named. Do not build yet: Nothing after this. ### Out of scope (and why) - A hosted editor with accounts for external translators, which is the point where this stops being a tool and becomes a service with uptime and access control to run. - Machine translation as a default fill, which is fine as an explicitly marked state a human must promote, and dishonest as a silent one that reports a locale as complete. - Translation memory and glossary suggestions, which need a corpus and are worth little on a project small enough to run from a folder. - The long tail of formats, including the awkward desktop toolkit ones; support the platforms you actually ship to and say which. ### README must contain - That there is no singular or plural column and that category sets come from the locale data, with the version recorded - That a description and a placeholder list are required on every key and why a bare word is untranslatable - That gettext plural indices are derived from the file's own header expression and a contradiction is refused - That exports are byte-deterministic, and the command that fails a build on an incomplete required locale - That machine-filled strings carry their own state and are never counted as translated
# Agent instructions · Loco indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 20 and TypeScript with a local web editor, one folder, SQLite, one row per message per plural category, CLDR plural rules from a maintained package, version recorded per project, ICU MessageFormat as the stored representation for every message, One module per format, each with an import, an export and a round-trip test. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. - Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. - Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. - A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. - Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. - Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. - Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. - Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale.
# Build plan · Loco A local translation manager whose hard problems are a plural model that comes from locale data rather than from English, making translator context mandatory instead of optional, and export adapters whose escaping and plural indexing are verified by round trip rather than by hope. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Plural categories from the locale data Adding a locale produces exactly the editing boxes its plural rules require, with no code change. ### Steps 1. Take plural rules from a maintained package Your hand-written table is wrong for languages you cannot check. Files: `src/locales/plural-rules.ts`, `package.json` 2. Store one message row per category The schema is where this either works or does not. Files: `src/db/schema.sql` 3. Enforce the category set with a constraint Form validation gets bypassed by the import path. Files: `tests/categories.test.ts` ### Done when - [ ] A four-category locale renders four boxes with no code change - [ ] An other-only locale renders one box - [ ] An out-of-set category is rejected by a constraint - [ ] The locale data version is stored and shown ### Watch out - Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. - Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. ## Phase 2 · Context is a required field No key can be published without a description and a declared placeholder set, and both travel into every export. ### Steps 1. Require description and placeholders at creation A key with no context is a key that comes back wrong. Files: `src/keys/create.ts` 2. Reject placeholder mismatches at save A dropped placeholder is a crash, not a typo. Files: `src/messages/validate.ts`, `tests/placeholders.test.ts` 3. Carry the description into every export comment The context is useless if it stays in your database. Files: `src/adapters/comments.ts` ### Done when - [ ] A key without a description cannot be created - [ ] A placeholder mismatch is rejected with both sets shown - [ ] Descriptions appear as comments in exported files - [ ] Screenshots render beside the editing boxes ### Watch out - Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. - A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. ## Phase 3 · One internal form, adapters at the edges Every format converts to and from one stored representation, and every adapter round trips a hostile fixture. ### Steps 1. Store one internal representation Pairwise conversion between formats is a combinatorial trap. Files: `src/messages/icu.ts` 2. Put escaping in one function per adapter Two copies means one of them is stale. Files: `src/adapters/android.ts`, `src/adapters/json.ts` 3. Round trip a hostile fixture per format Quotes and apostrophes are where adapters actually break. Files: `tests/roundtrip.test.ts`, `tests/fixtures/` ### Done when - [ ] Every format round trips the hostile fixture identically - [ ] Unrepresentable constructs are recorded and shown - [ ] An impossible category set fails the export by key name - [ ] Escaping lives in one place per adapter ### Watch out - Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. - Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. ## Phase 4 · gettext, where the categories have no names PO files import and export with their plural indices correctly mapped to named categories. ### Steps 1. Evaluate the header expression to derive the mapping The file's own rule is the only thing that defines the order. Files: `src/adapters/po/plural-forms.ts`, `tests/po-plurals.test.ts` 2. Refuse when the header contradicts the locale data Guessing here puts wrong text in front of real readers. Files: `src/adapters/po/import.ts` 3. Store the derived mapping with the import Export has to reproduce what the project already uses. Files: `src/adapters/po/export.ts` ### Done when - [ ] Three-form PO indices map to the correct categories - [ ] A contradicting header is refused with both sets shown - [ ] Export writes a consistent header and reimports identically - [ ] A missing Plural-Forms header is refused ### Watch out - Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. - Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. ## Not in this build - A hosted editor with translator accounts. - Machine translation as a silent default. - Translation memory and glossary suggestions. - The long tail of platform formats. ## After v1, if you want it - Ordinal and range plural support, which are separate rule sets in the same locale data and matter for anything with a leaderboard - A read-only review link for one locale, which is the smallest useful step towards a translator who will not install anything - Pseudolocalisation that expands strings and marks placeholders, which finds truncation before a real translation does
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Projects, keys, messages and history. This is the translation memory you have. DATABASE_PATH=./data/loco.db # Required. Per-key screenshots shown beside the editing boxes. Back up with the database. SCREENSHOT_DIR=./data/screenshots # Required. Recorded on each project so a changed category set is explainable. CLDR_PACKAGE_VERSION=46.0.0 # Required. The command line exits non-zero when any of these has untranslated keys. REQUIRED_LOCALES=en,fr,pl # Required. Where the command line writes. Exports are deterministic and safe to commit. EXPORT_DIR=./locales # Optional · secret. Optional machine translation. Filled cells are marked machine and never counted as translated. MT_API_KEY=redacted
# Loco · product brief ## Problem The core of Loco is a keyed string store with a locale matrix on top, and that part is genuinely a one-sitting build: SQLite, a table view, an import and an export. What eats the weekend is the format layer, because gettext PO with plural headers, Apple .strings and .stringsdict, Android plurals XML, ICU messages and flat JSON all disagree about pluralization and escaping in ways that only show up in production. Machine translation fill-in is a single API call if you already have a DeepL or provider key. If you are the only translator, a local build is fine and arguably faster to work with. If you send links to freelance translators and rely on the hosted API in CI, you are rebuilding a service, not a tool. ## Product outcome A translation store whose plural model comes from locale data rather than English, whose keys cannot ship without context, whose adapters are proven by round trip, and whose exports are deterministic enough to commit. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node 20 and a local folder to run in - Optional machine translation API key (DeepL or similar) in .env - Your existing locale files to import as a starting point ## Explicit non-goals for v1 - A hosted editor with translator accounts. - Machine translation as a silent default. - Translation memory and glossary suggestions. - The long tail of platform formats. - Format breadth: Loco reads and writes a long tail of formats, including the awkward Apple and Qt ones, with plural rules per locale already correct - A shareable editor for non-technical translators, with roles, comments and progress tracking - A stable public API and CLI that CI pipelines can pull from at build time - Translation history, who changed what, and the ability to roll a string back - Glossary and translation memory suggestions that improve as the project grows ## Success criteria - A four-category locale is confirmed to render four boxes with no code change - A placeholder mismatch is confirmed rejected at save - Every adapter is confirmed to round trip the hostile fixture - A contradicting Plural-Forms header is confirmed refused - Two unchanged exports are confirmed byte-identical
# Build brief · Loco The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a translation store whose exports get plurals, context and escaping right for each platform to replace Loco. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero The database is not the hard part. Plurals are, and getting them wrong ships mangled text to people who cannot tell you about it. The Unicode locale data defines six plural category tags, zero one two few many other, and only 'other' is required. Its own specification warns that these are mnemonics: a common mistake is to think 'one' is only for the number 1, when it is the category for any number that behaves like 1. Languages use different subsets: English two, the Slavic languages typically four, Arabic and Welsh six. So there is no singular column and no plural column in this schema. A counted message has one row per category the target locale actually uses, and that set comes from the locale data, never from your source language. The formats then disagree about how to say it. Android's string resources use the same category names but select by grammatical necessity, so its documentation notes a 'zero' string in English is ignored even when the quantity is 0, and that in Korean only 'other' is ever used. gettext uses no names at all: the header carries nplurals and a C expression over n, and translations are msgstr[0] upwards in whatever order that expression produces. Apple's current recommended format is the string catalogue, which replaced the older pair of files; check Apple's own documentation for what it compiles to before writing that adapter. Converting between these is a per-locale mapping, not a rename. Derive it, verify it, or refuse. ### Stack (fixed, do not substitute) - Runtime: Node 20 and TypeScript with a local web editor, one folder · if you are your own translator this should be faster than a hosted grid - Storage: SQLite, one row per message per plural category · the shape of the table is the whole argument of this build - Locale data: CLDR plural rules from a maintained package, version recorded per project · hand-written plural rules are wrong for the languages you do not speak - Internal form: ICU MessageFormat as the stored representation for every message · one representation means escaping is implemented once per adapter, not per pair - Adapters: One module per format, each with an import, an export and a round-trip test · the adapters are the product and the test is what makes them trustworthy ### Data model (create this before Phase 1) - projects(id, name, source_locale, cldr_version, created_at) · the locale data version is recorded because the rules change - locales(project_id, code, plural_categories_json, is_source, required) · categories come from the locale data, never typed by a human - keys(id, project_id, key, context, description, screenshot_path, placeholders_json, max_length, created_at) - messages(id, key_id, locale, category, text, state, updated_at, updated_by) · category is a CLDR tag, 'other' for anything uncounted - state is one of untranslated, machine, needs_review or approved, and machine is never silently promoted - imports(id, project_id, locale, format, file_sha256, at, added, changed, unchanged, skipped_json) · skipped is what the adapter did not understand - history(message_id, ordinal, text, state, at, by) · rolling one string back is most of why a translation manager exists - There is no singular column and no plural column anywhere, only a category whose allowed values come from the locale's own rules. ### Phase 1 · Plural categories from the locale data Build: Load plural rules from a maintained locale data package and store the category set per locale with the data version. Render a counted message as one box per category in that set. Reject any message row whose category is not in its locale's set, with a database constraint rather than a check in the form handler. Trap: Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. Trap: Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. Done when: Adding a locale with four categories produces four editable boxes for a counted message with no code change, tested with a Slavic locale. A locale whose rules give it only 'other' shows one box, tested. A message row carrying a category outside its locale's set is rejected by a constraint, tested by inserting directly. The locale data version is stored per project and displayed in the interface. Do not build yet: No context rules, adapters, gettext handling or command line yet. ### Phase 2 · Context is a required field Build: Require a description and the list of placeholders when a key is created. Reject a translation whose placeholder set differs from the key's declaration. Carry the description into each format's comment mechanism on export. Allow a screenshot per key and show it beside the editing boxes. Trap: Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. Trap: A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. Done when: Creating a key without a description is refused. A translation whose placeholder set differs from the key's declaration is rejected at save, tested with a missing placeholder and with an extra one. An exported gettext file carries the description as a translator comment and an exported Android resource carries it as a comment above the string, both checked against fixtures. A key with a screenshot shows it beside the editing boxes. Do not build yet: No format adapters, gettext plural mapping or command line yet. ### Phase 3 · One internal form, adapters at the edges Build: Store messages as ICU MessageFormat. Each adapter converts in both directions and does its escaping in exactly one place. Test every adapter by importing a fixture, exporting it and importing the result, comparing messages. Record anything an adapter could not represent in the import's skipped list and show it after the import instead of dropping it. Trap: Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. Trap: Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. Done when: For every supported format, import then export then import produces identical messages, tested with a fixture containing straight and curly quotes, an apostrophe, a newline, a leading at sign and a literal percent. Anything an adapter cannot represent appears in the import's skipped list with its source line and is shown after the import. Exporting a locale whose categories the target format cannot express fails with the key named rather than writing an approximation. No escaping logic appears in more than one place per adapter, verified by review. Do not build yet: No gettext plural index mapping or command line yet. ### Phase 4 · gettext, where the categories have no names Build: Parse the Plural-Forms header, evaluate its expression over a range of integers for the locale, and derive which index corresponds to which named category. Compare that mapping against the locale data's categories and refuse the import when they disagree, quoting the header. Store the derived mapping with the import so an export can reproduce it. Trap: Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. Trap: Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. Done when: Importing a PO for a language with three forms assigns each index to the correct named category, tested against fixtures for at least two such languages. A PO whose header contradicts the locale data is refused with the header quoted and both category sets shown. Exporting writes an nplurals and expression consistent with the categories stored, and reimporting the exported file reproduces the same mapping. A PO with no Plural-Forms header is refused rather than defaulted. Do not build yet: No command line, history or completeness reporting yet. ### Phase 5 · Into a build, and back out of a mistake Build: A command that exports a locale to a path, exits non-zero when a required locale has untranslated keys and names them. Make exports deterministic: sorted, normalised line endings, byte-identical between two runs with no change. Per-string history with rollback that appends rather than deletes. A completeness report counting by category so a locale missing only one form is visible. Trap: An export whose key ordering or whitespace shifts between runs produces a diff on every build, people stop reading those diffs, and the bad string ships inside one of them. Sort deterministically, normalise line endings, and put a test in the suite that exports twice and compares bytes. Trap: A completeness percentage computed over keys rather than over category rows reports a locale as finished while its 'few' forms are all empty, which is exactly the failure this whole build exists to prevent. Count rows per category per locale, and show the missing categories by name. Done when: Two exports with no intervening change produce byte-identical files, tested. The command exits non-zero when a required locale has untranslated keys and names them, tested in a pipeline-like invocation. Rolling a message back appends a history entry rather than deleting one, and the previous value remains visible. The completeness report counts by category and a locale missing only its 'few' forms is reported as incomplete with that category named. Do not build yet: Nothing after this. ### Out of scope (and why) - A hosted editor with accounts for external translators, which is the point where this stops being a tool and becomes a service with uptime and access control to run. - Machine translation as a default fill, which is fine as an explicitly marked state a human must promote, and dishonest as a silent one that reports a locale as complete. - Translation memory and glossary suggestions, which need a corpus and are worth little on a project small enough to run from a folder. - The long tail of formats, including the awkward desktop toolkit ones; support the platforms you actually ship to and say which. ### README must contain - That there is no singular or plural column and that category sets come from the locale data, with the version recorded - That a description and a placeholder list are required on every key and why a bare word is untranslatable - That gettext plural indices are derived from the file's own header expression and a contradiction is refused - That exports are byte-deterministic, and the command that fails a build on an incomplete required locale - That machine-filled strings carry their own state and are never counted as translated
# Architecture · Loco ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 20 and TypeScript with a local web editor, one folder | if you are your own translator this should be faster than a hosted grid | | Storage | SQLite, one row per message per plural category | the shape of the table is the whole argument of this build | | Locale data | CLDR plural rules from a maintained package, version recorded per project | hand-written plural rules are wrong for the languages you do not speak | | Internal form | ICU MessageFormat as the stored representation for every message | one representation means escaping is implemented once per adapter, not per pair | | Adapters | One module per format, each with an import, an export and a round-trip test | the adapters are the product and the test is what makes them trustworthy | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Locale data | Category sets per locale and the data version. | Any source, provided it is maintained and versioned. | | Keys | Context, descriptions, placeholders and screenshots. | Any editor, provided description and placeholders stay mandatory. | | Messages | Rows per category, states and history. | Any store, provided no singular or plural column appears. | | Adapters | Per-format import, export and escaping. | Any format, provided it ships with a round-trip test. | | Command line | Deterministic export and the completeness gate. | Any invocation, provided it fails the build on an incomplete required locale. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Projects, keys, messages and history. This is the translation memory you have. - `SCREENSHOT_DIR` · required · Per-key screenshots shown beside the editing boxes. Back up with the database. - `CLDR_PACKAGE_VERSION` · required · Recorded on each project so a changed category set is explainable. - `REQUIRED_LOCALES` · required · The command line exits non-zero when any of these has untranslated keys. - `EXPORT_DIR` · required · Where the command line writes. Exports are deterministic and safe to commit. - `MT_API_KEY` · optional, secret · Optional machine translation. Filled cells are marked machine and never counted as translated. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Loco product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 20 and TypeScript with a local web editor, one folder, SQLite, one row per message per plural category, CLDR plural rules from a maintained package, version recorded per project, ICU MessageFormat as the stored representation for every message, One module per format, each with an import, an export and a round-trip test. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. - Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. - Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. - A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. - Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. - Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. - Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. - Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. - An export whose key ordering or whitespace shifts between runs produces a diff on every build, people stop reading those diffs, and the bad string ships inside one of them. Sort deterministically, normalise line endings, and put a test in the suite that exports twice and compares bytes. - A completeness percentage computed over keys rather than over category rows reports a locale as finished while its 'few' forms are all empty, which is exactly the failure this whole build exists to prevent. Count rows per category per locale, and show the missing categories by name.
# Delivery milestones · Loco Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Plural categories from the locale data Adding a locale produces exactly the editing boxes its plural rules require, with no code change. ### Steps 1. Take plural rules from a maintained package Your hand-written table is wrong for languages you cannot check. Files: `src/locales/plural-rules.ts`, `package.json` 2. Store one message row per category The schema is where this either works or does not. Files: `src/db/schema.sql` 3. Enforce the category set with a constraint Form validation gets bypassed by the import path. Files: `tests/categories.test.ts` ### Done when - [ ] A four-category locale renders four boxes with no code change - [ ] An other-only locale renders one box - [ ] An out-of-set category is rejected by a constraint - [ ] The locale data version is stored and shown ### Watch out - Modelling a counted message as a singular string and a plural string works for English and breaks the first time somebody adds a language needing four forms, and the usual patch of adding a third column starts a sequence with no end and a schema nobody can query. Store one row per category, take the set from the locale data at render time, and let the editor build itself from that set. - Copying a table of plural rules into your source is the version of this bug that survives review, because the table is right for the languages you speak and stale for the rest. Take the rules from a maintained package, record its version on the project, and show that version in the interface so a category set that changed is explainable. ## M2 · Context is a required field No key can be published without a description and a declared placeholder set, and both travel into every export. ### Steps 1. Require description and placeholders at creation A key with no context is a key that comes back wrong. Files: `src/keys/create.ts` 2. Reject placeholder mismatches at save A dropped placeholder is a crash, not a typo. Files: `src/messages/validate.ts`, `tests/placeholders.test.ts` 3. Carry the description into every export comment The context is useless if it stays in your database. Files: `src/adapters/comments.ts` ### Done when - [ ] A key without a description cannot be created - [ ] A placeholder mismatch is rejected with both sets shown - [ ] Descriptions appear as comments in exported files - [ ] Screenshots render beside the editing boxes ### Watch out - Exporting a key called button.open with the text 'Open' and no description hands a translator a word that is a verb in one language and an adjective in another, and what comes back is wrong in a way nobody on the team can read. Make the description mandatory at creation, refuse to export without it, and put it in the comment field of every format that has one. - A translation that drops a placeholder, or invents one, crashes the application rather than reading oddly, and it will crash in the locale nobody on the team tests. Declare the placeholder set on the key, compare every saved translation against it, and reject the mismatch at save time with both sets shown. ## M3 · One internal form, adapters at the edges Every format converts to and from one stored representation, and every adapter round trips a hostile fixture. ### Steps 1. Store one internal representation Pairwise conversion between formats is a combinatorial trap. Files: `src/messages/icu.ts` 2. Put escaping in one function per adapter Two copies means one of them is stale. Files: `src/adapters/android.ts`, `src/adapters/json.ts` 3. Round trip a hostile fixture per format Quotes and apostrophes are where adapters actually break. Files: `tests/roundtrip.test.ts`, `tests/fixtures/` ### Done when - [ ] Every format round trips the hostile fixture identically - [ ] Unrepresentable constructs are recorded and shown - [ ] An impossible category set fails the export by key name - [ ] Escaping lives in one place per adapter ### Watch out - Writing each export straight from the database means the escaping rules get reimplemented once per format and at least one of them is wrong: Android resources treat an apostrophe and a leading at sign specially, gettext uses C string escapes with its own line continuation, and a flat JSON file has to survive whatever the message text contains. Convert to one internal form on import, put the escaping in one function per adapter, and prove it with a round trip. - Silently dropping what an adapter did not understand means a re-export deletes strings the previous tool wrote, and the deletion looks like a tidy diff. Record every unhandled construct in the skipped list with its line, show the list after every import, and refuse to export a locale where anything was skipped until it is acknowledged. ## M4 · gettext, where the categories have no names PO files import and export with their plural indices correctly mapped to named categories. ### Steps 1. Evaluate the header expression to derive the mapping The file's own rule is the only thing that defines the order. Files: `src/adapters/po/plural-forms.ts`, `tests/po-plurals.test.ts` 2. Refuse when the header contradicts the locale data Guessing here puts wrong text in front of real readers. Files: `src/adapters/po/import.ts` 3. Store the derived mapping with the import Export has to reproduce what the project already uses. Files: `src/adapters/po/export.ts` ### Done when - [ ] Three-form PO indices map to the correct categories - [ ] A contradicting header is refused with both sets shown - [ ] Export writes a consistent header and reimports identically - [ ] A missing Plural-Forms header is refused ### Watch out - Assuming msgstr[0] is 'one' and msgstr[1] is 'other' is right for English and wrong for most languages that have interesting plurals, because the index order comes from that file's own C expression rather than from any shared naming, and nothing in the file tells you which is which. Evaluate the header's expression yourself over sample integers, derive the index for each category, and refuse rather than guess when the result contradicts the locale data. - Accepting a PO file with no Plural-Forms header and defaulting to the English rule silently assigns every plural translation to the wrong form for any language that is not English, and the file will look fine in a diff. Refuse the import, name the missing header, and say what it should contain for that locale. ## M5 · Into a build, and back out of a mistake (production only) A command line feeds the build deterministically, and any string can be rolled back. ### Steps 1. Make every export byte-deterministic A noisy diff is a diff nobody reads. Files: `src/export/deterministic.ts`, `tests/determinism.test.ts` 2. Exit non-zero on an incomplete required locale This is the only thing that stops a bad build shipping. Files: `src/cli/export.ts` 3. Count completeness by category, not by key A key with three of four forms is not a translated key. Files: `src/report/completeness.ts` ### Done when - [ ] Two unchanged exports are byte-identical - [ ] An incomplete required locale exits non-zero and names keys - [ ] Rollback appends history rather than deleting - [ ] Completeness is counted by category and names what is missing ### Watch out - An export whose key ordering or whitespace shifts between runs produces a diff on every build, people stop reading those diffs, and the bad string ships inside one of them. Sort deterministically, normalise line endings, and put a test in the suite that exports twice and compares bytes. - A completeness percentage computed over keys rather than over category rows reports a locale as finished while its 'few' forms are all empty, which is exactly the failure this whole build exists to prevent. Count rows per category per locale, and show the missing categories by name.
# Operations · Loco ## Backup The database and the screenshot directory together. The translations themselves usually exist in the repository as exported files, but the history, the descriptions and the states do not, and those are what make the next translation round cheap. ## Restore Restore both, export every required locale, and compare the output against the files committed in your application repository. Byte-identical output is the proof that the restore is complete, which is a cleaner check than counting rows. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Completeness by category per required locale, the count of machine-filled strings not yet promoted, and whether the last export matched what is committed. The last of those catches somebody editing a locale file by hand, which is the usual way a translation store drifts out of date. ## Incident checklist The incident is mangled text in a language nobody on the team reads, and it is usually a plural index mapped wrongly or an escape applied twice. The round-trip tests and the refusal to guess a plural mapping are what stop it reaching an export, and the per-string history is what gets it back. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A four-category locale is confirmed to render four boxes with no code change - [ ] A placeholder mismatch is confirmed rejected at save - [ ] Every adapter is confirmed to round trip the hostile fixture - [ ] A contradicting Plural-Forms header is confirmed refused - [ ] Two unchanged exports are confirmed byte-identical ## Launch constraint Do not market omitted Loco capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Projects, keys, messages and history. This is the translation memory you have. DATABASE_PATH=./data/loco.db # Required. Per-key screenshots shown beside the editing boxes. Back up with the database. SCREENSHOT_DIR=./data/screenshots # Required. Recorded on each project so a changed category set is explainable. CLDR_PACKAGE_VERSION=46.0.0 # Required. The command line exits non-zero when any of these has untranslated keys. REQUIRED_LOCALES=en,fr,pl # Required. Where the command line writes. Exports are deterministic and safe to commit. EXPORT_DIR=./locales # Optional · secret. Optional machine translation. Filled cells are marked machine and never counted as translated. MT_API_KEY=redacted
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because localization is a pile of edge cases someone else already ate. Plural categories vary by language, every platform escapes strings differently, and one bad export ships mangled text to users in a language nobody on the team reads. Paying also buys a link you can send to a contractor and an API your build can hit, which a folder on your laptop cannot do. Solo developers with two or three locales tend to outgrow the need for that rather than into it.
xFormat breadth: Loco reads and writes a long tail of formats, including the awkward Apple and Qt ones, with plural rules per locale already correct
xA shareable editor for non-technical translators, with roles, comments and progress tracking
xA stable public API and CLI that CI pipelines can pull from at build time
xTranslation history, who changed what, and the ability to roll a string back
xGlossary and translation memory suggestions that improve as the project grows
Nothing worth pointing at. That's why the prompt exists.
Vibecode Loco
Kinda. The core of Loco is buildable in a weekend with the prompt on this page, but there are real gaps: Format breadth: Loco reads and writes a long tail of formats, including the awkward Apple and Qt ones, with plural rules per locale already correct, A shareable editor for non-technical translators, with roles, comments and progress tracking. Read the honest list above before committing.
How much does Loco cost?
Loco costs about $5.95/month (Pro, checked 2026-08-18), which is $71.4 per year.
What do I lose by replacing Loco?
Honestly: Format breadth: Loco reads and writes a long tail of formats, including the awkward Apple and Qt ones, with plural rules per locale already correct; A shareable editor for non-technical translators, with roles, comments and progress tracking; A stable public API and CLI that CI pipelines can pull from at build time; Translation history, who changed what, and the ability to roll a string back; Glossary and translation memory suggestions that improve as the project grows. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Loco?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.