Vibecode POEditor
track this build5 phases, 15 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For POEditor, manage translation keys, contributors, suggestions, and standard file exports. The hard boundary is hosted collaboration, integrations, translation memory, and simple workflow, plus translation memory, collaboration, and deployment integrations.
You are building a lean indie version of POEditor. 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 ===== # POEditor · indie build A place where source strings arrive from your repository with their context, translators work per language against the plural forms that language actually needs, suggestions are reviewed before they ship, and approved translations export back in the format your application already reads. Estimated effort: **multi-day**. 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 22 with PostgreSQL | keys, languages and revisions are relational, and a translation history you can query is the point | | Message format | ICU MessageFormat, parsed and validated | the industry standard for plurals and selection, and validating it catches broken translations before they ship | | Plural forms | Unicode plural rules through Intl.PluralRules | which forms a language needs is data the platform already has, and guessing it produces wrong text | | Files | Import and export standard formats, never a proprietary one | the file your application reads is the source of truth and must round trip exactly | | Machine assistance | An optional translation provider producing suggestions only | a suggestion a person approves is useful; an automatic translation that ships is how you get embarrassing text in a language nobody on the team reads | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and provides Intl.PluralRules, which is where plural categories come from. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher and Intl.PluralRules exists - [ ] **PostgreSQL 16 or newer** · free - Why: Keys, translations and revisions are relational and the history needs to be queryable. - Get it: Install locally or run it in Docker. - Verify: psql connects and reports 16 or higher - [ ] **A real source locale file from your application** · free - Why: Phase 1 round trips it, and a synthetic file will not have the comments, ordering and edge cases that break a naive parser. - Get it: Take the actual file your application loads, with its real keys and comments. - Verify: It imports and exports byte-identically - [ ] **A decision about which languages you support** · free - Why: It determines how many plural forms your keys must accommodate, and adding a four-form language after modelling keys as two forms is a migration rather than a setting. - Get it: List them before Phase 2. If any of them is Arabic, Russian, Polish or another multi-form language, the key shape decision is settled for you. - Verify: The list exists and its plural categories are derived and recorded - [ ] **At least one reviewer per language who speaks it** · varies; this is the cost the hosted marketplace exists to solve - Why: Format validation catches malformed messages, not wrong ones. A language with no reviewer ships text nobody has read. - Get it: Identify a person per language before enabling it. A language without a reviewer should stay disabled rather than shipping machine output. - Verify: Every enabled language has a named reviewer ## 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 professional translator marketplace. - Cross-project translation memory. - The integration catalogue. - Any judgement about whether a translation is good. - hosted collaboration, integrations, translation memory, and simple workflow - professional translator marketplace - advanced translation memory - website proxy network - enterprise workflows and integrations If one of those is essential to you, that is the reason to keep paying for POEditor, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · POEditor 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 management system where keys carry context and plurals are correct in every language to replace POEditor. 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 Do not invent a message format. ICU MessageFormat already expresses plurals, gender and nested selection, and the Unicode plural rules already say which forms each language needs. Adopting them is what makes a translation correct in languages you do not speak. The consequence to internalise now: a key modelled as a singular and a plural string cannot be translated correctly into Arabic, which uses all six plural categories, or Russian and Polish, which use four. That is not a formatting preference, it is the difference between correct and wrong text, and a key with the wrong shape cannot be fixed by the translator. Checked September 2026. ### Stack (fixed, do not substitute) - Runtime: Node 22 with PostgreSQL · keys, languages and revisions are relational, and a translation history you can query is the point - Message format: ICU MessageFormat, parsed and validated · the industry standard for plurals and selection, and validating it catches broken translations before they ship - Plural forms: Unicode plural rules through Intl.PluralRules · which forms a language needs is data the platform already has, and guessing it produces wrong text - Files: Import and export standard formats, never a proprietary one · the file your application reads is the source of truth and must round trip exactly - Machine assistance: An optional translation provider producing suggestions only · a suggestion a person approves is useful; an automatic translation that ships is how you get embarrassing text in a language nobody on the team reads ### Data model (create this before Phase 1) - projects(id, name, source_locale), languages(id, project_id, locale, plural_categories_json, enabled) - keys(id, project_id, name, source_text, context, max_length, screenshot_path, first_seen_at, removed_at) - translations(id, key_id, language_id, form, text, state, author_id, created_at) · form is the plural category, or none for a simple string - suggestions(id, key_id, language_id, form, text, source, author_id, created_at, resolved_as) - revisions(id, translation_id, text, at, author_id) · every change kept, because a regression needs a diff - imports(id, project_id, format, file_hash, at, keys_added, keys_removed, translations_updated) - A key removed from source is marked removed rather than deleted, so restoring it does not lose a year of translations. ### Phase 1 · Round trip a real file Build: Import the source locale file in the format your application already uses. Export it back and compare. Preserve ordering, comments and anything the format carries that you do not yet understand, rather than dropping it. Trap: Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. Done when: A real source file imports and exports byte-identically, verified by comparison. Comments and key ordering survive the round trip. A file with a key the parser does not understand fails the import loudly rather than silently dropping it. Re-importing an unchanged file reports no changes. Do not build yet: No languages, translators or plurals. ### Phase 2 · Plural forms per language, from the rules Build: For each target language, derive its plural categories from the Unicode rules rather than assuming. Render a translation editor with one field per category for that language. Validate that an ICU plural message covers every required category. Trap: A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. Trap: Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. Done when: Adding Arabic produces six plural fields and adding Russian produces four, both derived rather than configured. A plural message missing a required category fails validation naming the missing one. A language with a single form shows one field. Adding a new language requires no code change. Do not build yet: No translator workflow or export of translations yet. ### Phase 3 · Context, because a word is not a translation unit Build: Every key carries a context note, a maximum length where the interface constrains it, and optionally a screenshot showing where it appears. Import context from source comments so it comes from the code rather than being maintained separately. Trap: A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. Done when: A key shows its context, its length limit and any screenshot in the editor. Context imported from source comments updates when the comment changes. A translation exceeding the length limit is flagged before it is saved. A key with no context is visibly marked so it can be fixed at the source. Do not build yet: No review workflow yet. ### Phase 4 · Suggestions and review Build: Contributors submit suggestions rather than editing directly. A reviewer per language approves, edits or rejects. Approved text becomes the translation and every change keeps a revision. Machine translation, if enabled, enters as a suggestion like any other. Trap: Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. Done when: A suggestion does not change the exported translation until approved. Machine translation appears as a suggestion marked with its source. Every approved change keeps a revision with its author. A reviewer for one language cannot approve another, tested with a direct request. Do not build yet: No export pipeline or integration yet. ### Phase 5 · Export and the incomplete language question Build: Export approved translations per language in the source format. Decide explicitly what an untranslated key does: fall back to source, or be omitted so the application's own fallback handles it. Validate every exported message parses as ICU before writing. Trap: A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. Done when: An export contains only approved translations and every message parses as ICU, verified by parsing the exported file. A malformed message blocks the export naming the key. The fallback behaviour for untranslated keys matches what was configured. Exporting an unchanged project produces an identical file. Do not build yet: Integration and operations come last. ### Phase 6 · Keeping it in step with the code Build: A command that imports the source file from a repository and reports what changed. Removed keys are marked rather than deleted. A report shows per-language completeness. Wire it into your build so a release knows which languages are behind. Trap: Deleting a key because it disappeared from the source file loses every translation of it, and a key removed by mistake and restored a week later comes back untranslated in twelve languages. Mark it removed, keep the translations, and restore them if the key returns. Done when: Importing a source file with a removed key marks it rather than deleting, and restoring the key restores its translations. A completeness report shows the untranslated count per language. The import runs from a command suitable for continuous integration. A release can be blocked on a completeness threshold if you choose. Do not build yet: Nothing after this. ### Out of scope (and why) - The professional translator marketplace, which is the part of the hosted product that is people rather than software. - Translation memory that suggests from previously translated segments across projects, which needs a corpus you do not have yet. - The integration catalogue connecting to every framework and repository host, each of which is maintenance. - Quality assurance beyond format validation. Whether a translation is good is a judgement only a speaker can make. ### README must contain - That plural categories are derived from the Unicode rules per language, and that Arabic needs six while Russian needs four - That a key modelled as singular and plural is untranslatable into several languages - That context comes from source comments so it cannot drift - That machine translation enters as a suggestion and never ships unreviewed - That every exported message is validated as ICU before the file is written ===== AGENTS.md ===== # Agent instructions · POEditor indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with PostgreSQL, ICU MessageFormat, parsed and validated, Unicode plural rules through Intl.PluralRules, Import and export standard formats, never a proprietary one, An optional translation provider producing suggestions only. 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 - Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. - A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. - Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. - A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. - Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. - A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. ===== BUILD_PLAN.md ===== # Build plan · POEditor A place where source strings arrive from your repository with their context, translators work per language against the plural forms that language actually needs, suggestions are reviewed before they ship, and approved translations export back in the format your application already reads. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Round trip a real file Your application's translation file imports and exports back byte-identically. ### Steps 1. Parse the format you actually use Whichever your application reads. Adding a second format later is easier than migrating from a wrong first one. Files: `src/formats/` 2. Preserve everything on round trip Comments, order and unknown metadata. Compare byte for byte. Files: `src/formats/roundtrip.test.js` 3. Fail loudly on the unknown A key you cannot parse stops the import. Silently dropping it loses a translation nobody notices for months. Files: `src/import.js` ### Done when - [ ] A real file round trips byte-identically - [ ] Comments and ordering survive - [ ] An unparseable key fails the import loudly - [ ] Re-importing an unchanged file reports no changes ### Watch out - Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. ## Phase 2 · Plural forms per language, from the rules Each language shows exactly the plural forms it needs, not two. ### Steps 1. Derive categories per locale From Intl.PluralRules for the locale. Store them on the language so the editor can render from data. Files: `src/plurals.js` 2. Render one field per category With the category name and an example number, so a translator knows what few means here. Files: `src/editor.js` 3. Validate coverage An ICU plural message must cover every required category for its language. Missing one is a failed validation, not a warning. Files: `src/validate.js` ### Done when - [ ] Arabic produces six plural fields and Russian four - [ ] A missing category fails validation naming it - [ ] A single-form language shows one field - [ ] Adding a language needs no code change ### Watch out - A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. - Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. ## Phase 3 · Context, because a word is not a translation unit A translator can see what a string is for before translating it. ### Steps 1. Carry context from source comments So it lives with the code and cannot drift. A context maintained separately is a context that goes stale. Files: `src/import.js` 2. Show length limits where they exist German is routinely longer than English and a button that fits in one may not in the other. Files: `src/editor.js` 3. Mark context-free keys Visibly, as a backlog for the developers rather than a problem for the translator. Files: `src/keys.js` ### Done when - [ ] Context, length limit and screenshot appear in the editor - [ ] Context updates when the source comment changes - [ ] An over-length translation is flagged before saving - [ ] Keys without context are visibly marked ### Watch out - A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. ## Phase 4 · Suggestions and review Anyone can suggest, a reviewer approves, and nothing ships unreviewed. ### Steps 1. Separate suggestions from translations Different tables. The exported file reads only approved translations. Files: `src/suggestions.js` 2. Route machine output through the queue Marked with its source so a reviewer knows what they are reading. Files: `src/machine.js` 3. Enforce per-language review rights Server side, tested by direct request rather than through the interface. Files: `src/auth.js` ### Done when - [ ] An unapproved suggestion does not affect the export - [ ] Machine suggestions are marked with their source - [ ] Every approved change keeps a revision with its author - [ ] A reviewer cannot approve a language they do not own ### Watch out - Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. ## Phase 5 · Export and the incomplete language question Exports are valid, and a half-translated language behaves the way you decided. ### Steps 1. Export approved only Suggestions never reach a file. The state machine is the gate. Files: `src/export.js` 2. Validate before writing Parse every message. A file that throws in production is worse than a missing translation. Files: `src/export.js` 3. Decide the fallback deliberately Source text or omission. Both are defensible; an accidental mixture is not. Files: `docs/fallback.md` ### Done when - [ ] Every exported message parses as ICU - [ ] A malformed message blocks the export naming the key - [ ] Fallback behaviour matches the configuration - [ ] An unchanged export is identical ### Watch out - A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. ## Not in this build - A professional translator marketplace. - Cross-project translation memory. - The integration catalogue. - Any judgement about whether a translation is good. ## After v1, if you want it - Translation memory within one project, which needs no corpus beyond your own history - Screenshot capture from your test suite, so context arrives automatically - A pseudo-locale that expands strings, which finds every layout that will break in German ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/i18n # Required. The locale keys are authored in. Everything else translates from it. SOURCE_LOCALE=en # Required. The format your application reads. Round tripped exactly. EXPORT_FORMAT=json # Required. Whether an untranslated key falls back to source text or is omitted. Decide deliberately. UNTRANSLATED_BEHAVIOUR=omit # Optional · secret. Optional. Output enters the suggestion queue and never ships unreviewed. MACHINE_TRANSLATION_API_KEY=replace-with-key # Optional. Percentage below which a language is reported as blocking a release, if you use that check. RELEASE_COMPLETENESS_THRESHOLD=95
You are building a lean indie version of POEditor. 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 ===== # POEditor · indie build A place where source strings arrive from your repository with their context, translators work per language against the plural forms that language actually needs, suggestions are reviewed before they ship, and approved translations export back in the format your application already reads. Estimated effort: **multi-day**. 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 22 with PostgreSQL | keys, languages and revisions are relational, and a translation history you can query is the point | | Message format | ICU MessageFormat, parsed and validated | the industry standard for plurals and selection, and validating it catches broken translations before they ship | | Plural forms | Unicode plural rules through Intl.PluralRules | which forms a language needs is data the platform already has, and guessing it produces wrong text | | Files | Import and export standard formats, never a proprietary one | the file your application reads is the source of truth and must round trip exactly | | Machine assistance | An optional translation provider producing suggestions only | a suggestion a person approves is useful; an automatic translation that ships is how you get embarrassing text in a language nobody on the team reads | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and provides Intl.PluralRules, which is where plural categories come from. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher and Intl.PluralRules exists - [ ] **PostgreSQL 16 or newer** · free - Why: Keys, translations and revisions are relational and the history needs to be queryable. - Get it: Install locally or run it in Docker. - Verify: psql connects and reports 16 or higher - [ ] **A real source locale file from your application** · free - Why: Phase 1 round trips it, and a synthetic file will not have the comments, ordering and edge cases that break a naive parser. - Get it: Take the actual file your application loads, with its real keys and comments. - Verify: It imports and exports byte-identically - [ ] **A decision about which languages you support** · free - Why: It determines how many plural forms your keys must accommodate, and adding a four-form language after modelling keys as two forms is a migration rather than a setting. - Get it: List them before Phase 2. If any of them is Arabic, Russian, Polish or another multi-form language, the key shape decision is settled for you. - Verify: The list exists and its plural categories are derived and recorded - [ ] **At least one reviewer per language who speaks it** · varies; this is the cost the hosted marketplace exists to solve - Why: Format validation catches malformed messages, not wrong ones. A language with no reviewer ships text nobody has read. - Get it: Identify a person per language before enabling it. A language without a reviewer should stay disabled rather than shipping machine output. - Verify: Every enabled language has a named reviewer ## 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 professional translator marketplace. - Cross-project translation memory. - The integration catalogue. - Any judgement about whether a translation is good. - hosted collaboration, integrations, translation memory, and simple workflow - professional translator marketplace - advanced translation memory - website proxy network - enterprise workflows and integrations If one of those is essential to you, that is the reason to keep paying for POEditor, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · POEditor 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 management system where keys carry context and plurals are correct in every language to replace POEditor. 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 Do not invent a message format. ICU MessageFormat already expresses plurals, gender and nested selection, and the Unicode plural rules already say which forms each language needs. Adopting them is what makes a translation correct in languages you do not speak. The consequence to internalise now: a key modelled as a singular and a plural string cannot be translated correctly into Arabic, which uses all six plural categories, or Russian and Polish, which use four. That is not a formatting preference, it is the difference between correct and wrong text, and a key with the wrong shape cannot be fixed by the translator. Checked September 2026. ### Stack (fixed, do not substitute) - Runtime: Node 22 with PostgreSQL · keys, languages and revisions are relational, and a translation history you can query is the point - Message format: ICU MessageFormat, parsed and validated · the industry standard for plurals and selection, and validating it catches broken translations before they ship - Plural forms: Unicode plural rules through Intl.PluralRules · which forms a language needs is data the platform already has, and guessing it produces wrong text - Files: Import and export standard formats, never a proprietary one · the file your application reads is the source of truth and must round trip exactly - Machine assistance: An optional translation provider producing suggestions only · a suggestion a person approves is useful; an automatic translation that ships is how you get embarrassing text in a language nobody on the team reads ### Data model (create this before Phase 1) - projects(id, name, source_locale), languages(id, project_id, locale, plural_categories_json, enabled) - keys(id, project_id, name, source_text, context, max_length, screenshot_path, first_seen_at, removed_at) - translations(id, key_id, language_id, form, text, state, author_id, created_at) · form is the plural category, or none for a simple string - suggestions(id, key_id, language_id, form, text, source, author_id, created_at, resolved_as) - revisions(id, translation_id, text, at, author_id) · every change kept, because a regression needs a diff - imports(id, project_id, format, file_hash, at, keys_added, keys_removed, translations_updated) - A key removed from source is marked removed rather than deleted, so restoring it does not lose a year of translations. ### Phase 1 · Round trip a real file Build: Import the source locale file in the format your application already uses. Export it back and compare. Preserve ordering, comments and anything the format carries that you do not yet understand, rather than dropping it. Trap: Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. Done when: A real source file imports and exports byte-identically, verified by comparison. Comments and key ordering survive the round trip. A file with a key the parser does not understand fails the import loudly rather than silently dropping it. Re-importing an unchanged file reports no changes. Do not build yet: No languages, translators or plurals. ### Phase 2 · Plural forms per language, from the rules Build: For each target language, derive its plural categories from the Unicode rules rather than assuming. Render a translation editor with one field per category for that language. Validate that an ICU plural message covers every required category. Trap: A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. Trap: Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. Done when: Adding Arabic produces six plural fields and adding Russian produces four, both derived rather than configured. A plural message missing a required category fails validation naming the missing one. A language with a single form shows one field. Adding a new language requires no code change. Do not build yet: No translator workflow or export of translations yet. ### Phase 3 · Context, because a word is not a translation unit Build: Every key carries a context note, a maximum length where the interface constrains it, and optionally a screenshot showing where it appears. Import context from source comments so it comes from the code rather than being maintained separately. Trap: A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. Done when: A key shows its context, its length limit and any screenshot in the editor. Context imported from source comments updates when the comment changes. A translation exceeding the length limit is flagged before it is saved. A key with no context is visibly marked so it can be fixed at the source. Do not build yet: No review workflow yet. ### Phase 4 · Suggestions and review Build: Contributors submit suggestions rather than editing directly. A reviewer per language approves, edits or rejects. Approved text becomes the translation and every change keeps a revision. Machine translation, if enabled, enters as a suggestion like any other. Trap: Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. Done when: A suggestion does not change the exported translation until approved. Machine translation appears as a suggestion marked with its source. Every approved change keeps a revision with its author. A reviewer for one language cannot approve another, tested with a direct request. Do not build yet: No export pipeline or integration yet. ### Phase 5 · Export and the incomplete language question Build: Export approved translations per language in the source format. Decide explicitly what an untranslated key does: fall back to source, or be omitted so the application's own fallback handles it. Validate every exported message parses as ICU before writing. Trap: A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. Done when: An export contains only approved translations and every message parses as ICU, verified by parsing the exported file. A malformed message blocks the export naming the key. The fallback behaviour for untranslated keys matches what was configured. Exporting an unchanged project produces an identical file. Do not build yet: Integration and operations come last. ### Phase 6 · Keeping it in step with the code Build: A command that imports the source file from a repository and reports what changed. Removed keys are marked rather than deleted. A report shows per-language completeness. Wire it into your build so a release knows which languages are behind. Trap: Deleting a key because it disappeared from the source file loses every translation of it, and a key removed by mistake and restored a week later comes back untranslated in twelve languages. Mark it removed, keep the translations, and restore them if the key returns. Done when: Importing a source file with a removed key marks it rather than deleting, and restoring the key restores its translations. A completeness report shows the untranslated count per language. The import runs from a command suitable for continuous integration. A release can be blocked on a completeness threshold if you choose. Do not build yet: Nothing after this. ### Out of scope (and why) - The professional translator marketplace, which is the part of the hosted product that is people rather than software. - Translation memory that suggests from previously translated segments across projects, which needs a corpus you do not have yet. - The integration catalogue connecting to every framework and repository host, each of which is maintenance. - Quality assurance beyond format validation. Whether a translation is good is a judgement only a speaker can make. ### README must contain - That plural categories are derived from the Unicode rules per language, and that Arabic needs six while Russian needs four - That a key modelled as singular and plural is untranslatable into several languages - That context comes from source comments so it cannot drift - That machine translation enters as a suggestion and never ships unreviewed - That every exported message is validated as ICU before the file is written ===== AGENTS.md ===== # Agent instructions · POEditor indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with PostgreSQL, ICU MessageFormat, parsed and validated, Unicode plural rules through Intl.PluralRules, Import and export standard formats, never a proprietary one, An optional translation provider producing suggestions only. 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 - Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. - A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. - Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. - A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. - Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. - A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. ===== BUILD_PLAN.md ===== # Build plan · POEditor A place where source strings arrive from your repository with their context, translators work per language against the plural forms that language actually needs, suggestions are reviewed before they ship, and approved translations export back in the format your application already reads. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Round trip a real file Your application's translation file imports and exports back byte-identically. ### Steps 1. Parse the format you actually use Whichever your application reads. Adding a second format later is easier than migrating from a wrong first one. Files: `src/formats/` 2. Preserve everything on round trip Comments, order and unknown metadata. Compare byte for byte. Files: `src/formats/roundtrip.test.js` 3. Fail loudly on the unknown A key you cannot parse stops the import. Silently dropping it loses a translation nobody notices for months. Files: `src/import.js` ### Done when - [ ] A real file round trips byte-identically - [ ] Comments and ordering survive - [ ] An unparseable key fails the import loudly - [ ] Re-importing an unchanged file reports no changes ### Watch out - Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. ## Phase 2 · Plural forms per language, from the rules Each language shows exactly the plural forms it needs, not two. ### Steps 1. Derive categories per locale From Intl.PluralRules for the locale. Store them on the language so the editor can render from data. Files: `src/plurals.js` 2. Render one field per category With the category name and an example number, so a translator knows what few means here. Files: `src/editor.js` 3. Validate coverage An ICU plural message must cover every required category for its language. Missing one is a failed validation, not a warning. Files: `src/validate.js` ### Done when - [ ] Arabic produces six plural fields and Russian four - [ ] A missing category fails validation naming it - [ ] A single-form language shows one field - [ ] Adding a language needs no code change ### Watch out - A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. - Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. ## Phase 3 · Context, because a word is not a translation unit A translator can see what a string is for before translating it. ### Steps 1. Carry context from source comments So it lives with the code and cannot drift. A context maintained separately is a context that goes stale. Files: `src/import.js` 2. Show length limits where they exist German is routinely longer than English and a button that fits in one may not in the other. Files: `src/editor.js` 3. Mark context-free keys Visibly, as a backlog for the developers rather than a problem for the translator. Files: `src/keys.js` ### Done when - [ ] Context, length limit and screenshot appear in the editor - [ ] Context updates when the source comment changes - [ ] An over-length translation is flagged before saving - [ ] Keys without context are visibly marked ### Watch out - A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. ## Phase 4 · Suggestions and review Anyone can suggest, a reviewer approves, and nothing ships unreviewed. ### Steps 1. Separate suggestions from translations Different tables. The exported file reads only approved translations. Files: `src/suggestions.js` 2. Route machine output through the queue Marked with its source so a reviewer knows what they are reading. Files: `src/machine.js` 3. Enforce per-language review rights Server side, tested by direct request rather than through the interface. Files: `src/auth.js` ### Done when - [ ] An unapproved suggestion does not affect the export - [ ] Machine suggestions are marked with their source - [ ] Every approved change keeps a revision with its author - [ ] A reviewer cannot approve a language they do not own ### Watch out - Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. ## Phase 5 · Export and the incomplete language question Exports are valid, and a half-translated language behaves the way you decided. ### Steps 1. Export approved only Suggestions never reach a file. The state machine is the gate. Files: `src/export.js` 2. Validate before writing Parse every message. A file that throws in production is worse than a missing translation. Files: `src/export.js` 3. Decide the fallback deliberately Source text or omission. Both are defensible; an accidental mixture is not. Files: `docs/fallback.md` ### Done when - [ ] Every exported message parses as ICU - [ ] A malformed message blocks the export naming the key - [ ] Fallback behaviour matches the configuration - [ ] An unchanged export is identical ### Watch out - A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. ## Not in this build - A professional translator marketplace. - Cross-project translation memory. - The integration catalogue. - Any judgement about whether a translation is good. ## After v1, if you want it - Translation memory within one project, which needs no corpus beyond your own history - Screenshot capture from your test suite, so context arrives automatically - A pseudo-locale that expands strings, which finds every layout that will break in German ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/i18n # Required. The locale keys are authored in. Everything else translates from it. SOURCE_LOCALE=en # Required. The format your application reads. Round tripped exactly. EXPORT_FORMAT=json # Required. Whether an untranslated key falls back to source text or is omitted. Decide deliberately. UNTRANSLATED_BEHAVIOUR=omit # Optional · secret. Optional. Output enters the suggestion queue and never ships unreviewed. MACHINE_TRANSLATION_API_KEY=replace-with-key # Optional. Percentage below which a language is reported as blocking a release, if you use that check. RELEASE_COMPLETENESS_THRESHOLD=95
You are building a production product version of POEditor. 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 ===== # POEditor · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For POEditor, manage translation keys, contributors, suggestions, and standard file exports. The hard boundary is hosted collaboration, integrations, translation memory, and simple workflow, plus translation memory, collaboration, and deployment integrations. ## Product outcome A translation system where each language gets the plural forms it actually needs, translators see context, nothing ships without a speaker approving it, and every exported file is valid before it reaches production. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - PostgreSQL - self-hosted deployment - source locale files - optional DeepL or OpenAI API key ## Explicit non-goals for v1 - A professional translator marketplace. - Cross-project translation memory. - The integration catalogue. - Any judgement about whether a translation is good. - hosted collaboration, integrations, translation memory, and simple workflow - professional translator marketplace - advanced translation memory - website proxy network - enterprise workflows and integrations ## Success criteria - The source file round trips byte-identically - Plural categories are confirmed derived for a six-form and a four-form language - Every exported message is confirmed to parse as ICU - Machine output is confirmed unable to bypass review - A remove-and-restore cycle is confirmed to preserve translations ===== BRIEF.md ===== # Build brief · POEditor 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 management system where keys carry context and plurals are correct in every language to replace POEditor. 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 Do not invent a message format. ICU MessageFormat already expresses plurals, gender and nested selection, and the Unicode plural rules already say which forms each language needs. Adopting them is what makes a translation correct in languages you do not speak. The consequence to internalise now: a key modelled as a singular and a plural string cannot be translated correctly into Arabic, which uses all six plural categories, or Russian and Polish, which use four. That is not a formatting preference, it is the difference between correct and wrong text, and a key with the wrong shape cannot be fixed by the translator. Checked September 2026. ### Stack (fixed, do not substitute) - Runtime: Node 22 with PostgreSQL · keys, languages and revisions are relational, and a translation history you can query is the point - Message format: ICU MessageFormat, parsed and validated · the industry standard for plurals and selection, and validating it catches broken translations before they ship - Plural forms: Unicode plural rules through Intl.PluralRules · which forms a language needs is data the platform already has, and guessing it produces wrong text - Files: Import and export standard formats, never a proprietary one · the file your application reads is the source of truth and must round trip exactly - Machine assistance: An optional translation provider producing suggestions only · a suggestion a person approves is useful; an automatic translation that ships is how you get embarrassing text in a language nobody on the team reads ### Data model (create this before Phase 1) - projects(id, name, source_locale), languages(id, project_id, locale, plural_categories_json, enabled) - keys(id, project_id, name, source_text, context, max_length, screenshot_path, first_seen_at, removed_at) - translations(id, key_id, language_id, form, text, state, author_id, created_at) · form is the plural category, or none for a simple string - suggestions(id, key_id, language_id, form, text, source, author_id, created_at, resolved_as) - revisions(id, translation_id, text, at, author_id) · every change kept, because a regression needs a diff - imports(id, project_id, format, file_hash, at, keys_added, keys_removed, translations_updated) - A key removed from source is marked removed rather than deleted, so restoring it does not lose a year of translations. ### Phase 1 · Round trip a real file Build: Import the source locale file in the format your application already uses. Export it back and compare. Preserve ordering, comments and anything the format carries that you do not yet understand, rather than dropping it. Trap: Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. Done when: A real source file imports and exports byte-identically, verified by comparison. Comments and key ordering survive the round trip. A file with a key the parser does not understand fails the import loudly rather than silently dropping it. Re-importing an unchanged file reports no changes. Do not build yet: No languages, translators or plurals. ### Phase 2 · Plural forms per language, from the rules Build: For each target language, derive its plural categories from the Unicode rules rather than assuming. Render a translation editor with one field per category for that language. Validate that an ICU plural message covers every required category. Trap: A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. Trap: Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. Done when: Adding Arabic produces six plural fields and adding Russian produces four, both derived rather than configured. A plural message missing a required category fails validation naming the missing one. A language with a single form shows one field. Adding a new language requires no code change. Do not build yet: No translator workflow or export of translations yet. ### Phase 3 · Context, because a word is not a translation unit Build: Every key carries a context note, a maximum length where the interface constrains it, and optionally a screenshot showing where it appears. Import context from source comments so it comes from the code rather than being maintained separately. Trap: A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. Done when: A key shows its context, its length limit and any screenshot in the editor. Context imported from source comments updates when the comment changes. A translation exceeding the length limit is flagged before it is saved. A key with no context is visibly marked so it can be fixed at the source. Do not build yet: No review workflow yet. ### Phase 4 · Suggestions and review Build: Contributors submit suggestions rather than editing directly. A reviewer per language approves, edits or rejects. Approved text becomes the translation and every change keeps a revision. Machine translation, if enabled, enters as a suggestion like any other. Trap: Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. Done when: A suggestion does not change the exported translation until approved. Machine translation appears as a suggestion marked with its source. Every approved change keeps a revision with its author. A reviewer for one language cannot approve another, tested with a direct request. Do not build yet: No export pipeline or integration yet. ### Phase 5 · Export and the incomplete language question Build: Export approved translations per language in the source format. Decide explicitly what an untranslated key does: fall back to source, or be omitted so the application's own fallback handles it. Validate every exported message parses as ICU before writing. Trap: A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. Done when: An export contains only approved translations and every message parses as ICU, verified by parsing the exported file. A malformed message blocks the export naming the key. The fallback behaviour for untranslated keys matches what was configured. Exporting an unchanged project produces an identical file. Do not build yet: Integration and operations come last. ### Phase 6 · Keeping it in step with the code Build: A command that imports the source file from a repository and reports what changed. Removed keys are marked rather than deleted. A report shows per-language completeness. Wire it into your build so a release knows which languages are behind. Trap: Deleting a key because it disappeared from the source file loses every translation of it, and a key removed by mistake and restored a week later comes back untranslated in twelve languages. Mark it removed, keep the translations, and restore them if the key returns. Done when: Importing a source file with a removed key marks it rather than deleting, and restoring the key restores its translations. A completeness report shows the untranslated count per language. The import runs from a command suitable for continuous integration. A release can be blocked on a completeness threshold if you choose. Do not build yet: Nothing after this. ### Out of scope (and why) - The professional translator marketplace, which is the part of the hosted product that is people rather than software. - Translation memory that suggests from previously translated segments across projects, which needs a corpus you do not have yet. - The integration catalogue connecting to every framework and repository host, each of which is maintenance. - Quality assurance beyond format validation. Whether a translation is good is a judgement only a speaker can make. ### README must contain - That plural categories are derived from the Unicode rules per language, and that Arabic needs six while Russian needs four - That a key modelled as singular and plural is untranslatable into several languages - That context comes from source comments so it cannot drift - That machine translation enters as a suggestion and never ships unreviewed - That every exported message is validated as ICU before the file is written ===== ARCHITECTURE.md ===== # Architecture · POEditor ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with PostgreSQL | keys, languages and revisions are relational, and a translation history you can query is the point | | Message format | ICU MessageFormat, parsed and validated | the industry standard for plurals and selection, and validating it catches broken translations before they ship | | Plural forms | Unicode plural rules through Intl.PluralRules | which forms a language needs is data the platform already has, and guessing it produces wrong text | | Files | Import and export standard formats, never a proprietary one | the file your application reads is the source of truth and must round trip exactly | | Machine assistance | An optional translation provider producing suggestions only | a suggestion a person approves is useful; an automatic translation that ships is how you get embarrassing text in a language nobody on the team reads | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Formats | Import and export round trip. | Adding a format is a module. Byte-identical round trip is the contract each must meet. | | Plurals | Deriving categories per language. | Derived from platform rules rather than configured, so a new language costs nothing. | | Keys and context | What a string is for. | Context from source comments is what keeps it accurate. A separately maintained context is a stale one. | | Review | Suggestions, approval and revisions. | Any workflow, provided machine output cannot bypass it. | | Export | Producing valid files. | ICU validation before writing is a correctness property and must survive any change. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Your PostgreSQL connection string. - `SOURCE_LOCALE` · required · The locale keys are authored in. Everything else translates from it. - `EXPORT_FORMAT` · required · The format your application reads. Round tripped exactly. - `UNTRANSLATED_BEHAVIOUR` · required · Whether an untranslated key falls back to source text or is omitted. Decide deliberately. - `MACHINE_TRANSLATION_API_KEY` · optional, secret · Optional. Output enters the suggestion queue and never ships unreviewed. - `RELEASE_COMPLETENESS_THRESHOLD` · optional · Percentage below which a language is reported as blocking a release, if you use that check. ## 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 · POEditor product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with PostgreSQL, ICU MessageFormat, parsed and validated, Unicode plural rules through Intl.PluralRules, Import and export standard formats, never a proprietary one, An optional translation provider producing suggestions only. - 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 - Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. - A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. - Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. - A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. - Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. - A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. - Deleting a key because it disappeared from the source file loses every translation of it, and a key removed by mistake and restored a week later comes back untranslated in twelve languages. Mark it removed, keep the translations, and restore them if the key returns. ===== MILESTONES.md ===== # Delivery milestones · POEditor Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Round trip a real file Your application's translation file imports and exports back byte-identically. ### Steps 1. Parse the format you actually use Whichever your application reads. Adding a second format later is easier than migrating from a wrong first one. Files: `src/formats/` 2. Preserve everything on round trip Comments, order and unknown metadata. Compare byte for byte. Files: `src/formats/roundtrip.test.js` 3. Fail loudly on the unknown A key you cannot parse stops the import. Silently dropping it loses a translation nobody notices for months. Files: `src/import.js` ### Done when - [ ] A real file round trips byte-identically - [ ] Comments and ordering survive - [ ] An unparseable key fails the import loudly - [ ] Re-importing an unchanged file reports no changes ### Watch out - Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. ## M2 · Plural forms per language, from the rules Each language shows exactly the plural forms it needs, not two. ### Steps 1. Derive categories per locale From Intl.PluralRules for the locale. Store them on the language so the editor can render from data. Files: `src/plurals.js` 2. Render one field per category With the category name and an example number, so a translator knows what few means here. Files: `src/editor.js` 3. Validate coverage An ICU plural message must cover every required category for its language. Missing one is a failed validation, not a warning. Files: `src/validate.js` ### Done when - [ ] Arabic produces six plural fields and Russian four - [ ] A missing category fails validation naming it - [ ] A single-form language shows one field - [ ] Adding a language needs no code change ### Watch out - A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. - Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. ## M3 · Context, because a word is not a translation unit A translator can see what a string is for before translating it. ### Steps 1. Carry context from source comments So it lives with the code and cannot drift. A context maintained separately is a context that goes stale. Files: `src/import.js` 2. Show length limits where they exist German is routinely longer than English and a button that fits in one may not in the other. Files: `src/editor.js` 3. Mark context-free keys Visibly, as a backlog for the developers rather than a problem for the translator. Files: `src/keys.js` ### Done when - [ ] Context, length limit and screenshot appear in the editor - [ ] Context updates when the source comment changes - [ ] An over-length translation is flagged before saving - [ ] Keys without context are visibly marked ### Watch out - A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. ## M4 · Suggestions and review Anyone can suggest, a reviewer approves, and nothing ships unreviewed. ### Steps 1. Separate suggestions from translations Different tables. The exported file reads only approved translations. Files: `src/suggestions.js` 2. Route machine output through the queue Marked with its source so a reviewer knows what they are reading. Files: `src/machine.js` 3. Enforce per-language review rights Server side, tested by direct request rather than through the interface. Files: `src/auth.js` ### Done when - [ ] An unapproved suggestion does not affect the export - [ ] Machine suggestions are marked with their source - [ ] Every approved change keeps a revision with its author - [ ] A reviewer cannot approve a language they do not own ### Watch out - Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. ## M5 · Export and the incomplete language question Exports are valid, and a half-translated language behaves the way you decided. ### Steps 1. Export approved only Suggestions never reach a file. The state machine is the gate. Files: `src/export.js` 2. Validate before writing Parse every message. A file that throws in production is worse than a missing translation. Files: `src/export.js` 3. Decide the fallback deliberately Source text or omission. Both are defensible; an accidental mixture is not. Files: `docs/fallback.md` ### Done when - [ ] Every exported message parses as ICU - [ ] A malformed message blocks the export naming the key - [ ] Fallback behaviour matches the configuration - [ ] An unchanged export is identical ### Watch out - A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. ## M6 · Keeping it in step with the code (production only) New strings appear for translation automatically, and removed ones do not vanish. ### Steps 1. Import from the repository as a command Runnable in continuous integration, reporting added, removed and changed. Files: `src/cli.js` 2. Mark removals, keep translations And restore on return. Test the full remove-and-restore cycle. Files: `src/import.js` 3. Report completeness per language Untranslated and unreviewed counts. This is what a release decision needs. Files: `src/report.js` ### Done when - [ ] A removed key is marked and its translations survive - [ ] Restoring the key restores its translations - [ ] Completeness is reported per language - [ ] The import runs as a command in continuous integration ### Watch out - Deleting a key because it disappeared from the source file loses every translation of it, and a key removed by mistake and restored a week later comes back untranslated in twelve languages. Mark it removed, keep the translations, and restore them if the key returns. ===== OPERATIONS.md ===== # Operations · POEditor ## Backup The database holds every translation and its history, which represents real human work across languages. Back it up, and note that the exported files in your repository are a partial second copy of the approved state. ## Restore Restore and export each language, comparing against the files in your repository. If they match, the restore is complete; if they do not, the revision history is where the difference will be visible. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Completeness per language and the age of the oldest pending suggestion. A queue growing in one language usually means that reviewer has stopped, and the language will quietly fall behind. ## Incident checklist A malformed message reaching production affects only speakers of that language and is rarely reported quickly. The export validation is the control, and if one gets through, the validator missed a case worth adding to its tests. 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 - [ ] The source file round trips byte-identically - [ ] Plural categories are confirmed derived for a six-form and a four-form language - [ ] Every exported message is confirmed to parse as ICU - [ ] Machine output is confirmed unable to bypass review - [ ] A remove-and-restore cycle is confirmed to preserve translations ## Launch constraint Do not market omitted POEditor 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 · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/i18n # Required. The locale keys are authored in. Everything else translates from it. SOURCE_LOCALE=en # Required. The format your application reads. Round tripped exactly. EXPORT_FORMAT=json # Required. Whether an untranslated key falls back to source text or is omitted. Decide deliberately. UNTRANSLATED_BEHAVIOUR=omit # Optional · secret. Optional. Output enters the suggestion queue and never ships unreviewed. MACHINE_TRANSLATION_API_KEY=replace-with-key # Optional. Percentage below which a language is reported as blocking a release, if you use that check. RELEASE_COMPLETENESS_THRESHOLD=95
# POEditor · indie build A place where source strings arrive from your repository with their context, translators work per language against the plural forms that language actually needs, suggestions are reviewed before they ship, and approved translations export back in the format your application already reads. Estimated effort: **multi-day**. 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 22 with PostgreSQL | keys, languages and revisions are relational, and a translation history you can query is the point | | Message format | ICU MessageFormat, parsed and validated | the industry standard for plurals and selection, and validating it catches broken translations before they ship | | Plural forms | Unicode plural rules through Intl.PluralRules | which forms a language needs is data the platform already has, and guessing it produces wrong text | | Files | Import and export standard formats, never a proprietary one | the file your application reads is the source of truth and must round trip exactly | | Machine assistance | An optional translation provider producing suggestions only | a suggestion a person approves is useful; an automatic translation that ships is how you get embarrassing text in a language nobody on the team reads | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and provides Intl.PluralRules, which is where plural categories come from. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher and Intl.PluralRules exists - [ ] **PostgreSQL 16 or newer** · free - Why: Keys, translations and revisions are relational and the history needs to be queryable. - Get it: Install locally or run it in Docker. - Verify: psql connects and reports 16 or higher - [ ] **A real source locale file from your application** · free - Why: Phase 1 round trips it, and a synthetic file will not have the comments, ordering and edge cases that break a naive parser. - Get it: Take the actual file your application loads, with its real keys and comments. - Verify: It imports and exports byte-identically - [ ] **A decision about which languages you support** · free - Why: It determines how many plural forms your keys must accommodate, and adding a four-form language after modelling keys as two forms is a migration rather than a setting. - Get it: List them before Phase 2. If any of them is Arabic, Russian, Polish or another multi-form language, the key shape decision is settled for you. - Verify: The list exists and its plural categories are derived and recorded - [ ] **At least one reviewer per language who speaks it** · varies; this is the cost the hosted marketplace exists to solve - Why: Format validation catches malformed messages, not wrong ones. A language with no reviewer ships text nobody has read. - Get it: Identify a person per language before enabling it. A language without a reviewer should stay disabled rather than shipping machine output. - Verify: Every enabled language has a named reviewer ## 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 professional translator marketplace. - Cross-project translation memory. - The integration catalogue. - Any judgement about whether a translation is good. - hosted collaboration, integrations, translation memory, and simple workflow - professional translator marketplace - advanced translation memory - website proxy network - enterprise workflows and integrations If one of those is essential to you, that is the reason to keep paying for POEditor, and the README should say so rather than pretend.
# Build brief · POEditor 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 management system where keys carry context and plurals are correct in every language to replace POEditor. 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 Do not invent a message format. ICU MessageFormat already expresses plurals, gender and nested selection, and the Unicode plural rules already say which forms each language needs. Adopting them is what makes a translation correct in languages you do not speak. The consequence to internalise now: a key modelled as a singular and a plural string cannot be translated correctly into Arabic, which uses all six plural categories, or Russian and Polish, which use four. That is not a formatting preference, it is the difference between correct and wrong text, and a key with the wrong shape cannot be fixed by the translator. Checked September 2026. ### Stack (fixed, do not substitute) - Runtime: Node 22 with PostgreSQL · keys, languages and revisions are relational, and a translation history you can query is the point - Message format: ICU MessageFormat, parsed and validated · the industry standard for plurals and selection, and validating it catches broken translations before they ship - Plural forms: Unicode plural rules through Intl.PluralRules · which forms a language needs is data the platform already has, and guessing it produces wrong text - Files: Import and export standard formats, never a proprietary one · the file your application reads is the source of truth and must round trip exactly - Machine assistance: An optional translation provider producing suggestions only · a suggestion a person approves is useful; an automatic translation that ships is how you get embarrassing text in a language nobody on the team reads ### Data model (create this before Phase 1) - projects(id, name, source_locale), languages(id, project_id, locale, plural_categories_json, enabled) - keys(id, project_id, name, source_text, context, max_length, screenshot_path, first_seen_at, removed_at) - translations(id, key_id, language_id, form, text, state, author_id, created_at) · form is the plural category, or none for a simple string - suggestions(id, key_id, language_id, form, text, source, author_id, created_at, resolved_as) - revisions(id, translation_id, text, at, author_id) · every change kept, because a regression needs a diff - imports(id, project_id, format, file_hash, at, keys_added, keys_removed, translations_updated) - A key removed from source is marked removed rather than deleted, so restoring it does not lose a year of translations. ### Phase 1 · Round trip a real file Build: Import the source locale file in the format your application already uses. Export it back and compare. Preserve ordering, comments and anything the format carries that you do not yet understand, rather than dropping it. Trap: Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. Done when: A real source file imports and exports byte-identically, verified by comparison. Comments and key ordering survive the round trip. A file with a key the parser does not understand fails the import loudly rather than silently dropping it. Re-importing an unchanged file reports no changes. Do not build yet: No languages, translators or plurals. ### Phase 2 · Plural forms per language, from the rules Build: For each target language, derive its plural categories from the Unicode rules rather than assuming. Render a translation editor with one field per category for that language. Validate that an ICU plural message covers every required category. Trap: A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. Trap: Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. Done when: Adding Arabic produces six plural fields and adding Russian produces four, both derived rather than configured. A plural message missing a required category fails validation naming the missing one. A language with a single form shows one field. Adding a new language requires no code change. Do not build yet: No translator workflow or export of translations yet. ### Phase 3 · Context, because a word is not a translation unit Build: Every key carries a context note, a maximum length where the interface constrains it, and optionally a screenshot showing where it appears. Import context from source comments so it comes from the code rather than being maintained separately. Trap: A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. Done when: A key shows its context, its length limit and any screenshot in the editor. Context imported from source comments updates when the comment changes. A translation exceeding the length limit is flagged before it is saved. A key with no context is visibly marked so it can be fixed at the source. Do not build yet: No review workflow yet. ### Phase 4 · Suggestions and review Build: Contributors submit suggestions rather than editing directly. A reviewer per language approves, edits or rejects. Approved text becomes the translation and every change keeps a revision. Machine translation, if enabled, enters as a suggestion like any other. Trap: Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. Done when: A suggestion does not change the exported translation until approved. Machine translation appears as a suggestion marked with its source. Every approved change keeps a revision with its author. A reviewer for one language cannot approve another, tested with a direct request. Do not build yet: No export pipeline or integration yet. ### Phase 5 · Export and the incomplete language question Build: Export approved translations per language in the source format. Decide explicitly what an untranslated key does: fall back to source, or be omitted so the application's own fallback handles it. Validate every exported message parses as ICU before writing. Trap: A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. Done when: An export contains only approved translations and every message parses as ICU, verified by parsing the exported file. A malformed message blocks the export naming the key. The fallback behaviour for untranslated keys matches what was configured. Exporting an unchanged project produces an identical file. Do not build yet: Integration and operations come last. ### Phase 6 · Keeping it in step with the code Build: A command that imports the source file from a repository and reports what changed. Removed keys are marked rather than deleted. A report shows per-language completeness. Wire it into your build so a release knows which languages are behind. Trap: Deleting a key because it disappeared from the source file loses every translation of it, and a key removed by mistake and restored a week later comes back untranslated in twelve languages. Mark it removed, keep the translations, and restore them if the key returns. Done when: Importing a source file with a removed key marks it rather than deleting, and restoring the key restores its translations. A completeness report shows the untranslated count per language. The import runs from a command suitable for continuous integration. A release can be blocked on a completeness threshold if you choose. Do not build yet: Nothing after this. ### Out of scope (and why) - The professional translator marketplace, which is the part of the hosted product that is people rather than software. - Translation memory that suggests from previously translated segments across projects, which needs a corpus you do not have yet. - The integration catalogue connecting to every framework and repository host, each of which is maintenance. - Quality assurance beyond format validation. Whether a translation is good is a judgement only a speaker can make. ### README must contain - That plural categories are derived from the Unicode rules per language, and that Arabic needs six while Russian needs four - That a key modelled as singular and plural is untranslatable into several languages - That context comes from source comments so it cannot drift - That machine translation enters as a suggestion and never ships unreviewed - That every exported message is validated as ICU before the file is written
# Agent instructions · POEditor indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with PostgreSQL, ICU MessageFormat, parsed and validated, Unicode plural rules through Intl.PluralRules, Import and export standard formats, never a proprietary one, An optional translation provider producing suggestions only. 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 - Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. - A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. - Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. - A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. - Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. - A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one.
# Build plan · POEditor A place where source strings arrive from your repository with their context, translators work per language against the plural forms that language actually needs, suggestions are reviewed before they ship, and approved translations export back in the format your application already reads. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Round trip a real file Your application's translation file imports and exports back byte-identically. ### Steps 1. Parse the format you actually use Whichever your application reads. Adding a second format later is easier than migrating from a wrong first one. Files: `src/formats/` 2. Preserve everything on round trip Comments, order and unknown metadata. Compare byte for byte. Files: `src/formats/roundtrip.test.js` 3. Fail loudly on the unknown A key you cannot parse stops the import. Silently dropping it loses a translation nobody notices for months. Files: `src/import.js` ### Done when - [ ] A real file round trips byte-identically - [ ] Comments and ordering survive - [ ] An unparseable key fails the import loudly - [ ] Re-importing an unchanged file reports no changes ### Watch out - Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. ## Phase 2 · Plural forms per language, from the rules Each language shows exactly the plural forms it needs, not two. ### Steps 1. Derive categories per locale From Intl.PluralRules for the locale. Store them on the language so the editor can render from data. Files: `src/plurals.js` 2. Render one field per category With the category name and an example number, so a translator knows what few means here. Files: `src/editor.js` 3. Validate coverage An ICU plural message must cover every required category for its language. Missing one is a failed validation, not a warning. Files: `src/validate.js` ### Done when - [ ] Arabic produces six plural fields and Russian four - [ ] A missing category fails validation naming it - [ ] A single-form language shows one field - [ ] Adding a language needs no code change ### Watch out - A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. - Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. ## Phase 3 · Context, because a word is not a translation unit A translator can see what a string is for before translating it. ### Steps 1. Carry context from source comments So it lives with the code and cannot drift. A context maintained separately is a context that goes stale. Files: `src/import.js` 2. Show length limits where they exist German is routinely longer than English and a button that fits in one may not in the other. Files: `src/editor.js` 3. Mark context-free keys Visibly, as a backlog for the developers rather than a problem for the translator. Files: `src/keys.js` ### Done when - [ ] Context, length limit and screenshot appear in the editor - [ ] Context updates when the source comment changes - [ ] An over-length translation is flagged before saving - [ ] Keys without context are visibly marked ### Watch out - A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. ## Phase 4 · Suggestions and review Anyone can suggest, a reviewer approves, and nothing ships unreviewed. ### Steps 1. Separate suggestions from translations Different tables. The exported file reads only approved translations. Files: `src/suggestions.js` 2. Route machine output through the queue Marked with its source so a reviewer knows what they are reading. Files: `src/machine.js` 3. Enforce per-language review rights Server side, tested by direct request rather than through the interface. Files: `src/auth.js` ### Done when - [ ] An unapproved suggestion does not affect the export - [ ] Machine suggestions are marked with their source - [ ] Every approved change keeps a revision with its author - [ ] A reviewer cannot approve a language they do not own ### Watch out - Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. ## Phase 5 · Export and the incomplete language question Exports are valid, and a half-translated language behaves the way you decided. ### Steps 1. Export approved only Suggestions never reach a file. The state machine is the gate. Files: `src/export.js` 2. Validate before writing Parse every message. A file that throws in production is worse than a missing translation. Files: `src/export.js` 3. Decide the fallback deliberately Source text or omission. Both are defensible; an accidental mixture is not. Files: `docs/fallback.md` ### Done when - [ ] Every exported message parses as ICU - [ ] A malformed message blocks the export naming the key - [ ] Fallback behaviour matches the configuration - [ ] An unchanged export is identical ### Watch out - A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. ## Not in this build - A professional translator marketplace. - Cross-project translation memory. - The integration catalogue. - Any judgement about whether a translation is good. ## After v1, if you want it - Translation memory within one project, which needs no corpus beyond your own history - Screenshot capture from your test suite, so context arrives automatically - A pseudo-locale that expands strings, which finds every layout that will break in German
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/i18n # Required. The locale keys are authored in. Everything else translates from it. SOURCE_LOCALE=en # Required. The format your application reads. Round tripped exactly. EXPORT_FORMAT=json # Required. Whether an untranslated key falls back to source text or is omitted. Decide deliberately. UNTRANSLATED_BEHAVIOUR=omit # Optional · secret. Optional. Output enters the suggestion queue and never ships unreviewed. MACHINE_TRANSLATION_API_KEY=replace-with-key # Optional. Percentage below which a language is reported as blocking a release, if you use that check. RELEASE_COMPLETENESS_THRESHOLD=95
# POEditor · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For POEditor, manage translation keys, contributors, suggestions, and standard file exports. The hard boundary is hosted collaboration, integrations, translation memory, and simple workflow, plus translation memory, collaboration, and deployment integrations. ## Product outcome A translation system where each language gets the plural forms it actually needs, translators see context, nothing ships without a speaker approving it, and every exported file is valid before it reaches production. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - PostgreSQL - self-hosted deployment - source locale files - optional DeepL or OpenAI API key ## Explicit non-goals for v1 - A professional translator marketplace. - Cross-project translation memory. - The integration catalogue. - Any judgement about whether a translation is good. - hosted collaboration, integrations, translation memory, and simple workflow - professional translator marketplace - advanced translation memory - website proxy network - enterprise workflows and integrations ## Success criteria - The source file round trips byte-identically - Plural categories are confirmed derived for a six-form and a four-form language - Every exported message is confirmed to parse as ICU - Machine output is confirmed unable to bypass review - A remove-and-restore cycle is confirmed to preserve translations
# Build brief · POEditor 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 management system where keys carry context and plurals are correct in every language to replace POEditor. 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 Do not invent a message format. ICU MessageFormat already expresses plurals, gender and nested selection, and the Unicode plural rules already say which forms each language needs. Adopting them is what makes a translation correct in languages you do not speak. The consequence to internalise now: a key modelled as a singular and a plural string cannot be translated correctly into Arabic, which uses all six plural categories, or Russian and Polish, which use four. That is not a formatting preference, it is the difference between correct and wrong text, and a key with the wrong shape cannot be fixed by the translator. Checked September 2026. ### Stack (fixed, do not substitute) - Runtime: Node 22 with PostgreSQL · keys, languages and revisions are relational, and a translation history you can query is the point - Message format: ICU MessageFormat, parsed and validated · the industry standard for plurals and selection, and validating it catches broken translations before they ship - Plural forms: Unicode plural rules through Intl.PluralRules · which forms a language needs is data the platform already has, and guessing it produces wrong text - Files: Import and export standard formats, never a proprietary one · the file your application reads is the source of truth and must round trip exactly - Machine assistance: An optional translation provider producing suggestions only · a suggestion a person approves is useful; an automatic translation that ships is how you get embarrassing text in a language nobody on the team reads ### Data model (create this before Phase 1) - projects(id, name, source_locale), languages(id, project_id, locale, plural_categories_json, enabled) - keys(id, project_id, name, source_text, context, max_length, screenshot_path, first_seen_at, removed_at) - translations(id, key_id, language_id, form, text, state, author_id, created_at) · form is the plural category, or none for a simple string - suggestions(id, key_id, language_id, form, text, source, author_id, created_at, resolved_as) - revisions(id, translation_id, text, at, author_id) · every change kept, because a regression needs a diff - imports(id, project_id, format, file_hash, at, keys_added, keys_removed, translations_updated) - A key removed from source is marked removed rather than deleted, so restoring it does not lose a year of translations. ### Phase 1 · Round trip a real file Build: Import the source locale file in the format your application already uses. Export it back and compare. Preserve ordering, comments and anything the format carries that you do not yet understand, rather than dropping it. Trap: Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. Done when: A real source file imports and exports byte-identically, verified by comparison. Comments and key ordering survive the round trip. A file with a key the parser does not understand fails the import loudly rather than silently dropping it. Re-importing an unchanged file reports no changes. Do not build yet: No languages, translators or plurals. ### Phase 2 · Plural forms per language, from the rules Build: For each target language, derive its plural categories from the Unicode rules rather than assuming. Render a translation editor with one field per category for that language. Validate that an ICU plural message covers every required category. Trap: A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. Trap: Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. Done when: Adding Arabic produces six plural fields and adding Russian produces four, both derived rather than configured. A plural message missing a required category fails validation naming the missing one. A language with a single form shows one field. Adding a new language requires no code change. Do not build yet: No translator workflow or export of translations yet. ### Phase 3 · Context, because a word is not a translation unit Build: Every key carries a context note, a maximum length where the interface constrains it, and optionally a screenshot showing where it appears. Import context from source comments so it comes from the code rather than being maintained separately. Trap: A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. Done when: A key shows its context, its length limit and any screenshot in the editor. Context imported from source comments updates when the comment changes. A translation exceeding the length limit is flagged before it is saved. A key with no context is visibly marked so it can be fixed at the source. Do not build yet: No review workflow yet. ### Phase 4 · Suggestions and review Build: Contributors submit suggestions rather than editing directly. A reviewer per language approves, edits or rejects. Approved text becomes the translation and every change keeps a revision. Machine translation, if enabled, enters as a suggestion like any other. Trap: Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. Done when: A suggestion does not change the exported translation until approved. Machine translation appears as a suggestion marked with its source. Every approved change keeps a revision with its author. A reviewer for one language cannot approve another, tested with a direct request. Do not build yet: No export pipeline or integration yet. ### Phase 5 · Export and the incomplete language question Build: Export approved translations per language in the source format. Decide explicitly what an untranslated key does: fall back to source, or be omitted so the application's own fallback handles it. Validate every exported message parses as ICU before writing. Trap: A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. Done when: An export contains only approved translations and every message parses as ICU, verified by parsing the exported file. A malformed message blocks the export naming the key. The fallback behaviour for untranslated keys matches what was configured. Exporting an unchanged project produces an identical file. Do not build yet: Integration and operations come last. ### Phase 6 · Keeping it in step with the code Build: A command that imports the source file from a repository and reports what changed. Removed keys are marked rather than deleted. A report shows per-language completeness. Wire it into your build so a release knows which languages are behind. Trap: Deleting a key because it disappeared from the source file loses every translation of it, and a key removed by mistake and restored a week later comes back untranslated in twelve languages. Mark it removed, keep the translations, and restore them if the key returns. Done when: Importing a source file with a removed key marks it rather than deleting, and restoring the key restores its translations. A completeness report shows the untranslated count per language. The import runs from a command suitable for continuous integration. A release can be blocked on a completeness threshold if you choose. Do not build yet: Nothing after this. ### Out of scope (and why) - The professional translator marketplace, which is the part of the hosted product that is people rather than software. - Translation memory that suggests from previously translated segments across projects, which needs a corpus you do not have yet. - The integration catalogue connecting to every framework and repository host, each of which is maintenance. - Quality assurance beyond format validation. Whether a translation is good is a judgement only a speaker can make. ### README must contain - That plural categories are derived from the Unicode rules per language, and that Arabic needs six while Russian needs four - That a key modelled as singular and plural is untranslatable into several languages - That context comes from source comments so it cannot drift - That machine translation enters as a suggestion and never ships unreviewed - That every exported message is validated as ICU before the file is written
# Architecture · POEditor ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with PostgreSQL | keys, languages and revisions are relational, and a translation history you can query is the point | | Message format | ICU MessageFormat, parsed and validated | the industry standard for plurals and selection, and validating it catches broken translations before they ship | | Plural forms | Unicode plural rules through Intl.PluralRules | which forms a language needs is data the platform already has, and guessing it produces wrong text | | Files | Import and export standard formats, never a proprietary one | the file your application reads is the source of truth and must round trip exactly | | Machine assistance | An optional translation provider producing suggestions only | a suggestion a person approves is useful; an automatic translation that ships is how you get embarrassing text in a language nobody on the team reads | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Formats | Import and export round trip. | Adding a format is a module. Byte-identical round trip is the contract each must meet. | | Plurals | Deriving categories per language. | Derived from platform rules rather than configured, so a new language costs nothing. | | Keys and context | What a string is for. | Context from source comments is what keeps it accurate. A separately maintained context is a stale one. | | Review | Suggestions, approval and revisions. | Any workflow, provided machine output cannot bypass it. | | Export | Producing valid files. | ICU validation before writing is a correctness property and must survive any change. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Your PostgreSQL connection string. - `SOURCE_LOCALE` · required · The locale keys are authored in. Everything else translates from it. - `EXPORT_FORMAT` · required · The format your application reads. Round tripped exactly. - `UNTRANSLATED_BEHAVIOUR` · required · Whether an untranslated key falls back to source text or is omitted. Decide deliberately. - `MACHINE_TRANSLATION_API_KEY` · optional, secret · Optional. Output enters the suggestion queue and never ships unreviewed. - `RELEASE_COMPLETENESS_THRESHOLD` · optional · Percentage below which a language is reported as blocking a release, if you use that check. ## 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 · POEditor product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with PostgreSQL, ICU MessageFormat, parsed and validated, Unicode plural rules through Intl.PluralRules, Import and export standard formats, never a proprietary one, An optional translation provider producing suggestions only. - 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 - Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. - A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. - Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. - A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. - Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. - A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. - Deleting a key because it disappeared from the source file loses every translation of it, and a key removed by mistake and restored a week later comes back untranslated in twelve languages. Mark it removed, keep the translations, and restore them if the key returns.
# Delivery milestones · POEditor Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Round trip a real file Your application's translation file imports and exports back byte-identically. ### Steps 1. Parse the format you actually use Whichever your application reads. Adding a second format later is easier than migrating from a wrong first one. Files: `src/formats/` 2. Preserve everything on round trip Comments, order and unknown metadata. Compare byte for byte. Files: `src/formats/roundtrip.test.js` 3. Fail loudly on the unknown A key you cannot parse stops the import. Silently dropping it loses a translation nobody notices for months. Files: `src/import.js` ### Done when - [ ] A real file round trips byte-identically - [ ] Comments and ordering survive - [ ] An unparseable key fails the import loudly - [ ] Re-importing an unchanged file reports no changes ### Watch out - Dropping comments, ordering or metadata on import means the first export produces a file that diffs against the original everywhere, and nobody will trust the tool after that. Round trip before doing anything else, and treat any difference as a bug rather than a formatting preference. ## M2 · Plural forms per language, from the rules Each language shows exactly the plural forms it needs, not two. ### Steps 1. Derive categories per locale From Intl.PluralRules for the locale. Store them on the language so the editor can render from data. Files: `src/plurals.js` 2. Render one field per category With the category name and an example number, so a translator knows what few means here. Files: `src/editor.js` 3. Validate coverage An ICU plural message must cover every required category for its language. Missing one is a failed validation, not a warning. Files: `src/validate.js` ### Done when - [ ] Arabic produces six plural fields and Russian four - [ ] A missing category fails validation naming it - [ ] A single-form language shows one field - [ ] Adding a language needs no code change ### Watch out - A key with only singular and plural fields is untranslatable into Arabic, which uses zero, one, two, few, many and other, or into Russian and Polish, which use one, few, many and other. The shape of the key is the problem and the translator cannot work around it. - Do not hard code which languages need which forms. Derive them from the plural rules the platform provides, so adding a language is a configuration change rather than a code change. ## M3 · Context, because a word is not a translation unit A translator can see what a string is for before translating it. ### Steps 1. Carry context from source comments So it lives with the code and cannot drift. A context maintained separately is a context that goes stale. Files: `src/import.js` 2. Show length limits where they exist German is routinely longer than English and a button that fits in one may not in the other. Files: `src/editor.js` 3. Mark context-free keys Visibly, as a backlog for the developers rather than a problem for the translator. Files: `src/keys.js` ### Done when - [ ] Context, length limit and screenshot appear in the editor - [ ] Context updates when the source comment changes - [ ] An over-length translation is flagged before saving - [ ] Keys without context are visibly marked ### Watch out - A bare string with no context is ambiguous in most languages: Open is a verb and an adjective, and the correct translation differs. Without context the translator guesses, and the guess is wrong often enough to matter in exactly the languages nobody on your team can check. ## M4 · Suggestions and review Anyone can suggest, a reviewer approves, and nothing ships unreviewed. ### Steps 1. Separate suggestions from translations Different tables. The exported file reads only approved translations. Files: `src/suggestions.js` 2. Route machine output through the queue Marked with its source so a reviewer knows what they are reading. Files: `src/machine.js` 3. Enforce per-language review rights Server side, tested by direct request rather than through the interface. Files: `src/auth.js` ### Done when - [ ] An unapproved suggestion does not affect the export - [ ] Machine suggestions are marked with their source - [ ] Every approved change keeps a revision with its author - [ ] A reviewer cannot approve a language they do not own ### Watch out - Letting machine translation write directly into the approved translation means shipping text nobody has read in a language nobody on the team speaks, and the failure is invisible until a user reports it. Route it through the same suggestion queue, so the review step is structural rather than a policy people remember. ## M5 · Export and the incomplete language question Exports are valid, and a half-translated language behaves the way you decided. ### Steps 1. Export approved only Suggestions never reach a file. The state machine is the gate. Files: `src/export.js` 2. Validate before writing Parse every message. A file that throws in production is worse than a missing translation. Files: `src/export.js` 3. Decide the fallback deliberately Source text or omission. Both are defensible; an accidental mixture is not. Files: `docs/fallback.md` ### Done when - [ ] Every exported message parses as ICU - [ ] A malformed message blocks the export naming the key - [ ] Fallback behaviour matches the configuration - [ ] An unchanged export is identical ### Watch out - A translation containing a malformed ICU message will throw at runtime in the user's language and not in yours, so it ships and appears only for people who cannot easily report it. Validate every message at export and refuse to write a file containing one. ## M6 · Keeping it in step with the code (production only) New strings appear for translation automatically, and removed ones do not vanish. ### Steps 1. Import from the repository as a command Runnable in continuous integration, reporting added, removed and changed. Files: `src/cli.js` 2. Mark removals, keep translations And restore on return. Test the full remove-and-restore cycle. Files: `src/import.js` 3. Report completeness per language Untranslated and unreviewed counts. This is what a release decision needs. Files: `src/report.js` ### Done when - [ ] A removed key is marked and its translations survive - [ ] Restoring the key restores its translations - [ ] Completeness is reported per language - [ ] The import runs as a command in continuous integration ### Watch out - Deleting a key because it disappeared from the source file loses every translation of it, and a key removed by mistake and restored a week later comes back untranslated in twelve languages. Mark it removed, keep the translations, and restore them if the key returns.
# Operations · POEditor ## Backup The database holds every translation and its history, which represents real human work across languages. Back it up, and note that the exported files in your repository are a partial second copy of the approved state. ## Restore Restore and export each language, comparing against the files in your repository. If they match, the restore is complete; if they do not, the revision history is where the difference will be visible. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Completeness per language and the age of the oldest pending suggestion. A queue growing in one language usually means that reviewer has stopped, and the language will quietly fall behind. ## Incident checklist A malformed message reaching production affects only speakers of that language and is rarely reported quickly. The export validation is the control, and if one gets through, the validator missed a case worth adding to its tests. 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 - [ ] The source file round trips byte-identically - [ ] Plural categories are confirmed derived for a six-form and a four-form language - [ ] Every exported message is confirmed to parse as ICU - [ ] Machine output is confirmed unable to bypass review - [ ] A remove-and-restore cycle is confirmed to preserve translations ## Launch constraint Do not market omitted POEditor 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 · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/i18n # Required. The locale keys are authored in. Everything else translates from it. SOURCE_LOCALE=en # Required. The format your application reads. Round tripped exactly. EXPORT_FORMAT=json # Required. Whether an untranslated key falls back to source text or is omitted. Decide deliberately. UNTRANSLATED_BEHAVIOUR=omit # Optional · secret. Optional. Output enters the suggestion queue and never ships unreviewed. MACHINE_TRANSLATION_API_KEY=replace-with-key # Optional. Percentage below which a language is reported as blocking a release, if you use that check. RELEASE_COMPLETENESS_THRESHOLD=95
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for POEditor because localization teams pay because language context, review discipline, translators, and release integrations are the hard parts. The recurring cost buys file formats, placeholders, plural rules, screenshots, context, permissions, review, glossary, machine translation, sync, and backups, not just the visible interface.
xhosted collaboration, integrations, translation memory, and simple workflow
xprofessional translator marketplace
xadvanced translation memory
xwebsite proxy network
xenterprise workflows and integrations
Don't feel like building it? These folks already made it free.
all 4 free alternatives to POEditor →· no votes, no pay-to-list · just what's real
POEditor pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 1,000 strings; unlimited projects; up to 5 contributors; rate-limited API |
| start | $20/workspace | $17/workspace | 3,000 strings; unlimited projects and contributors |
| plus | $60/workspace | $51/workspace | 10,000 strings; unlimited projects and contributors; workflows |
| premium | $160/workspace | $136/workspace | 30,000 strings; unlimited projects and contributors; broader automation/integrations |
| enterprise | $260/workspace | $221/workspace | 100,000 strings; unlimited projects and contributors; SSO |
free tier1,000 strings total across all projects, unlimited projects, and up to 5 contributors
billingmonthly + every 6 months (-10%) + annual (-15%)
hidden costsThe string count is the sum of source terms and translations across every hosted project; upgrading mid-cycle charges the prorated difference.
verified 2026-08-14 · source ↗
Vibecode POEditor
Kinda. The core of POEditor is buildable in a weekend with the prompt on this page, but there are real gaps: hosted collaboration, integrations, translation memory, and simple workflow, professional translator marketplace. Read the honest list above before committing.
How much does POEditor cost?
POEditor costs about $20/month (Start, checked 2026-08-14), which is $240 per year.
What do I lose by replacing POEditor?
Honestly: hosted collaboration, integrations, translation memory, and simple workflow; professional translator marketplace; advanced translation memory; website proxy network; enterprise workflows and integrations. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to POEditor?
Yes: Weblate (Contributor translation, suggestions, checks, review and broad file-format support tied neatly to version control.) Tolgee (Keys, contributors, comments, review and normal localization formats, plus in-context editing POEditor never had.) Ever Traduora (A clean team translation catalog with suggestions, search, imports, exports and an API.) All 4 curated free alternatives are at vibecodeit.com/poeditor/alternatives. The prompt is for when you want it exactly your way.