phase-0: foundation, both apps boot end to end
Monorepo (pnpm workspaces) with two deployable apps and three pure packages. apps/api (Hono on Node): Zod validated env that fails fast and names the problem, Kysely factories for SQLite and Postgres chosen by DATABASE_URL scheme, portable migrations covering the whole SPEC section 5 schema, Better Auth with the four roles and seeded demo accounts, localized error envelope, /healthz and /readyz, graceful SIGTERM drain. Dialect specific SQL is confined to the two factories. apps/web (Next.js App Router): locale routed shell in es and en with a language switcher, sign in screen, and a runtime /api proxy so the browser only ever sees one origin and cookies stay first party. packages/i18n ships both catalogs complete; es is generated from COPY.md and a test re-derives it from the document on every run so it cannot drift. packages/contracts holds the Zod schemas and the typed client the web app uses. Verified: 43 vitest tests, 14 Playwright tests on mobile and desktop, typecheck and lint clean, migrate and seed from a clean database, sign in through the proxy with CSRF rejection of foreign origins. Not verified here: docker compose. This user has no access to the docker socket. RULES.md is absent from docs/, so packages/rules exports only RULES_VERSION and no tax rule, check digit or deadline was invented. See DECISIONS.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { en } from './en';
|
||||
import { esCopy } from './es';
|
||||
import { es } from './index';
|
||||
|
||||
const COPY_MD = fileURLToPath(new URL('../../../../docs/COPY.md', import.meta.url));
|
||||
|
||||
/** Re-derives the key/value pairs from COPY.md the same way the catalog was generated. */
|
||||
function parseCopyMd(): Map<string, string> {
|
||||
const entries = new Map<string, string>();
|
||||
let inBlock = false;
|
||||
for (const line of readFileSync(COPY_MD, 'utf8').split('\n')) {
|
||||
if (line.startsWith('```')) {
|
||||
inBlock = !inBlock;
|
||||
continue;
|
||||
}
|
||||
if (!inBlock) continue;
|
||||
const match = /^([A-Za-z][A-Za-z0-9_.]*)\s+=\s(.*)$/.exec(line);
|
||||
if (!match) continue;
|
||||
const [, key = '', value = ''] = match;
|
||||
// The only editorial annotation in COPY.md, stripped when the catalog was generated.
|
||||
entries.set(key, key === 'common.appName' ? 'Impuestos' : value);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
describe('es catalog', () => {
|
||||
const copy = parseCopyMd();
|
||||
|
||||
it('carries every string in COPY.md verbatim', () => {
|
||||
for (const [key, value] of copy) {
|
||||
expect(esCopy[key as keyof typeof esCopy], `key ${key}`).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
it('is exactly COPY.md, no more and no less', () => {
|
||||
expect(Object.keys(esCopy).sort()).toEqual([...copy.keys()].sort());
|
||||
});
|
||||
|
||||
it('is never overridden by the extra catalog', () => {
|
||||
for (const key of Object.keys(esCopy)) {
|
||||
expect(es[key as keyof typeof es], `key ${key}`).toBe(esCopy[key as keyof typeof esCopy]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog parity', () => {
|
||||
it('es and en have identical key sets', () => {
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(es).sort());
|
||||
});
|
||||
|
||||
it('en has no empty strings', () => {
|
||||
for (const [key, value] of Object.entries(en)) {
|
||||
expect(value.trim().length, `key ${key}`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('en uses exactly the same interpolation params as es', () => {
|
||||
const params = (value: string) => [...value.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
|
||||
for (const key of Object.keys(es) as (keyof typeof es)[]) {
|
||||
expect(params(en[key]), `key ${key}`).toEqual(params(es[key]));
|
||||
}
|
||||
});
|
||||
|
||||
it('uses no em dashes in any locale (COPY.md section 0)', () => {
|
||||
for (const catalog of [es, en]) {
|
||||
for (const [key, value] of Object.entries(catalog)) {
|
||||
expect(value.includes('—'), `key ${key}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('uses only the two permitted emoji', () => {
|
||||
const permitted = /[✓\u{1F525}]/u;
|
||||
const emoji = /\p{Extended_Pictographic}/u;
|
||||
for (const catalog of [es, en]) {
|
||||
for (const [key, value] of Object.entries(catalog)) {
|
||||
for (const char of value) {
|
||||
if (emoji.test(char)) expect(permitted.test(char), `key ${key}: ${char}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
// English catalog. Same keys as es (enforced by the type below and by parity.test.ts).
|
||||
// Written per COPY.md section 0-EN: natural English, Paraguayan tax terms kept in Spanish
|
||||
// with a short gloss on first use per screen. No em dashes. Money always "Gs.".
|
||||
|
||||
import type { MessageKey } from './index';
|
||||
|
||||
export const en: Record<MessageKey, string> = {
|
||||
// 1. Common
|
||||
"common.appName": "Impuestos",
|
||||
"common.continue": "Continue",
|
||||
"common.back": "Back",
|
||||
"common.save": "Save",
|
||||
"common.cancel": "Cancel",
|
||||
"common.confirm": "Confirm",
|
||||
"common.edit": "Edit",
|
||||
"common.delete": "Delete",
|
||||
"common.retry": "Try again",
|
||||
"common.close": "Close",
|
||||
"common.loading": "Loading...",
|
||||
"common.search": "Search",
|
||||
"common.seeDetail": "See detail",
|
||||
"common.optional": "(optional)",
|
||||
"common.error.generic": "Something went wrong on our side. Try again in a moment.",
|
||||
"common.error.offline": "No connection. Your changes are saved and will sync when you are back.",
|
||||
"common.status.alDia": "Up to date",
|
||||
"common.status.porVencer": "Due soon",
|
||||
"common.status.enRevision": "In review",
|
||||
"common.status.atrasado": "Overdue",
|
||||
|
||||
// 2. Landing
|
||||
"landing.hero.title": "Your taxes, on autopilot.",
|
||||
"landing.hero.subtitle": "Scan your facturas and we build your books, your deductions and your declarations. Never miss a vencimiento again.",
|
||||
"landing.hero.inputLabel": "Enter your RUC or CI",
|
||||
"landing.hero.cta": "See where I stand",
|
||||
"landing.value1.title": "Scan and done",
|
||||
"landing.value1.body": "Take a photo of any factura (invoice). We read it, verify it and file it under the right category for you.",
|
||||
"landing.value2.title": "Always know what you owe",
|
||||
"landing.value2.body": "Your IVA for the month and your IRP for the year, recalculated live with every factura you add.",
|
||||
"landing.value3.title": "Declarations ready to file",
|
||||
"landing.value3.body": "Your Formulario 120 and your 515 build themselves. You just review and approve.",
|
||||
"landing.preview.title": "This is what DNIT already knows about you",
|
||||
"landing.preview.deadline": "Your vencimientos (due dates) fall on day {day} of every month",
|
||||
"landing.preview.next": "Coming up: {d1}, {d2} and {d3}",
|
||||
"landing.preview.cta": "Create my free account",
|
||||
"landing.invalidDoc": "That number does not look valid. Check the verifier digit (the number after the dash).",
|
||||
|
||||
// 3. Auth
|
||||
"auth.register.title": "Create your account",
|
||||
"auth.register.email": "Your email",
|
||||
"auth.register.password": "Choose a password",
|
||||
"auth.login.title": "Sign in to your account",
|
||||
"auth.otp.title": "Check your email",
|
||||
"auth.otp.body": "We sent a 6 digit code to {email}.",
|
||||
"auth.otp.resend": "Resend code",
|
||||
"auth.logout": "Sign out",
|
||||
|
||||
// 4. Consent
|
||||
"consent.title": "Before we start, the important part",
|
||||
"consent.intro": "We store your facturas and your tax data to build your taxes. Nothing more, nothing less.",
|
||||
"consent.bullet1": "Your data is yours: download it or delete it whenever you want.",
|
||||
"consent.bullet2": "We never sell or share your information.",
|
||||
"consent.bullet3": "Every time our team accesses your data, it gets logged.",
|
||||
"consent.dataProcessing": "I agree to my data being processed for this service",
|
||||
"consent.notifications": "I want alerts about vencimientos (due dates) and news",
|
||||
"consent.policyLink": "Read the full policy",
|
||||
|
||||
// 5. Profile setup
|
||||
"setup.step1.title": "Confirm your details",
|
||||
"setup.fullName": "Full name",
|
||||
"setup.taxpayerKind.q": "Are you an individual or a company?",
|
||||
"setup.taxpayerKind.ind": "Individual",
|
||||
"setup.taxpayerKind.com": "Company",
|
||||
"setup.step2.title": "What are you registered for?",
|
||||
"setup.oblig.iva.title": "Monthly IVA",
|
||||
"setup.oblig.iva.body": "I have an active RUC and I invoice with IVA",
|
||||
"setup.oblig.irp.title": "IRP",
|
||||
"setup.oblig.irp.body": "I earn more than Gs. 80 million a year",
|
||||
"setup.oblig.unsure": "I am not sure",
|
||||
"setup.income.q": "How much do you expect to earn this year?",
|
||||
"setup.income.help": "We use this to project your IRP. You can change it whenever you want.",
|
||||
"setup.dependents.title": "Your dependants",
|
||||
"setup.dependents.help": "Expenses for the family members you support can be deductible too.",
|
||||
"setup.dependents.add": "Add a dependant",
|
||||
"setup.dependents.skip": "I will do this later",
|
||||
"setup.step3.title": "Alerts",
|
||||
"setup.push.title": "Turn on notifications",
|
||||
"setup.push.body": "One timely alert beats a thousand late fees. We only send what matters.",
|
||||
"setup.push.cta": "Turn on",
|
||||
"setup.push.later": "Not now",
|
||||
|
||||
// 6. Dashboard (Mi situacion)
|
||||
"home.title": "Where I stand",
|
||||
"home.iva.title": "IVA for {month}",
|
||||
"home.iva.aPagar": "To pay",
|
||||
"home.iva.aFavor": "In your favor",
|
||||
"home.iva.breakdown": "Debit Gs. {debito}, credit Gs. {credito}",
|
||||
"home.irp.title": "Projected IRP {year}",
|
||||
"home.irp.delta": "You brought it down Gs. {amount} this month thanks to your deductions",
|
||||
"home.irp.belowThreshold": "For now you are below the Gs. 80 million threshold. This is for information only.",
|
||||
"home.next.allClear": "All up to date ✓",
|
||||
"home.next.upcoming": "Next vencimiento: {date}",
|
||||
"home.next.dueSoon": "Your {form} is due in {days} days",
|
||||
"home.next.reviewCta": "Review and approve",
|
||||
"home.next.bandeja": "You have {count} facturas to review",
|
||||
"home.next.bandejaCta": "Go to the inbox",
|
||||
"home.insight.gapTitle": "You are leaving deductions on the table",
|
||||
"home.insight.gapBody": "You barely added any {category} facturas this year, and they are deductible.",
|
||||
"home.insight.monthClose": "You closed {month} with Gs. {savings} in new deductions.",
|
||||
"home.firstRun.title": "Let us start with your first factura",
|
||||
"home.firstRun.body": "Scan any factura (invoice) you have on hand and watch what happens.",
|
||||
"home.firstRun.scan": "Scan my first factura",
|
||||
"home.firstRun.manual": "Or enter it by hand",
|
||||
|
||||
// 7. Scan and manual entry
|
||||
"scan.fab": "Scan",
|
||||
"scan.hint": "Point at the QR on the factura",
|
||||
"scan.noQrHint": "No QR on the factura? Photograph it anyway",
|
||||
"scan.upload": "Upload a file",
|
||||
"scan.processing": "Reading your factura...",
|
||||
"scan.verified": "Verified ✓",
|
||||
"scan.registered": "Recorded",
|
||||
"scan.ocrLowConfidence": "Check the highlighted fields, we could not read them clearly.",
|
||||
"scan.duplicate.title": "You already had this factura",
|
||||
"scan.duplicate.body": "We recorded it on {date}. Nothing gets duplicated.",
|
||||
"scan.result.suggested": "We suggest: {category}",
|
||||
"scan.result.confirm": "Confirm",
|
||||
"scan.result.changeCat": "Change category",
|
||||
"scan.result.discard": "Discard",
|
||||
"manual.title": "Enter a factura by hand",
|
||||
"manual.emitterRuc": "RUC of the issuer",
|
||||
"manual.emitterName": "Name or business name",
|
||||
"manual.total": "Total",
|
||||
"manual.ivaSplit.q": "All at 10%?",
|
||||
"manual.date": "Date",
|
||||
"manual.saved": "Factura saved ✓",
|
||||
|
||||
// 8. Bandeja
|
||||
"bandeja.title": "Inbox",
|
||||
"bandeja.empty": "Inbox clear ✓",
|
||||
"bandeja.emptyBody": "When you scan a factura or a new one reaches us, it shows up here for you to confirm.",
|
||||
"bandeja.confirm": "Confirm",
|
||||
"bandeja.reject.title": "Why are you discarding it?",
|
||||
"bandeja.reject.notMine": "Not mine",
|
||||
"bandeja.reject.duplicate": "It is a duplicate",
|
||||
"bandeja.reject.other": "Another reason",
|
||||
"bandeja.autoConfirmNote": "High confidence facturas confirm themselves after {days} days.",
|
||||
"bandeja.autoConfirmLink": "Change",
|
||||
"categories.alimentacion": "Food",
|
||||
"categories.salud": "Health",
|
||||
"categories.educacion": "Education",
|
||||
"categories.vivienda": "Housing",
|
||||
"categories.vestimenta": "Clothing",
|
||||
"categories.esparcimiento": "Leisure",
|
||||
"categories.vehiculo": "Vehicle",
|
||||
"categories.familiares": "Dependants",
|
||||
"categories.none": "No category",
|
||||
|
||||
// 9. Declarations
|
||||
"decl.title": "Declarations",
|
||||
"decl.generate": "Prepare {form} for {period}",
|
||||
"decl.status.draft": "Draft",
|
||||
"decl.status.ready": "Ready to review",
|
||||
"decl.status.approved": "Approved",
|
||||
"decl.f120.summary": "You sold Gs. {sales} and bought Gs. {purchases}. {result}",
|
||||
"decl.f120.toPay": "You owe Gs. {amount}.",
|
||||
"decl.f120.inFavor": "You carry a credit of Gs. {amount} into next month.",
|
||||
"decl.f515.storyTitle": "Your {year}",
|
||||
"decl.f515.capNote": "Gs. {amount} could not be deducted because of the 1% cap on RESIMPLE purchases.",
|
||||
"decl.preview.draftMark": "BORRADOR",
|
||||
"decl.approve": "Approve",
|
||||
"decl.approve.confirmTitle": "Approve this declaration?",
|
||||
"decl.approve.confirmBody": "You are about to approve your {form} for {period} for Gs. {amount}.",
|
||||
"decl.downloadPdf": "Download PDF",
|
||||
"decl.checklist.title": "File it in Marangatu",
|
||||
"decl.checklist.intro": "Follow these steps with your Marangatu password. The values are ready to copy.",
|
||||
"decl.checklist.copyValue": "Copy value",
|
||||
"decl.checklist.done": "I already filed it",
|
||||
"decl.filed.title": "Declaration filed ✓",
|
||||
"decl.filed.body": "We will remind you before the payment date. Nice work.",
|
||||
"decl.streak": "{count} periods in a row up to date 🔥",
|
||||
|
||||
// 10. Documents, deadlines, profile
|
||||
"docs.title": "Documents",
|
||||
"docs.filter.month": "Month",
|
||||
"docs.filter.category": "Category",
|
||||
"docs.detail.trail": "Change history",
|
||||
"vto.title": "Due dates",
|
||||
"vto.explainer": "Because your RUC ends in {digit}, your vencimientos fall on day {day} of every month.",
|
||||
"profile.title": "Profile",
|
||||
"profile.myData.title": "Your data",
|
||||
"profile.myData.body": "This is everything we store about you.",
|
||||
"profile.myData.export": "Download my data",
|
||||
"profile.myData.delete": "Delete my account",
|
||||
"profile.myData.deleteWarn": "Everything goes: your facturas, your declarations and your account. There is no undo.",
|
||||
"profile.consent.revoke": "Revoke consent",
|
||||
"notif.digestHour": "Daily summary time",
|
||||
|
||||
// 11. Notifications (templates)
|
||||
"push.digest": "{bandejaCount} facturas to review · next vencimiento {date}",
|
||||
"push.deadline.t2": "Your {form} is due the day after tomorrow: Gs. {amount}",
|
||||
"push.deadline.t0": "Your {form} is due today: Gs. {amount}",
|
||||
"push.declReady": "Your {form} for {period} is ready to review",
|
||||
"push.savings": "We found Gs. {amount} of new credit this month",
|
||||
"email.subject.deadline": "Your {form} is due on {date}",
|
||||
"telegram.linked": "Done, I will ping you here. Only what matters.",
|
||||
|
||||
// Error envelope (es.extra.ts)
|
||||
"error.validation_error": "Check what you entered, something does not add up.",
|
||||
"error.unauthorized": "You need to sign in to see this.",
|
||||
"error.forbidden": "You do not have permission to do this.",
|
||||
"error.not_found": "We could not find what you were looking for.",
|
||||
"error.conflict": "This changed since you last saw it. Refresh and try again.",
|
||||
"error.rate_limited": "That was a lot of attempts in a row. Wait a moment and try again.",
|
||||
"error.ocr_unavailable": "We cannot read photos right now. Enter the factura by hand.",
|
||||
|
||||
// Auth screens (es.extra.ts)
|
||||
"auth.login.password": "Your password",
|
||||
"auth.login.submit": "Sign in",
|
||||
"auth.login.failed": "That email and password do not match. Try again.",
|
||||
"auth.login.noAccount": "Do not have an account yet?",
|
||||
|
||||
// Chrome (es.extra.ts)
|
||||
"common.language": "Language",
|
||||
"common.languageEs": "Español",
|
||||
"common.languageEn": "English",
|
||||
|
||||
// 12. Admin
|
||||
"admin.users.title": "Users",
|
||||
"admin.users.auditBanner": "Every access to user data is logged.",
|
||||
"admin.errors.title": "Ingestion errors",
|
||||
"admin.errors.resolve": "Mark resolved",
|
||||
"admin.audit.title": "Audit",
|
||||
"admin.audit.export": "Export CSV",
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
// Strings the product needs that COPY.md does not list. They follow the tone rules in
|
||||
// COPY.md section 0: voseo, plain words, never blame the user, no em dashes.
|
||||
// Anything COPY.md does define belongs in es.ts and must stay verbatim.
|
||||
|
||||
export const esExtra = {
|
||||
// Error envelope. Every non-2xx response carries one of these, localized to the
|
||||
// requester. `internal` reuses common.error.generic.
|
||||
"error.validation_error": "Revisá los datos que cargaste, hay algo que no cierra.",
|
||||
"error.unauthorized": "Necesitás iniciar sesion para ver esto.",
|
||||
"error.forbidden": "No tenés permiso para hacer esto.",
|
||||
"error.not_found": "No encontramos lo que buscabas.",
|
||||
"error.conflict": "Esto cambio desde la ultima vez que lo viste. Actualizá y probá de nuevo.",
|
||||
"error.rate_limited": "Probaste muchas veces seguidas. Esperá un momento y volvé a intentar.",
|
||||
"error.ocr_unavailable": "Por ahora no podemos leer fotos. Cargá la factura a mano.",
|
||||
|
||||
// Auth screens
|
||||
"auth.login.password": "Tu contraseña",
|
||||
"auth.login.submit": "Entrar",
|
||||
"auth.login.failed": "Ese email y esa contraseña no coinciden. Probá de nuevo.",
|
||||
"auth.login.noAccount": "¿Todavia no tenés cuenta?",
|
||||
|
||||
// Chrome
|
||||
"common.language": "Idioma",
|
||||
"common.languageEs": "Español",
|
||||
"common.languageEn": "English",
|
||||
} as const satisfies Record<string, string>;
|
||||
@@ -0,0 +1,212 @@
|
||||
// Generated from docs/COPY.md, verbatim. Do not paraphrase.
|
||||
// Strings the product needs that COPY.md does not define live in es.extra.ts.
|
||||
// The parity test in copy-parity.test.ts re-derives this file from COPY.md and fails on drift.
|
||||
|
||||
export const esCopy = {
|
||||
// 1. Common
|
||||
"common.appName": "Impuestos",
|
||||
"common.continue": "Continuar",
|
||||
"common.back": "Volver",
|
||||
"common.save": "Guardar",
|
||||
"common.cancel": "Cancelar",
|
||||
"common.confirm": "Confirmar",
|
||||
"common.edit": "Editar",
|
||||
"common.delete": "Eliminar",
|
||||
"common.retry": "Reintentar",
|
||||
"common.close": "Cerrar",
|
||||
"common.loading": "Cargando...",
|
||||
"common.search": "Buscar",
|
||||
"common.seeDetail": "Ver detalle",
|
||||
"common.optional": "(opcional)",
|
||||
"common.error.generic": "Algo salio mal de nuestro lado. Probá de nuevo en un momento.",
|
||||
"common.error.offline": "Sin conexion. Tus cambios se guardan y se sincronizan al volver.",
|
||||
"common.status.alDia": "Al dia",
|
||||
"common.status.porVencer": "Por vencer",
|
||||
"common.status.enRevision": "En revision",
|
||||
"common.status.atrasado": "Atrasado",
|
||||
|
||||
// 2. Landing
|
||||
"landing.hero.title": "Tus impuestos, en piloto automatico",
|
||||
"landing.hero.subtitle": "Escaneá tus facturas y nosotros armamos tus libros, tus deducciones y tus declaraciones. Nunca mas un vencimiento olvidado.",
|
||||
"landing.hero.inputLabel": "Ingresá tu RUC o CI",
|
||||
"landing.hero.cta": "Ver mi situacion",
|
||||
"landing.value1.title": "Escaneá y listo",
|
||||
"landing.value1.body": "Sacale una foto a cualquier factura. La leemos, la verificamos y la clasificamos por vos.",
|
||||
"landing.value2.title": "Sabé cuanto vas a pagar, siempre",
|
||||
"landing.value2.body": "Tu IVA del mes y tu IRP del año, calculados en vivo con cada factura que cargás.",
|
||||
"landing.value3.title": "Declaraciones listas para presentar",
|
||||
"landing.value3.body": "Tu Formulario 120 y tu 515 se arman solos. Vos solo revisás y aprobás.",
|
||||
"landing.preview.title": "Esto es lo que la DNIT ya sabe de vos",
|
||||
"landing.preview.deadline": "Tus vencimientos caen el dia {day} de cada mes",
|
||||
"landing.preview.next": "Proximos: {d1}, {d2} y {d3}",
|
||||
"landing.preview.cta": "Crear mi cuenta gratis",
|
||||
"landing.invalidDoc": "Ese numero no parece valido. Revisá el digito verificador (el numero despues del guion).",
|
||||
|
||||
// 3. Auth
|
||||
"auth.register.title": "Creá tu cuenta",
|
||||
"auth.register.email": "Tu email",
|
||||
"auth.register.password": "Elegí una contraseña",
|
||||
"auth.login.title": "Entrá a tu cuenta",
|
||||
"auth.otp.title": "Revisá tu email",
|
||||
"auth.otp.body": "Te enviamos un codigo de 6 digitos a {email}.",
|
||||
"auth.otp.resend": "Reenviar codigo",
|
||||
"auth.logout": "Cerrar sesion",
|
||||
|
||||
// 4. Consent
|
||||
"consent.title": "Antes de empezar, lo importante",
|
||||
"consent.intro": "Guardamos tus facturas y tus datos fiscales para armar tus impuestos. Nada mas, nada menos.",
|
||||
"consent.bullet1": "Tus datos son tuyos: los podes descargar o borrar cuando quieras.",
|
||||
"consent.bullet2": "Nunca vendemos ni compartimos tu informacion.",
|
||||
"consent.bullet3": "Todo acceso de nuestro equipo a tus datos queda registrado.",
|
||||
"consent.dataProcessing": "Acepto el tratamiento de mis datos para este servicio",
|
||||
"consent.notifications": "Quiero recibir avisos de vencimientos y novedades",
|
||||
"consent.policyLink": "Leer la politica completa",
|
||||
|
||||
// 5. Profile setup
|
||||
"setup.step1.title": "Confirmá tus datos",
|
||||
"setup.fullName": "Nombre completo",
|
||||
"setup.taxpayerKind.q": "¿Sos persona o empresa?",
|
||||
"setup.taxpayerKind.ind": "Persona fisica",
|
||||
"setup.taxpayerKind.com": "Empresa",
|
||||
"setup.step2.title": "¿Que obligaciones tenes?",
|
||||
"setup.oblig.iva.title": "IVA mensual",
|
||||
"setup.oblig.iva.body": "Tengo RUC activo y facturo con IVA",
|
||||
"setup.oblig.irp.title": "IRP",
|
||||
"setup.oblig.irp.body": "Gano mas de Gs. 80 millones al año",
|
||||
"setup.oblig.unsure": "No estoy seguro",
|
||||
"setup.income.q": "¿Cuanto estimás que vas a ganar este año?",
|
||||
"setup.income.help": "Sirve para proyectar tu IRP. Lo podés cambiar cuando quieras.",
|
||||
"setup.dependents.title": "Tus familiares a cargo",
|
||||
"setup.dependents.help": "Los gastos de tus familiares a cargo tambien pueden ser deducibles.",
|
||||
"setup.dependents.add": "Agregar familiar",
|
||||
"setup.dependents.skip": "Lo hago despues",
|
||||
"setup.step3.title": "Avisos",
|
||||
"setup.push.title": "Activá las notificaciones",
|
||||
"setup.push.body": "Un aviso a tiempo vale mas que mil recargos. Te avisamos solo lo importante.",
|
||||
"setup.push.cta": "Activar",
|
||||
"setup.push.later": "Ahora no",
|
||||
|
||||
// 6. Dashboard (Mi situacion)
|
||||
"home.title": "Mi situacion",
|
||||
"home.iva.title": "IVA de {month}",
|
||||
"home.iva.aPagar": "A pagar",
|
||||
"home.iva.aFavor": "A tu favor",
|
||||
"home.iva.breakdown": "Debito Gs. {debito}, credito Gs. {credito}",
|
||||
"home.irp.title": "IRP proyectado {year}",
|
||||
"home.irp.delta": "Bajaste Gs. {amount} este mes gracias a tus deducciones",
|
||||
"home.irp.belowThreshold": "Por ahora estas debajo del minimo de Gs. 80 millones. Esto es solo informativo.",
|
||||
"home.next.allClear": "Todo al dia ✓",
|
||||
"home.next.upcoming": "Proximo vencimiento: {date}",
|
||||
"home.next.dueSoon": "Vence tu {form} en {days} dias",
|
||||
"home.next.reviewCta": "Revisar y aprobar",
|
||||
"home.next.bandeja": "Tenes {count} facturas por revisar",
|
||||
"home.next.bandejaCta": "Ir a la bandeja",
|
||||
"home.insight.gapTitle": "Te estas perdiendo deducciones",
|
||||
"home.insight.gapBody": "Casi no cargaste facturas de {category} este año, y son deducibles.",
|
||||
"home.insight.monthClose": "Cerraste {month} con Gs. {savings} en deducciones nuevas.",
|
||||
"home.firstRun.title": "Empecemos con tu primera factura",
|
||||
"home.firstRun.body": "Escaneá cualquier factura que tengas a mano y mirá lo que pasa.",
|
||||
"home.firstRun.scan": "Escanear mi primera factura",
|
||||
"home.firstRun.manual": "O cargala a mano",
|
||||
|
||||
// 7. Scan and manual entry
|
||||
"scan.fab": "Escanear",
|
||||
"scan.hint": "Enfocá el QR de la factura",
|
||||
"scan.noQrHint": "¿Factura sin QR? Sacale una foto igual",
|
||||
"scan.upload": "Subir archivo",
|
||||
"scan.processing": "Leyendo tu factura...",
|
||||
"scan.verified": "Verificado ✓",
|
||||
"scan.registered": "Registrada",
|
||||
"scan.ocrLowConfidence": "Revisá los campos marcados, no los pudimos leer bien.",
|
||||
"scan.duplicate.title": "Ya tenias esta factura",
|
||||
"scan.duplicate.body": "La registramos el {date}. No se duplica nada.",
|
||||
"scan.result.suggested": "Sugerimos: {category}",
|
||||
"scan.result.confirm": "Confirmar",
|
||||
"scan.result.changeCat": "Cambiar categoria",
|
||||
"scan.result.discard": "Descartar",
|
||||
"manual.title": "Cargar factura a mano",
|
||||
"manual.emitterRuc": "RUC del que emitio",
|
||||
"manual.emitterName": "Nombre o razon social",
|
||||
"manual.total": "Total",
|
||||
"manual.ivaSplit.q": "¿Todo al 10%?",
|
||||
"manual.date": "Fecha",
|
||||
"manual.saved": "Factura guardada ✓",
|
||||
|
||||
// 8. Bandeja
|
||||
"bandeja.title": "Bandeja",
|
||||
"bandeja.empty": "Bandeja limpia ✓",
|
||||
"bandeja.emptyBody": "Cuando escanees o recibamos facturas nuevas, aparecen aca para que las confirmes.",
|
||||
"bandeja.confirm": "Confirmar",
|
||||
"bandeja.reject.title": "¿Por que la descartas?",
|
||||
"bandeja.reject.notMine": "No es mia",
|
||||
"bandeja.reject.duplicate": "Esta duplicada",
|
||||
"bandeja.reject.other": "Otro motivo",
|
||||
"bandeja.autoConfirmNote": "Las facturas con alta confianza se confirman solas en {days} dias.",
|
||||
"bandeja.autoConfirmLink": "Cambiar",
|
||||
"categories.alimentacion": "Alimentacion",
|
||||
"categories.salud": "Salud",
|
||||
"categories.educacion": "Educacion",
|
||||
"categories.vivienda": "Vivienda",
|
||||
"categories.vestimenta": "Vestimenta",
|
||||
"categories.esparcimiento": "Esparcimiento",
|
||||
"categories.vehiculo": "Vehiculo",
|
||||
"categories.familiares": "Familiares",
|
||||
"categories.none": "Sin categoria",
|
||||
|
||||
// 9. Declarations
|
||||
"decl.title": "Declaraciones",
|
||||
"decl.generate": "Preparar {form} de {period}",
|
||||
"decl.status.draft": "Borrador",
|
||||
"decl.status.ready": "Lista para revisar",
|
||||
"decl.status.approved": "Aprobada",
|
||||
"decl.f120.summary": "Vendiste Gs. {sales} y compraste Gs. {purchases}. {result}",
|
||||
"decl.f120.toPay": "Te corresponde pagar Gs. {amount}.",
|
||||
"decl.f120.inFavor": "Te queda un saldo a favor de Gs. {amount} para el mes que viene.",
|
||||
"decl.f515.storyTitle": "Tu año {year}",
|
||||
"decl.f515.capNote": "Gs. {amount} no se pudieron deducir por el tope del 1% en compras a RESIMPLE.",
|
||||
"decl.preview.draftMark": "BORRADOR",
|
||||
"decl.approve": "Aprobar",
|
||||
"decl.approve.confirmTitle": "¿Aprobas esta declaracion?",
|
||||
"decl.approve.confirmBody": "Vas a aprobar tu {form} de {period} por Gs. {amount}.",
|
||||
"decl.downloadPdf": "Descargar PDF",
|
||||
"decl.checklist.title": "Presentala en Marangatu",
|
||||
"decl.checklist.intro": "Segui estos pasos con tu clave de Marangatu. Los valores ya estan listos para copiar.",
|
||||
"decl.checklist.copyValue": "Copiar valor",
|
||||
"decl.checklist.done": "Ya la presente",
|
||||
"decl.filed.title": "Declaracion presentada ✓",
|
||||
"decl.filed.body": "Te avisamos antes de la fecha de pago. Buen trabajo.",
|
||||
"decl.streak": "{count} periodos seguidos al dia 🔥",
|
||||
|
||||
// 10. Documents, deadlines, profile
|
||||
"docs.title": "Comprobantes",
|
||||
"docs.filter.month": "Mes",
|
||||
"docs.filter.category": "Categoria",
|
||||
"docs.detail.trail": "Historial de cambios",
|
||||
"vto.title": "Vencimientos",
|
||||
"vto.explainer": "Por tu RUC terminado en {digit}, tus vencimientos caen el dia {day} de cada mes.",
|
||||
"profile.title": "Perfil",
|
||||
"profile.myData.title": "Tus datos",
|
||||
"profile.myData.body": "Esto es todo lo que guardamos sobre vos.",
|
||||
"profile.myData.export": "Descargar mis datos",
|
||||
"profile.myData.delete": "Eliminar mi cuenta",
|
||||
"profile.myData.deleteWarn": "Se borra todo: tus facturas, tus declaraciones y tu cuenta. No hay vuelta atras.",
|
||||
"profile.consent.revoke": "Revocar consentimiento",
|
||||
"notif.digestHour": "Hora del resumen diario",
|
||||
|
||||
// 11. Notifications (templates)
|
||||
"push.digest": "{bandejaCount} facturas por revisar · proximo vencimiento {date}",
|
||||
"push.deadline.t2": "Pasado mañana vence tu {form}: Gs. {amount}",
|
||||
"push.deadline.t0": "Hoy vence tu {form}: Gs. {amount}",
|
||||
"push.declReady": "Tu {form} de {period} esta lista para revisar",
|
||||
"push.savings": "Encontramos Gs. {amount} de credito nuevo este mes",
|
||||
"email.subject.deadline": "Vence tu {form} el {date}",
|
||||
"telegram.linked": "Listo, te aviso por aca. Solo lo importante.",
|
||||
|
||||
// 12. Admin
|
||||
"admin.users.title": "Usuarios",
|
||||
"admin.users.auditBanner": "Todos los accesos a datos de usuarios quedan registrados.",
|
||||
"admin.errors.title": "Errores de ingesta",
|
||||
"admin.errors.resolve": "Marcar resuelto",
|
||||
"admin.audit.title": "Auditoria",
|
||||
"admin.audit.export": "Exportar CSV",
|
||||
} as const satisfies Record<string, string>;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { esCopy } from './es';
|
||||
import { esExtra } from './es.extra';
|
||||
|
||||
/** The Spanish catalog: COPY.md verbatim, plus the strings COPY.md does not define. */
|
||||
export const es = { ...esCopy, ...esExtra };
|
||||
|
||||
/** Every message key in the product. The `en` catalog is typed against this. */
|
||||
export type MessageKey = keyof typeof es;
|
||||
|
||||
export { esCopy } from './es';
|
||||
export { esExtra } from './es.extra';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatDateLong,
|
||||
formatDateShort,
|
||||
formatGs,
|
||||
formatGsAmount,
|
||||
formatMonthName,
|
||||
} from './format';
|
||||
|
||||
describe('formatGs', () => {
|
||||
it('groups thousands with dots', () => {
|
||||
expect(formatGs(1234567)).toBe('Gs. 1.234.567');
|
||||
expect(formatGs(1000)).toBe('Gs. 1.000');
|
||||
expect(formatGs(999)).toBe('Gs. 999');
|
||||
expect(formatGs(0)).toBe('Gs. 0');
|
||||
expect(formatGs(180000000)).toBe('Gs. 180.000.000');
|
||||
});
|
||||
|
||||
it('puts the sign before the unit', () => {
|
||||
expect(formatGs(-1234)).toBe('-Gs. 1.234');
|
||||
});
|
||||
|
||||
it('exposes the bare amount for catalog strings that already say Gs.', () => {
|
||||
expect(formatGsAmount(430000)).toBe('430.000');
|
||||
});
|
||||
|
||||
it('rejects non finite values rather than rendering NaN', () => {
|
||||
expect(() => formatGs(Number.NaN)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dates', () => {
|
||||
it('formats long dates per locale', () => {
|
||||
expect(formatDateLong('es', '2026-09-19')).toBe('19 de septiembre');
|
||||
expect(formatDateLong('en', '2026-09-19')).toBe('September 19');
|
||||
});
|
||||
|
||||
it('formats short dates per locale', () => {
|
||||
expect(formatDateShort('es', '2026-09-19')).toBe('19 sep');
|
||||
expect(formatDateShort('en', '2026-09-19')).toBe('Sep 19');
|
||||
});
|
||||
|
||||
it('does not shift the day across timezones', () => {
|
||||
expect(formatDateShort('es', '2026-01-01')).toBe('1 ene');
|
||||
expect(formatDateShort('es', '2026-12-31')).toBe('31 dic');
|
||||
});
|
||||
|
||||
it('names months from a period', () => {
|
||||
expect(formatMonthName('es', '2026-08')).toBe('agosto');
|
||||
expect(formatMonthName('en', '2026-08')).toBe('August');
|
||||
});
|
||||
|
||||
it('rejects malformed dates', () => {
|
||||
expect(() => formatDateLong('es', '2026-9-1')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Locale } from './locales';
|
||||
|
||||
/**
|
||||
* Groups an integer with Paraguayan thousands separators: 1234567 -> "1.234.567".
|
||||
* Deliberately not Intl: money formatting must be byte-identical in Node, the
|
||||
* browser and tests, and it never varies by locale (FLOWS.md section 1).
|
||||
*/
|
||||
export function formatGsAmount(value: number): string {
|
||||
if (!Number.isFinite(value)) throw new Error(`formatGsAmount: not a finite number: ${value}`);
|
||||
const rounded = Math.trunc(value);
|
||||
const sign = rounded < 0 ? '-' : '';
|
||||
const digits = Math.abs(rounded).toString();
|
||||
let grouped = '';
|
||||
for (let i = 0; i < digits.length; i++) {
|
||||
if (i > 0 && (digits.length - i) % 3 === 0) grouped += '.';
|
||||
grouped += digits[i];
|
||||
}
|
||||
return sign + grouped;
|
||||
}
|
||||
|
||||
/** The one and only way to render money: `Gs. 1.234.567`. Never localized. */
|
||||
export function formatGs(value: number): string {
|
||||
const amount = formatGsAmount(value);
|
||||
return amount.startsWith('-') ? `-Gs. ${amount.slice(1)}` : `Gs. ${amount}`;
|
||||
}
|
||||
|
||||
const MONTHS: Record<Locale, readonly string[]> = {
|
||||
es: ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'],
|
||||
en: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
|
||||
};
|
||||
|
||||
const MONTHS_SHORT: Record<Locale, readonly string[]> = {
|
||||
es: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
|
||||
en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
|
||||
};
|
||||
|
||||
/** Splits a `YYYY-MM-DD` date without going through Date, which would shift by timezone. */
|
||||
function parts(isoDate: string): { year: number; month: number; day: number } {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
|
||||
if (!match) throw new Error(`expected a YYYY-MM-DD date, got: ${isoDate}`);
|
||||
return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
|
||||
}
|
||||
|
||||
function monthName(locale: Locale, month: number, list: Record<Locale, readonly string[]>): string {
|
||||
const name = list[locale][month - 1];
|
||||
if (name === undefined) throw new Error(`month out of range: ${month}`);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** es: "19 de septiembre". en: "September 19". */
|
||||
export function formatDateLong(locale: Locale, isoDate: string): string {
|
||||
const { month, day } = parts(isoDate);
|
||||
const name = monthName(locale, month, MONTHS);
|
||||
return locale === 'es' ? `${day} de ${name}` : `${name} ${day}`;
|
||||
}
|
||||
|
||||
/** es: "19 sep". en: "Sep 19". */
|
||||
export function formatDateShort(locale: Locale, isoDate: string): string {
|
||||
const { month, day } = parts(isoDate);
|
||||
const name = monthName(locale, month, MONTHS_SHORT);
|
||||
return locale === 'es' ? `${day} ${name}` : `${name} ${day}`;
|
||||
}
|
||||
|
||||
/** es: "septiembre". en: "September". For `{month}` params in the catalogs. */
|
||||
export function formatMonthName(locale: Locale, period: string): string {
|
||||
const match = /^(\d{4})-(\d{2})$/.exec(period);
|
||||
if (!match) throw new Error(`expected a YYYY-MM period, got: ${period}`);
|
||||
return monthName(locale, Number(match[2]), MONTHS);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export { es, esCopy, esExtra, type MessageKey } from './catalogs/index';
|
||||
export { en } from './catalogs/en';
|
||||
export {
|
||||
catalogs,
|
||||
t,
|
||||
unflatten,
|
||||
resolveKey,
|
||||
NAMESPACE_KEYS,
|
||||
NAMESPACE_LEAF,
|
||||
type Catalog,
|
||||
type MessageParams,
|
||||
} from './t';
|
||||
export {
|
||||
SUPPORTED_LOCALES,
|
||||
DEFAULT_LOCALE,
|
||||
isLocale,
|
||||
localeFromAcceptLanguage,
|
||||
type Locale,
|
||||
} from './locales';
|
||||
export {
|
||||
formatGs,
|
||||
formatGsAmount,
|
||||
formatDateLong,
|
||||
formatDateShort,
|
||||
formatMonthName,
|
||||
} from './format';
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Adding a locale: add the code here and add one catalog file. Nothing else. */
|
||||
export const SUPPORTED_LOCALES = ['es', 'en'] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'es';
|
||||
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return typeof value === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks a locale from an `Accept-Language` header. Used by the API only as the
|
||||
* last fallback: a stored `profiles.locale` always wins when the user is known.
|
||||
*/
|
||||
export function localeFromAcceptLanguage(header: string | null | undefined): Locale {
|
||||
if (!header) return DEFAULT_LOCALE;
|
||||
const ranked = header
|
||||
.split(',')
|
||||
.map((part) => {
|
||||
const [tag = '', ...rest] = part.trim().split(';');
|
||||
const q = rest.find((p) => p.trim().startsWith('q='))?.split('=')[1];
|
||||
return { tag: tag.trim().toLowerCase(), q: q ? Number(q) : 1 };
|
||||
})
|
||||
.filter((entry) => entry.tag.length > 0 && Number.isFinite(entry.q))
|
||||
.sort((a, b) => b.q - a.q);
|
||||
|
||||
for (const { tag } of ranked) {
|
||||
const base = tag.split('-')[0];
|
||||
if (isLocale(base)) return base;
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { localeFromAcceptLanguage } from './locales';
|
||||
import { t, unflatten } from './t';
|
||||
|
||||
describe('t', () => {
|
||||
it('returns the string for the locale', () => {
|
||||
expect(t('es', 'bandeja.empty')).toBe('Bandeja limpia ✓');
|
||||
expect(t('en', 'bandeja.empty')).toBe('Inbox clear ✓');
|
||||
});
|
||||
|
||||
it('interpolates params', () => {
|
||||
expect(t('es', 'landing.preview.deadline', { day: 19 })).toBe(
|
||||
'Tus vencimientos caen el dia 19 de cada mes',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves unknown placeholders alone instead of printing undefined', () => {
|
||||
expect(t('es', 'auth.otp.body', {})).toContain('{email}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unflatten', () => {
|
||||
it('nests dotted keys for next-intl', () => {
|
||||
const nested = unflatten({ 'a.b': 'x', 'a.c': 'y' } as never) as {
|
||||
a: { b: string; c: string };
|
||||
};
|
||||
expect(nested.a).toEqual({ b: 'x', c: 'y' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('localeFromAcceptLanguage', () => {
|
||||
it('picks the highest ranked supported locale', () => {
|
||||
expect(localeFromAcceptLanguage('en-US,en;q=0.9,es;q=0.8')).toBe('en');
|
||||
expect(localeFromAcceptLanguage('pt-BR,pt;q=0.9,es;q=0.7')).toBe('es');
|
||||
});
|
||||
|
||||
it('falls back to es', () => {
|
||||
expect(localeFromAcceptLanguage('pt-BR')).toBe('es');
|
||||
expect(localeFromAcceptLanguage(null)).toBe('es');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { en } from './catalogs/en';
|
||||
import { es, type MessageKey } from './catalogs/index';
|
||||
import { DEFAULT_LOCALE, type Locale } from './locales';
|
||||
|
||||
export type Catalog = Record<MessageKey, string>;
|
||||
|
||||
export const catalogs: Record<Locale, Catalog> = { es, en };
|
||||
|
||||
export type MessageParams = Record<string, string | number>;
|
||||
|
||||
/**
|
||||
* Server-side translator. The web app uses next-intl over the same catalogs;
|
||||
* the API uses this directly for error envelopes, notifications and emails.
|
||||
*/
|
||||
export function t(locale: Locale, key: MessageKey, params?: MessageParams): string {
|
||||
const template = catalogs[locale][key] ?? catalogs[DEFAULT_LOCALE][key];
|
||||
return interpolate(template, params);
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: MessageParams): string {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (whole, name: string) => {
|
||||
const value = params[name];
|
||||
return value === undefined ? whole : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserved child for a key that is both a message and a namespace. COPY.md defines
|
||||
* `decl.approve` (the button) alongside `decl.approve.confirmTitle`, which a nested
|
||||
* tree cannot express directly, so the message moves to `decl.approve._`.
|
||||
* Callers never see this: `resolveKey` rewrites the key for them.
|
||||
*/
|
||||
export const NAMESPACE_LEAF = '_';
|
||||
|
||||
/** Keys that are both a message and a namespace, computed from the catalog itself. */
|
||||
export const NAMESPACE_KEYS: ReadonlySet<string> = new Set(
|
||||
Object.keys(es).filter((key) => Object.keys(es).some((other) => other.startsWith(`${key}.`))),
|
||||
);
|
||||
|
||||
/** Maps a COPY.md key onto the path it occupies in the nested tree next-intl walks. */
|
||||
export function resolveKey(key: string): string {
|
||||
return NAMESPACE_KEYS.has(key) ? `${key}.${NAMESPACE_LEAF}` : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the flat catalog into the nested shape next-intl expects.
|
||||
* Catalogs stay flat so they map key for key onto COPY.md.
|
||||
*/
|
||||
export function unflatten(catalog: Catalog): Record<string, unknown> {
|
||||
const root: Record<string, unknown> = {};
|
||||
for (const [path, value] of Object.entries(catalog)) {
|
||||
const segments = resolveKey(path).split('.');
|
||||
let node = root;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const segment = segments[i] as string;
|
||||
const next = node[segment];
|
||||
if (next === undefined) node[segment] = {};
|
||||
else if (typeof next !== 'object') {
|
||||
throw new Error(`catalog key collision at "${segments.slice(0, i + 1).join('.')}"`);
|
||||
}
|
||||
node = node[segment] as Record<string, unknown>;
|
||||
}
|
||||
node[segments[segments.length - 1] as string] = value;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
Reference in New Issue
Block a user