From 29b0e0fbdbb18e0a3263fb29fa10dc11f928bdc0 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 20:20:40 -0600 Subject: [PATCH 1/9] scaffold: bump @worldware/msg to ^0.8.1 and add MF1 deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull in format-aware msg 0.8.1 plus @messageformat/icu-messageformat-1 and parser for MF1↔PGS conversion. Refs #15. Co-authored-by: Cursor --- package-lock.json | 72 +++++++++++++++++++++++++++++++++++++++-------- package.json | 4 ++- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index d85385d..f8b336e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.3.0", "license": "MIT", "dependencies": { + "@messageformat/icu-messageformat-1": "^0.12.0", + "@messageformat/parser": "^5.1.1", "@oclif/core": "^3.21.2", - "@worldware/msg": "^0.7.0", + "@worldware/msg": "^0.8.1", "fast-xml-parser": "^5.3.4", "messageformat": "^4.0.0-10" }, @@ -689,6 +691,42 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@messageformat/date-skeleton": { + "version": "2.0.0-0", + "resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-2.0.0-0.tgz", + "integrity": "sha512-gkCatAK4MYlhjKYWxkfRM6Ql0M1VmA1DK6A9rszUBprZpQox/XqX987AI2w7YoEz0p5DgkPOpI6PK1mW08yNqQ==", + "license": "MIT" + }, + "node_modules/@messageformat/icu-messageformat-1": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@messageformat/icu-messageformat-1/-/icu-messageformat-1-0.12.0.tgz", + "integrity": "sha512-vCpR24LBD6cSwaQ2lUJLXeJ0Ls+qT9PTh+z8RHrtazLLOJj0Y5IaVflLCWPm5NBM3IYKwklQsge25MCmMWppAg==", + "license": "Apache-2.0", + "dependencies": { + "@messageformat/date-skeleton": "2.0.0-0", + "@messageformat/number-skeleton": "2.0.0-0", + "@messageformat/parser": "^5.0.0", + "messageformat": "^4.0.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + } + }, + "node_modules/@messageformat/number-skeleton": { + "version": "2.0.0-0", + "resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-2.0.0-0.tgz", + "integrity": "sha512-bDrIrVSKwn0sNrbU+N6tjwljV+Zl9pRCR/D9Xim6Q7lLzZ20nFYtuuKOZ3QKisN+vPxa9sTqu3ufJ+uwKNpHbQ==", + "license": "MIT" + }, + "node_modules/@messageformat/parser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.1.1.tgz", + "integrity": "sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==", + "license": "MIT", + "dependencies": { + "moo": "^0.5.1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1905,12 +1943,13 @@ } }, "node_modules/@worldware/msg": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@worldware/msg/-/msg-0.7.0.tgz", - "integrity": "sha512-BrrM4Uu/5+S4WF3jZI9Q8e/7nOwjtlz5lAa5v7ifwNUJjZufYcsRYmSzw6ijmrDpXXM8VV3tFr9+DgXwEc8YQg==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@worldware/msg/-/msg-0.8.1.tgz", + "integrity": "sha512-7c0YTYSLCXIrCgAF/gl/3hwNwQ95Dqb8olQuwqbrQmkyP89K+yUtWJSNRPwLP6Um5oOmxk86D9C4xBHI0Vc/hQ==", "license": "MIT", "dependencies": { - "messageformat": "4.0.0-10", + "@messageformat/icu-messageformat-1": "^0.12.0", + "messageformat": "^4.0.0", "pseudo-localization": "^2.4.0" } }, @@ -4373,10 +4412,13 @@ } }, "node_modules/messageformat": { - "version": "4.0.0-10", - "resolved": "https://registry.npmjs.org/messageformat/-/messageformat-4.0.0-10.tgz", - "integrity": "sha512-yzGCa7Y6qmZ8zeNxEvlNRrTWsW0zABsSOEOtDjaSOvFWYUJxXsOetG8+S41P7LUmVUZixjCk7l3j5zI9zyZHkQ==", - "license": "Apache-2.0" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/messageformat/-/messageformat-4.0.0.tgz", + "integrity": "sha512-XKmJ/ffTWToWOlHJzt85ZChQgVGC0LHzNWuNK8zuYpNySsB0nIEmytOdSAOW9ETKtkajAUJf520m5gFHHnrTYg==", + "license": "Apache-2.0", + "engines": { + "node": "^20.19 || ^22.12 || >=24" + } }, "node_modules/micromatch": { "version": "4.0.8", @@ -4467,6 +4509,12 @@ "ufo": "^1.6.1" } }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8780,9 +8828,9 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "license": "MIT", "dependencies": { "cliui": "^8.0.1", diff --git a/package.json b/package.json index 9911510..b23c23d 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,10 @@ "topicSeparator": " " }, "dependencies": { + "@messageformat/icu-messageformat-1": "^0.12.0", + "@messageformat/parser": "^5.1.1", "@oclif/core": "^3.21.2", - "@worldware/msg": "^0.7.0", + "@worldware/msg": "^0.8.1", "fast-xml-parser": "^5.3.4", "messageformat": "^4.0.0-10" }, From fcae27f5d555266dad683364d3ebb0bdf138872c Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 20:20:40 -0600 Subject: [PATCH 2/9] scaffold: add msg-format unit type helpers Encode/decode MsgFormat via XLIFF unit type (msg:NONE|MF1|MF2) and resolve inherited format. Refs #15. Co-authored-by: Cursor --- src/lib/msg-format.ts | 61 ++++++++++++++++++++++++++++++++++++ src/tests/msg-format.test.ts | 48 ++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/lib/msg-format.ts create mode 100644 src/tests/msg-format.test.ts diff --git a/src/lib/msg-format.ts b/src/lib/msg-format.ts new file mode 100644 index 0000000..5c5858f --- /dev/null +++ b/src/lib/msg-format.ts @@ -0,0 +1,61 @@ +/** + * Maps `@worldware/msg` `format` values to XLIFF 2.2 `unit@type` and back. + * + * XLIFF requires custom `type` values on `` to use `prefix:value` form + * (see XLIFF 2.2 Core §3.3.1.40). We use the `msg:` authority prefix. + */ + +import { MSG_DEFAULT_FORMAT } from "@worldware/msg"; + +/** Message format values supported by `@worldware/msg`. */ +export type MsgFormat = "NONE" | "MF1" | "MF2"; + +const MSG_TYPE_PREFIX = "msg"; + +const FORMAT_VALUES = new Set(["NONE", "MF1", "MF2"]); + +/** + * Encodes a format as an XLIFF 2.2 unit `type` attribute value. + * @example formatToUnitType("MF1") → "msg:MF1" + */ +export function formatToUnitType(format: MsgFormat): string { + return `${MSG_TYPE_PREFIX}:${format}`; +} + +/** + * Decodes an XLIFF unit `type` into a MsgFormat. + * Accepts `msg:MF1` (canonical) and bare `MF1` for resilience. + * @returns undefined when type is missing or not a recognized format + */ +export function unitTypeToFormat( + type: string | undefined | null +): MsgFormat | undefined { + if (type == null || type === "") return undefined; + if (FORMAT_VALUES.has(type as MsgFormat)) { + return type as MsgFormat; + } + const prefixed = new RegExp( + `^${MSG_TYPE_PREFIX}:(NONE|MF1|MF2)$` + ).exec(type); + if (prefixed?.[1] && FORMAT_VALUES.has(prefixed[1] as MsgFormat)) { + return prefixed[1] as MsgFormat; + } + return undefined; +} + +/** + * Resolves the effective format for a message using inheritance + * (message → resource → project → library default). + */ +export function resolveMessageFormat( + messageFormat?: MsgFormat, + resourceFormat?: MsgFormat, + projectFormat?: MsgFormat +): MsgFormat { + return ( + messageFormat ?? + resourceFormat ?? + projectFormat ?? + (MSG_DEFAULT_FORMAT as MsgFormat) + ); +} diff --git a/src/tests/msg-format.test.ts b/src/tests/msg-format.test.ts new file mode 100644 index 0000000..ac28ac2 --- /dev/null +++ b/src/tests/msg-format.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vitest"; +import { MSG_DEFAULT_FORMAT } from "@worldware/msg"; +import { + formatToUnitType, + resolveMessageFormat, + unitTypeToFormat, +} from "../lib/msg-format.js"; + +describe("msg-format", () => { + describe("formatToUnitType", () => { + test("encodes formats with msg: prefix", () => { + expect(formatToUnitType("NONE")).toBe("msg:NONE"); + expect(formatToUnitType("MF1")).toBe("msg:MF1"); + expect(formatToUnitType("MF2")).toBe("msg:MF2"); + }); + }); + + describe("unitTypeToFormat", () => { + test("decodes msg: prefixed types", () => { + expect(unitTypeToFormat("msg:NONE")).toBe("NONE"); + expect(unitTypeToFormat("msg:MF1")).toBe("MF1"); + expect(unitTypeToFormat("msg:MF2")).toBe("MF2"); + }); + + test("accepts bare format values for resilience", () => { + expect(unitTypeToFormat("NONE")).toBe("NONE"); + expect(unitTypeToFormat("MF1")).toBe("MF1"); + expect(unitTypeToFormat("MF2")).toBe("MF2"); + }); + + test("returns undefined for missing or unknown types", () => { + expect(unitTypeToFormat(undefined)).toBeUndefined(); + expect(unitTypeToFormat(null)).toBeUndefined(); + expect(unitTypeToFormat("")).toBeUndefined(); + expect(unitTypeToFormat("generic")).toBeUndefined(); + expect(unitTypeToFormat("msg:OTHER")).toBeUndefined(); + }); + }); + + describe("resolveMessageFormat", () => { + test("prefers message, then resource, then project, then default", () => { + expect(resolveMessageFormat("NONE", "MF1", "MF2")).toBe("NONE"); + expect(resolveMessageFormat(undefined, "MF1", "MF2")).toBe("MF1"); + expect(resolveMessageFormat(undefined, undefined, "MF1")).toBe("MF1"); + expect(resolveMessageFormat()).toBe(MSG_DEFAULT_FORMAT); + }); + }); +}); From 77a86a0ac3a18627620cb297f8e7fdfa40532c95 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 20:20:40 -0600 Subject: [PATCH 3/9] =?UTF-8?q?scaffold:=20stub=20MF1=E2=86=94PGS=20conver?= =?UTF-8?q?ters=20with=20failing=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mf1MessageToPgsExport / pgsImportToMf1Message stubs that return null so TDD tests fail until Phase 4. Refs #15. Co-authored-by: Cursor --- src/lib/pgs-mf1.ts | 44 ++++++++++++++++++ src/tests/pgs-mf1.test.ts | 94 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/lib/pgs-mf1.ts create mode 100644 src/tests/pgs-mf1.test.ts diff --git a/src/lib/pgs-mf1.ts b/src/lib/pgs-mf1.ts new file mode 100644 index 0000000..478b4ef --- /dev/null +++ b/src/lib/pgs-mf1.ts @@ -0,0 +1,44 @@ +/** + * Maps between ICU MessageFormat 1 plural/selectordinal/select messages and + * XLIFF 2.2 PGS (`pgs:switch` / `pgs:case`). + * + * Export path: MF1 → MF2 data model (via `@messageformat/icu-messageformat-1`) + * → shared PGS classification used by `pgs-mf2.ts`. + * Import path: PGS → ICU MF1 string (format-preserving reconstruction). + */ + +import type { + PgsSegmentExport, + PgsSegmentImport, +} from "./pgs-mf2.js"; + +/** + * If `source` is an MF1 plural/selectordinal/select message that maps to PGS, + * returns switch/case segments; otherwise null (caller falls back to plain XLIFF). + * + * @param source - ICU MessageFormat 1 source string + */ +export function mf1MessageToPgsExport( + source: string +): { switchAttr: string; segments: PgsSegmentExport[] } | null { + void source; + // Scaffold: implemented in Phase 4 (MF1 → mf1ToMessageData → PGS). + return null; +} + +/** + * Builds an ICU MessageFormat 1 string from PGS `pgs:switch` and segment bodies. + * Multi-selector switches are nested as ICU select/plural trees. + * + * @param switchAttr - Value of `pgs:switch` (e.g. `plural:count`) + * @param segments - Segments with `pgs:case` and body text + */ +export function pgsImportToMf1Message( + switchAttr: string, + segments: PgsSegmentImport[] +): string | null { + void switchAttr; + void segments; + // Scaffold: implemented in Phase 4 (PGS → nested ICU MF1). + return null; +} diff --git a/src/tests/pgs-mf1.test.ts b/src/tests/pgs-mf1.test.ts new file mode 100644 index 0000000..0c98930 --- /dev/null +++ b/src/tests/pgs-mf1.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "vitest"; +import { + mf1MessageToPgsExport, + pgsImportToMf1Message, +} from "../lib/pgs-mf1.js"; + +describe("pgs-mf1", () => { + test("export plural message to PGS segments", () => { + const src = "{count, plural, one {# file} other {# files}}"; + const exp = mf1MessageToPgsExport(src); + expect(exp).not.toBeNull(); + expect(exp!.switchAttr).toBe("plural:count"); + expect(exp!.segments).toHaveLength(2); + expect(exp!.segments[0]!.caseAttr).toBe("one"); + expect(exp!.segments[1]!.caseAttr).toBe("other"); + }); + + test("export selectordinal uses ordinal switch", () => { + const src = + "{p, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}"; + const exp = mf1MessageToPgsExport(src); + expect(exp).not.toBeNull(); + expect(exp!.switchAttr).toBe("ordinal:p"); + }); + + test("export select with gender-like keys maps to gender", () => { + // After MF1→MF2 lift, string selectors with gender keys classify as gender. + // ICU male/female map to literal keys; gender classification uses feminine/masculine. + const src = + "{g, select, feminine {Her party} masculine {His party} other {Their party}}"; + const exp = mf1MessageToPgsExport(src); + expect(exp).not.toBeNull(); + expect(exp!.switchAttr).toBe("gender:g"); + expect(exp!.segments.map((s) => s.caseAttr)).toEqual([ + "feminine", + "masculine", + "other", + ]); + }); + + test("export select with non-gender keys maps to select", () => { + const src = + "{color, select, red {Red} blue {Blue} other {Other}}"; + const exp = mf1MessageToPgsExport(src); + expect(exp).not.toBeNull(); + expect(exp!.switchAttr).toBe("select:color"); + }); + + test("returns null for plain text and simple placeholders", () => { + expect(mf1MessageToPgsExport("Hello")).toBeNull(); + expect(mf1MessageToPgsExport("Hello {name}")).toBeNull(); + }); + + test("round-trip plural via PGS import", () => { + const src = "{count, plural, one {one file} other {# files}}"; + const exp = mf1MessageToPgsExport(src); + expect(exp).not.toBeNull(); + const back = pgsImportToMf1Message( + exp!.switchAttr, + exp!.segments.map((s) => ({ + caseAttr: s.caseAttr, + body: s.sourcePattern, + })) + ); + expect(back).not.toBeNull(); + // Structural ICU equivalence: same arg, type, and case keys. + expect(back).toMatch(/\{count,\s*plural,/); + expect(back).toMatch(/\bone\s*\{/); + expect(back).toMatch(/\bother\s*\{/); + }); + + test("import reconstructs selectordinal", () => { + const back = pgsImportToMf1Message("ordinal:n", [ + { caseAttr: "one", body: "1st" }, + { caseAttr: "other", body: "nth" }, + ]); + expect(back).not.toBeNull(); + expect(back).toMatch(/\{n,\s*selectordinal,/); + expect(back).toContain("one {1st}"); + expect(back).toContain("other {nth}"); + }); + + test("import nests multi-selector switches", () => { + const back = pgsImportToMf1Message("gender:g plural:n", [ + { caseAttr: "feminine one", body: "She has one" }, + { caseAttr: "feminine other", body: "She has many" }, + { caseAttr: "other one", body: "They have one" }, + { caseAttr: "other other", body: "They have many" }, + ]); + expect(back).not.toBeNull(); + expect(back).toMatch(/\{g,\s*select,/); + expect(back).toMatch(/\{n,\s*plural,/); + }); +}); From 09ffca74b109140d5160df00449ad472f09afefb Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 20:20:40 -0600 Subject: [PATCH 4/9] scaffold: wire format type into export/import paths Persist resolved format on unit@type, route MF1 through PGS stubs, and tolerate messageformat options as Map or plain object for ordinals. Refs #15. Co-authored-by: Cursor --- src/lib/export-helpers.ts | 30 +++++++++++- src/lib/import-helpers.ts | 43 ++++++++++------- src/lib/pgs-mf2.ts | 18 +++++-- src/tests/export-helpers.test.ts | 66 +++++++++++++++++++++++-- src/tests/import-helpers.test.ts | 82 ++++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 28 deletions(-) diff --git a/src/lib/export-helpers.ts b/src/lib/export-helpers.ts index e52ce82..b273ccb 100644 --- a/src/lib/export-helpers.ts +++ b/src/lib/export-helpers.ts @@ -1,8 +1,14 @@ -import { MsgResource } from "@worldware/msg"; +import { MsgResource, MSG_DEFAULT_FORMAT } from "@worldware/msg"; import { readdir, writeFile, mkdir } from "fs/promises"; import { join } from "path"; import { pathToFileURL } from "url"; import { dynamicImportFromUrl } from "./create-resource-helpers.js"; +import { + formatToUnitType, + resolveMessageFormat, + type MsgFormat, +} from "./msg-format.js"; +import { mf1MessageToPgsExport } from "./pgs-mf1.js"; import { selectMessageToPgsExport } from "./pgs-mf2.js"; /** Object grouping resources with their project name (spec: resource group object). */ @@ -129,6 +135,7 @@ interface MsgAttributesLike { lang?: string; dir?: string; dnt?: boolean; + format?: MsgFormat; } /** @@ -212,6 +219,13 @@ function resourceGroupToXliff22(group: ResourceGroup): string { fileIndex += 1; const fileId = `f${fileIndex}`; const attrs: MsgAttributesLike = resource.attributes ?? {}; + const projectFormat = (resource.getProject().format ?? + MSG_DEFAULT_FORMAT) as MsgFormat; + const resourceFormat = resolveMessageFormat( + attrs.format, + undefined, + projectFormat + ); const fileAttrs: string[] = [ `id="${fileId}"`, `original="${escapeXml(orig)}"`, @@ -246,7 +260,19 @@ function resourceGroupToXliff22(group: ResourceGroup): string { msgAttrs.push(`srcDir="${escapeXml(msgAttr.dir)}"`); } - const pgsExport = selectMessageToPgsExport(msg.value); + const msgFormat = resolveMessageFormat( + msgAttr?.format, + resourceFormat, + projectFormat + ); + msgAttrs.push(`type="${escapeXml(formatToUnitType(msgFormat))}"`); + + const pgsExport = + msgFormat === "MF1" + ? mf1MessageToPgsExport(msg.value) + : msgFormat === "MF2" + ? selectMessageToPgsExport(msg.value) + : null; if (pgsExport) { msgAttrs.push(`pgs:switch="${escapeXml(pgsExport.switchAttr)}"`); } diff --git a/src/lib/import-helpers.ts b/src/lib/import-helpers.ts index 7c6dbd8..9a2f26b 100644 --- a/src/lib/import-helpers.ts +++ b/src/lib/import-helpers.ts @@ -5,6 +5,8 @@ import { pathToFileURL } from "url"; import { XMLParser } from "fast-xml-parser"; import { existsSync } from "fs"; import { dynamicImportFromUrl } from "./create-resource-helpers.js"; +import { unitTypeToFormat, type MsgFormat } from "./msg-format.js"; +import { pgsImportToMf1Message } from "./pgs-mf1.js"; import { pgsImportToSelectMessage } from "./pgs-mf2.js"; /** File extensions considered XLIFF files. */ @@ -235,17 +237,19 @@ interface FileLevelAttrs { translate: string; } +type ImportedMessage = { + key: string; + value: string; + attributes: { lang?: string; dir?: string; dnt?: boolean; format?: MsgFormat }; + notes: Array<{ type: string; content: string }>; +}; + /** Processes a flat array of unit elements. */ function processUnitsFromItems( units: unknown[], fileAttrs: FileLevelAttrs -): Array<{ key: string; value: string; attributes: { lang?: string; dir?: string; dnt?: boolean }; notes: Array<{ type: string; content: string }> }> { - const messages: Array<{ - key: string; - value: string; - attributes: { lang?: string; dir?: string; dnt?: boolean }; - notes: Array<{ type: string; content: string }>; - }> = []; +): ImportedMessage[] { + const messages: ImportedMessage[] = []; for (const u of units) { const msg = processUnit(u as Record, fileAttrs); if (msg) messages.push(msg); @@ -257,13 +261,8 @@ function processUnitsFromItems( function processGroupsForMessages( groups: unknown[], fileAttrs: FileLevelAttrs -): Array<{ key: string; value: string; attributes: { lang?: string; dir?: string; dnt?: boolean }; notes: Array<{ type: string; content: string }> }> { - const messages: Array<{ - key: string; - value: string; - attributes: { lang?: string; dir?: string; dnt?: boolean }; - notes: Array<{ type: string; content: string }>; - }> = []; +): ImportedMessage[] { + const messages: ImportedMessage[] = []; for (const item of groups) { const obj = item as Record; if (obj.unit) { @@ -281,12 +280,13 @@ function processGroupsForMessages( function processUnit( unit: Record, fileAttrs: FileLevelAttrs -): { key: string; value: string; attributes: { lang?: string; dir?: string; dnt?: boolean }; notes: Array<{ type: string; content: string }> } | null { +): ImportedMessage | null { const name = (unit["@_name"] ?? unit["@_id"] ?? "") as string; const trgLang = (unit["@_trgLang"] ?? fileAttrs.trgLang) as string; const trgDir = (unit["@_trgDir"] ?? fileAttrs.trgDir) as string | undefined; const translate = ((unit["@_translate"] ?? "yes") as string).toLowerCase(); const dnt = translate === "no" || translate === "false"; + const format = unitTypeToFormat(unit["@_type"] as string | undefined); const fileTranslate = (fileAttrs.translate ?? "yes").toLowerCase(); const fileDnt = fileTranslate === "no" || fileTranslate === "false"; @@ -324,7 +324,10 @@ function processUnit( caseAttr: (seg as Record)["@_pgs:case"] as string, body: extractSegmentText(seg), })); - const rebuilt = pgsImportToSelectMessage(pgsSwitch, importSegments); + const rebuilt = + format === "MF1" + ? pgsImportToMf1Message(pgsSwitch, importSegments) + : pgsImportToSelectMessage(pgsSwitch, importSegments); if (rebuilt) { value = rebuilt; } else { @@ -342,10 +345,16 @@ function processUnit( value = targetParts.join(""); } - const attributes: { lang?: string; dir?: string; dnt?: boolean } = {}; + const attributes: { + lang?: string; + dir?: string; + dnt?: boolean; + format?: MsgFormat; + } = {}; if (trgLang !== fileAttrs.trgLang) attributes.lang = trgLang || undefined; if (trgDir !== fileAttrs.trgDir) attributes.dir = trgDir || undefined; if (dnt !== fileDnt) attributes.dnt = dnt; + if (format) attributes.format = format; return { key: name, diff --git a/src/lib/pgs-mf2.ts b/src/lib/pgs-mf2.ts index 365d79b..abb63bb 100644 --- a/src/lib/pgs-mf2.ts +++ b/src/lib/pgs-mf2.ts @@ -79,12 +79,22 @@ function getFunctionName(expr: { return expr.functionRef?.name; } +function getOption( + opts: Map | Record | undefined, + key: string +): unknown { + if (!opts) return undefined; + if (opts instanceof Map) return opts.get(key); + return opts[key]; +} + function getSelectOptionOrdinal(expr: { - functionRef?: { name?: string; options?: Map }; + functionRef?: { + name?: string; + options?: Map | Record; + }; }): boolean { - const opts = expr.functionRef?.options; - if (!opts || !(opts instanceof Map)) return false; - const sel = opts.get("select"); + const sel = getOption(expr.functionRef?.options, "select"); if (!sel || typeof sel !== "object" || sel === null) return false; if ((sel as { type?: string }).type !== "literal") return false; return (sel as { value?: string }).value === "ordinal"; diff --git a/src/tests/export-helpers.test.ts b/src/tests/export-helpers.test.ts index 7f215bb..9434d91 100644 --- a/src/tests/export-helpers.test.ts +++ b/src/tests/export-helpers.test.ts @@ -20,9 +20,12 @@ const FIXTURES_MSG = join(__dirname, "fixtures", "msg-files"); const FIXTURES_MSG_INVALID = join(__dirname, "fixtures", "msg-files-invalid"); /** Creates a minimal MsgProject for use in tests. */ -function createTestProject(name: string) { +function createTestProject( + name: string, + opts?: { format?: "NONE" | "MF1" | "MF2" } +) { return MsgProject.create({ - project: { name }, + project: { name, ...(opts?.format ? { format: opts.format } : {}) }, locales: { sourceLocale: "en", pseudoLocale: "en-XA", @@ -46,15 +49,23 @@ function createTestResource( messages: { key: string; value: string; - attributes?: { dnt?: boolean; dir?: string }; + attributes?: { dnt?: boolean; dir?: string; format?: "NONE" | "MF1" | "MF2" }; notes?: MsgNoteLike[]; }[] = [], resourceOpts?: { - attributes?: { lang?: string; dir?: string; dnt?: boolean }; + attributes?: { + lang?: string; + dir?: string; + dnt?: boolean; + format?: "NONE" | "MF1" | "MF2"; + }; notes?: MsgNoteLike[]; + projectFormat?: "NONE" | "MF1" | "MF2"; } ) { - const project = createTestProject(projectName); + const project = createTestProject(projectName, { + format: resourceOpts?.projectFormat, + }); const data = { title, attributes: { lang: "en", dir: "ltr", dnt: false, ...resourceOpts?.attributes }, @@ -393,6 +404,51 @@ one {{One item}} ]); expect(result[0].xliff).toContain('srcDir="rtl"'); }); + + test("sets unit type from resolved message format", () => { + const res = createTestResource( + "R", + "P", + [ + { key: "raw", value: "literal {x}", attributes: { format: "NONE" } }, + { + key: "icu", + value: "{count, plural, one {# file} other {# files}}", + attributes: { format: "MF1" }, + }, + { key: "plain", value: "Hello" }, + ], + { projectFormat: "MF2" } + ); + const xliff = serializeResourceGroupsToXliff([ + { project: "P", resources: [res] }, + ])[0]!.xliff; + expect(xliff).toMatch(/name="raw"[^>]*type="msg:NONE"|type="msg:NONE"[^>]*name="raw"/); + expect(xliff).toMatch(/name="icu"[^>]*type="msg:MF1"|type="msg:MF1"[^>]*name="icu"/); + expect(xliff).toMatch(/name="plain"[^>]*type="msg:MF2"|type="msg:MF2"[^>]*name="plain"/); + }); + + test("emits PGS for MF1 plural messages", () => { + const res = createTestResource( + "R", + "P", + [ + { + key: "files", + value: "{count, plural, one {# file} other {# files}}", + attributes: { format: "MF1" }, + }, + ], + { projectFormat: "MF1" } + ); + const xliff = serializeResourceGroupsToXliff([ + { project: "P", resources: [res] }, + ])[0]!.xliff; + expect(xliff).toContain('type="msg:MF1"'); + expect(xliff).toContain('pgs:switch="plural:count"'); + expect(xliff).toContain('pgs:case="one"'); + expect(xliff).toContain('pgs:case="other"'); + }); }); describe("writeXliffFiles", () => { diff --git a/src/tests/import-helpers.test.ts b/src/tests/import-helpers.test.ts index 076c3c5..7ee69ee 100644 --- a/src/tests/import-helpers.test.ts +++ b/src/tests/import-helpers.test.ts @@ -361,6 +361,88 @@ one {{一}} ); }); + test("reads unit type into message format attribute", () => { + const fileEl = { + "@_original": "R.json", + "@_trgLang": "zh", + unit: [ + { + "@_id": "u1", + "@_name": "raw", + "@_type": "msg:NONE", + segment: { source: "S", target: "T" }, + }, + { + "@_id": "u2", + "@_name": "icu", + "@_type": "msg:MF1", + segment: { source: "S", target: "{n, plural, other {#}}" }, + }, + { + "@_id": "u3", + "@_name": "mf2", + "@_type": "msg:MF2", + segment: { source: "S", target: "Hello {$name}" }, + }, + ], + }; + const result = extractResourceFromXliffFile( + fileEl as unknown as Record, + "zh", + project, + ["zh"] + ); + expect(result).toBeInstanceOf(MsgResource); + const data = result!.getData(true); + expect(data.messages![0]!.attributes?.format).toBe("NONE"); + expect(data.messages![1]!.attributes?.format).toBe("MF1"); + // MF2 matches the project default, so getData may omit it when inherited. + expect( + data.messages![2]!.attributes?.format === "MF2" || + data.messages![2]!.attributes?.format === undefined + ).toBe(true); + }); + + test("rebuilds MF1 plural from PGS when unit type is msg:MF1", () => { + const fileEl = { + "@_original": "P.json", + "@_trgLang": "zh", + unit: { + "@_id": "u1", + "@_name": "files", + "@_type": "msg:MF1", + "@_pgs:switch": "plural:count", + segment: [ + { + "@_id": "u1_s1", + "@_pgs:case": "one", + source: "one file", + target: "一个文件", + }, + { + "@_id": "u1_s2", + "@_pgs:case": "other", + source: "# files", + target: "# 个文件", + }, + ], + }, + }; + const result = extractResourceFromXliffFile( + fileEl as unknown as Record, + "zh", + project, + ["zh"] + ); + expect(result).toBeInstanceOf(MsgResource); + const data = result!.getData(true); + expect(data.messages).toHaveLength(1); + expect(data.messages![0]!.attributes?.format).toBe("MF1"); + expect(data.messages![0]!.value).toMatch(/\{count,\s*plural,/); + expect(data.messages![0]!.value).toContain("一个文件"); + expect(data.messages![0]!.value).toContain("# 个文件"); + }); + test("extracts notes from units", () => { const fileEl = { "@_original": "R.json", From a858bd578e405c9e18b82d46def0d6c0ba9878f1 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 20:22:30 -0600 Subject: [PATCH 5/9] =?UTF-8?q?implement:=20MF1=20ICU=20plural/select=20?= =?UTF-8?q?=E2=86=94=20XLIFF=20PGS=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert via mf1ToMessageData into shared PGS export; reconstruct nested ICU on import, mapping plural vars to #. Refs #15. Co-authored-by: Cursor --- src/lib/pgs-mf1.ts | 175 ++++++++++++++++++++++++++++++++++++++++----- src/lib/pgs-mf2.ts | 34 +++++---- 2 files changed, 180 insertions(+), 29 deletions(-) diff --git a/src/lib/pgs-mf1.ts b/src/lib/pgs-mf1.ts index 478b4ef..42a6264 100644 --- a/src/lib/pgs-mf1.ts +++ b/src/lib/pgs-mf1.ts @@ -4,41 +4,184 @@ * * Export path: MF1 → MF2 data model (via `@messageformat/icu-messageformat-1`) * → shared PGS classification used by `pgs-mf2.ts`. - * Import path: PGS → ICU MF1 string (format-preserving reconstruction). + * Import path: PGS → nested ICU MF1 string. */ -import type { - PgsSegmentExport, - PgsSegmentImport, +import { parse } from "@messageformat/parser"; +import { mf1ToMessageData } from "@messageformat/icu-messageformat-1"; +import { parseMessage } from "messageformat"; +import { + parsePgsSwitch, + selectMessageDataToPgsExport, + type ParsedPgsSwitch, + type PgsSegmentExport, + type PgsSegmentImport, + type PgsSelectorKind, } from "./pgs-mf2.js"; /** * If `source` is an MF1 plural/selectordinal/select message that maps to PGS, * returns switch/case segments; otherwise null (caller falls back to plain XLIFF). - * - * @param source - ICU MessageFormat 1 source string */ export function mf1MessageToPgsExport( source: string ): { switchAttr: string; segments: PgsSegmentExport[] } | null { - void source; - // Scaffold: implemented in Phase 4 (MF1 → mf1ToMessageData → PGS). - return null; + try { + const data = mf1ToMessageData(parse(source)); + return selectMessageDataToPgsExport(data); + } catch { + return null; + } +} + +function kindToIcuType(kind: PgsSelectorKind): "plural" | "selectordinal" | "select" { + if (kind === "plural") return "plural"; + if (kind === "ordinal") return "selectordinal"; + return "select"; +} + +/** Names of plural/ordinal selectors (their `#` octothorpe in ICU). */ +function pluralVarNames(parsedSwitch: ParsedPgsSwitch): Set { + const names = new Set(); + for (const { kind, name } of parsedSwitch) { + if (kind === "plural" || kind === "ordinal") names.add(name); + } + return names; +} + +/** + * Converts an MF2 segment pattern (or plain text) into an ICU MF1 case body. + * Plural/ordinal selector vars become `#`; other vars become `{name}`. + */ +function segmentBodyToMf1( + body: string, + hashVars: Set +): string { + const trimmed = body.trim(); + if (!trimmed) return ""; + try { + const msg = parseMessage(trimmed); + if ((msg as { type?: string }).type !== "message") { + return escapeIcuLiteral(trimmed); + } + return patternPartsToMf1( + (msg as { pattern: unknown[] }).pattern, + hashVars + ); + } catch { + return escapeIcuLiteral(trimmed); + } +} + +function patternPartsToMf1( + pattern: unknown[], + hashVars: Set +): string { + let out = ""; + for (const part of pattern) { + if (typeof part === "string") { + out += escapeIcuLiteral(part); + continue; + } + if (!part || typeof part !== "object") continue; + const expr = part as { + type?: string; + arg?: { type?: string; name?: string }; + }; + if (expr.type === "expression" && expr.arg?.type === "variable" && expr.arg.name) { + const name = expr.arg.name; + out += hashVars.has(name) ? "#" : `{${name}}`; + continue; + } + // Unsupported inline: drop rather than corrupt the ICU string. + } + return out; +} + +/** Escape `{` / `}` in literal ICU text (leave `#` as-is for translators). */ +function escapeIcuLiteral(s: string): string { + return s.replace(/'/g, "''").replace(/([{}])/g, "'$1'"); +} + +type CaseTree = Map; + +function insertCase( + tree: CaseTree, + tokens: string[], + body: string, + depth: number +): boolean { + if (tokens.length === 0) return false; + const token = tokens[depth]; + if (token === undefined) return false; + + if (depth === tokens.length - 1) { + tree.set(token, body); + return true; + } + + let child = tree.get(token); + if (typeof child === "string") return false; + if (!child) { + child = new Map(); + tree.set(token, child); + } + return insertCase(child, tokens, body, depth + 1); +} + +function serializeCaseTree( + tree: CaseTree, + parsedSwitch: ParsedPgsSwitch, + depth: number, + hashVars: Set +): string | null { + const item = parsedSwitch[depth]; + if (!item) return null; + const icuType = kindToIcuType(item.kind); + const parts: string[] = []; + + for (const [key, child] of tree) { + let content: string; + if (typeof child === "string") { + content = segmentBodyToMf1(child, hashVars); + } else { + const nested = serializeCaseTree( + child, + parsedSwitch, + depth + 1, + hashVars + ); + if (nested == null) return null; + content = nested; + } + parts.push(`${key} {${content}}`); + } + + if (parts.length === 0) return null; + return `{${item.name}, ${icuType}, ${parts.join(" ")}}`; } /** * Builds an ICU MessageFormat 1 string from PGS `pgs:switch` and segment bodies. * Multi-selector switches are nested as ICU select/plural trees. - * - * @param switchAttr - Value of `pgs:switch` (e.g. `plural:count`) - * @param segments - Segments with `pgs:case` and body text */ export function pgsImportToMf1Message( switchAttr: string, segments: PgsSegmentImport[] ): string | null { - void switchAttr; - void segments; - // Scaffold: implemented in Phase 4 (PGS → nested ICU MF1). - return null; + const parsedSwitch = parsePgsSwitch(switchAttr); + if (!parsedSwitch || parsedSwitch.length === 0) return null; + const n = parsedSwitch.length; + if (segments.length === 0) return null; + + const hashVars = pluralVarNames(parsedSwitch); + const tree: CaseTree = new Map(); + + for (const seg of segments) { + const tokens = seg.caseAttr.trim().split(/\s+/).filter(Boolean); + if (tokens.length !== n) return null; + if (!insertCase(tree, tokens, seg.body, 0)) return null; + } + + return serializeCaseTree(tree, parsedSwitch, 0, hashVars); } diff --git a/src/lib/pgs-mf2.ts b/src/lib/pgs-mf2.ts index abb63bb..20df9d2 100644 --- a/src/lib/pgs-mf2.ts +++ b/src/lib/pgs-mf2.ts @@ -217,18 +217,12 @@ export interface PgsSegmentExport { } /** - * If `source` is a classifiable `.match` message, returns PGS attributes and - * per-variant segments; otherwise null (caller falls back to plain XLIFF). + * If `msg` is a classifiable MF2 select (`.match`) data model, returns PGS + * attributes and per-variant segments; otherwise null. */ -export function selectMessageToPgsExport( - source: string +export function selectMessageDataToPgsExport( + msg: unknown ): { switchAttr: string; segments: PgsSegmentExport[] } | null { - let msg: unknown; - try { - msg = parseMessage(source); - } catch { - return null; - } if (!isSelectMessage(msg as never)) return null; const classification = classifySelectMessageForPgs( @@ -248,9 +242,7 @@ export function selectMessageToPgsExport( for (let ki = 0; ki < classification.length; ki++) { const key = variant.keys[ki]; if (!key) return null; - caseParts.push( - mf2KeyToPgsToken(key, classification[ki]!.kind) - ); + caseParts.push(mf2KeyToPgsToken(key, classification[ki]!.kind)); } segments.push({ caseAttr: caseParts.join(" "), @@ -261,6 +253,22 @@ export function selectMessageToPgsExport( return { switchAttr, segments }; } +/** + * If `source` is a classifiable `.match` message, returns PGS attributes and + * per-variant segments; otherwise null (caller falls back to plain XLIFF). + */ +export function selectMessageToPgsExport( + source: string +): { switchAttr: string; segments: PgsSegmentExport[] } | null { + let msg: unknown; + try { + msg = parseMessage(source); + } catch { + return null; + } + return selectMessageDataToPgsExport(msg); +} + export interface PgsSegmentImport { caseAttr: string; /** Text for target locale (or source when building monolingual). */ From a6b142647db7f97aaefe5ff0398771d726368d26 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 20:27:29 -0600 Subject: [PATCH 6/9] optimize: MF1-friendly PGS bodies and format cleanup Emit ICU #/{name} segment text on MF1 export, reuse MsgFormat from @worldware/msg, clarify resolveMessageFormat args, and DRY import segment joins. Refs #15. Co-authored-by: Cursor --- src/lib/export-helpers.ts | 2 +- src/lib/import-helpers.ts | 24 ++++++++++-------------- src/lib/msg-format.ts | 7 +++---- src/lib/pgs-mf1.ts | 17 +++++++++++++++-- src/tests/pgs-mf1.test.ts | 2 ++ 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/lib/export-helpers.ts b/src/lib/export-helpers.ts index b273ccb..c1489b3 100644 --- a/src/lib/export-helpers.ts +++ b/src/lib/export-helpers.ts @@ -222,8 +222,8 @@ function resourceGroupToXliff22(group: ResourceGroup): string { const projectFormat = (resource.getProject().format ?? MSG_DEFAULT_FORMAT) as MsgFormat; const resourceFormat = resolveMessageFormat( - attrs.format, undefined, + attrs.format, projectFormat ); const fileAttrs: string[] = [ diff --git a/src/lib/import-helpers.ts b/src/lib/import-helpers.ts index 9a2f26b..0ac90fe 100644 --- a/src/lib/import-helpers.ts +++ b/src/lib/import-helpers.ts @@ -311,6 +311,14 @@ function processUnit( const pgsSwitch = unit["@_pgs:switch"] as string | undefined; + const joinSegmentTexts = (): string => { + const parts: string[] = []; + for (const seg of segments) { + parts.push(extractSegmentText(seg)); + } + return parts.join(""); + }; + let value: string; if ( pgsSwitch && @@ -328,21 +336,9 @@ function processUnit( format === "MF1" ? pgsImportToMf1Message(pgsSwitch, importSegments) : pgsImportToSelectMessage(pgsSwitch, importSegments); - if (rebuilt) { - value = rebuilt; - } else { - const targetParts: string[] = []; - for (const seg of segments) { - targetParts.push(extractSegmentText(seg)); - } - value = targetParts.join(""); - } + value = rebuilt ?? joinSegmentTexts(); } else { - const targetParts: string[] = []; - for (const seg of segments) { - targetParts.push(extractSegmentText(seg)); - } - value = targetParts.join(""); + value = joinSegmentTexts(); } const attributes: { diff --git a/src/lib/msg-format.ts b/src/lib/msg-format.ts index 5c5858f..51d43cd 100644 --- a/src/lib/msg-format.ts +++ b/src/lib/msg-format.ts @@ -5,10 +5,9 @@ * (see XLIFF 2.2 Core §3.3.1.40). We use the `msg:` authority prefix. */ -import { MSG_DEFAULT_FORMAT } from "@worldware/msg"; +import { MSG_DEFAULT_FORMAT, type MsgFormat } from "@worldware/msg"; -/** Message format values supported by `@worldware/msg`. */ -export type MsgFormat = "NONE" | "MF1" | "MF2"; +export type { MsgFormat }; const MSG_TYPE_PREFIX = "msg"; @@ -56,6 +55,6 @@ export function resolveMessageFormat( messageFormat ?? resourceFormat ?? projectFormat ?? - (MSG_DEFAULT_FORMAT as MsgFormat) + MSG_DEFAULT_FORMAT ); } diff --git a/src/lib/pgs-mf1.ts b/src/lib/pgs-mf1.ts index 42a6264..7b96be2 100644 --- a/src/lib/pgs-mf1.ts +++ b/src/lib/pgs-mf1.ts @@ -3,7 +3,8 @@ * XLIFF 2.2 PGS (`pgs:switch` / `pgs:case`). * * Export path: MF1 → MF2 data model (via `@messageformat/icu-messageformat-1`) - * → shared PGS classification used by `pgs-mf2.ts`. + * → shared PGS classification used by `pgs-mf2.ts`, with segment bodies + * rewritten to ICU-friendly text (`#` / `{name}`). * Import path: PGS → nested ICU MF1 string. */ @@ -22,13 +23,25 @@ import { /** * If `source` is an MF1 plural/selectordinal/select message that maps to PGS, * returns switch/case segments; otherwise null (caller falls back to plain XLIFF). + * Segment bodies use ICU MF1 conventions (`#` for plural/ordinal vars). */ export function mf1MessageToPgsExport( source: string ): { switchAttr: string; segments: PgsSegmentExport[] } | null { try { const data = mf1ToMessageData(parse(source)); - return selectMessageDataToPgsExport(data); + const exp = selectMessageDataToPgsExport(data); + if (!exp) return null; + const parsedSwitch = parsePgsSwitch(exp.switchAttr); + if (!parsedSwitch) return null; + const hashVars = pluralVarNames(parsedSwitch); + return { + switchAttr: exp.switchAttr, + segments: exp.segments.map((seg) => ({ + caseAttr: seg.caseAttr, + sourcePattern: segmentBodyToMf1(seg.sourcePattern, hashVars), + })), + }; } catch { return null; } diff --git a/src/tests/pgs-mf1.test.ts b/src/tests/pgs-mf1.test.ts index 0c98930..d66bf95 100644 --- a/src/tests/pgs-mf1.test.ts +++ b/src/tests/pgs-mf1.test.ts @@ -13,6 +13,8 @@ describe("pgs-mf1", () => { expect(exp!.segments).toHaveLength(2); expect(exp!.segments[0]!.caseAttr).toBe("one"); expect(exp!.segments[1]!.caseAttr).toBe("other"); + expect(exp!.segments[0]!.sourcePattern).toBe("# file"); + expect(exp!.segments[1]!.sourcePattern).toBe("# files"); }); test("export selectordinal uses ordinal switch", () => { From f87fb803c167b5a9f1aa3329e29a488d6abc4be7 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 20:30:14 -0600 Subject: [PATCH 7/9] validate: add format/MF1 PGS export-import integration tests Cover helper round-trips and import-command e2e for NONE/MF1/MF2 plus MF1 plural PGS, and edge cases for malformed MF1. Refs #15. Co-authored-by: Cursor --- src/tests/fixtures/xliff/formats.zh.xliff | 27 ++++ src/tests/format-xliff.integration.test.ts | 180 +++++++++++++++++++++ src/tests/import.test.ts | 33 ++++ src/tests/pgs-mf1.test.ts | 12 ++ 4 files changed, 252 insertions(+) create mode 100644 src/tests/fixtures/xliff/formats.zh.xliff create mode 100644 src/tests/format-xliff.integration.test.ts diff --git a/src/tests/fixtures/xliff/formats.zh.xliff b/src/tests/fixtures/xliff/formats.zh.xliff new file mode 100644 index 0000000..bb48ee6 --- /dev/null +++ b/src/tests/fixtures/xliff/formats.zh.xliff @@ -0,0 +1,27 @@ + + + + + + literal {x} + 字面 {x} + + + + + # file + # 个文件 + + + # files + # 个文件 + + + + + Hello {$name} + 你好 {$name} + + + + diff --git a/src/tests/format-xliff.integration.test.ts b/src/tests/format-xliff.integration.test.ts new file mode 100644 index 0000000..69aa5e2 --- /dev/null +++ b/src/tests/format-xliff.integration.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "vitest"; +import { MsgProject, MsgResource } from "@worldware/msg"; +import { + serializeResourceGroupsToXliff, + type ResourceGroup, +} from "../lib/export-helpers.js"; +import { + extractResourceFromXliffFile, + parseXliff20, +} from "../lib/import-helpers.js"; + +/** Creates a project with optional format and zh target locale for import. */ +function createProject( + name: string, + opts?: { format?: "NONE" | "MF1" | "MF2" } +) { + return MsgProject.create({ + project: { name, ...(opts?.format ? { format: opts.format } : {}) }, + locales: { + sourceLocale: "en", + pseudoLocale: "en-XA", + targetLocales: { en: ["en"], zh: ["zh"] }, + }, + loader: async () => ({ + title: "", + attributes: { lang: "", dir: "", dnt: false }, + messages: [], + }), + }); +} + +/** + * Turns a monolingual export XLIFF into a bilingual file for import tests: + * sets trgLang and copies each as . + */ +function toBilingualXliff(xliff: string, trgLang: string): string { + let out = xliff.replace( + /]*?)>/, + (match, attrs: string) => { + if (/\btrgLang=/.test(attrs)) { + return match.replace(/trgLang="[^"]*"/, `trgLang="${trgLang}"`); + } + return ``; + } + ); + out = out.replace( + /]*?)>/, + (match, attrs: string) => { + if (/\btrgLang=/.test(attrs)) return match; + return ``; + } + ); + out = out.replace( + /([\s\S]*?)<\/source>/g, + (_m, body: string) => `${body}\n ${body}` + ); + return out; +} + +describe("format XLIFF integration", () => { + test("export→import round-trip preserves NONE/MF1/MF2 and MF1 PGS plurals", () => { + const project = createProject("fmtApp", { format: "MF2" }); + const resource = MsgResource.create( + { + title: "Strings", + attributes: { lang: "en", dir: "ltr", dnt: false }, + messages: [ + { + key: "raw", + value: "literal {x}", + attributes: { format: "NONE" }, + }, + { + key: "files", + value: "{count, plural, one {# file} other {# files}}", + attributes: { format: "MF1" }, + }, + { + key: "hello", + value: "Hello {$name}", + attributes: { format: "MF2" }, + }, + ], + }, + project + ); + + const groups: ResourceGroup[] = [ + { project: "fmtApp", resources: [resource] }, + ]; + const exported = serializeResourceGroupsToXliff(groups)[0]!.xliff; + + expect(exported).toMatch(/name="raw"[^>]*type="msg:NONE"|type="msg:NONE"[^>]*name="raw"/); + expect(exported).toMatch(/name="files"[^>]*type="msg:MF1"|type="msg:MF1"[^>]*name="files"/); + expect(exported).toContain('pgs:switch="plural:count"'); + expect(exported).toContain("# file"); + expect(exported).toContain("# files"); + expect(exported).toMatch(/name="hello"[^>]*type="msg:MF2"|type="msg:MF2"[^>]*name="hello"/); + + const bilingual = toBilingualXliff(exported, "zh"); + const parsed = parseXliff20(bilingual); + const xliffRoot = (parsed as Record).xliff as Record< + string, + unknown + >; + const fileEl = ( + Array.isArray(xliffRoot.file) ? xliffRoot.file[0] : xliffRoot.file + ) as Record; + + const imported = extractResourceFromXliffFile( + fileEl, + "zh", + project, + ["zh"] + ); + expect(imported).not.toBeNull(); + const data = imported!.getData(true); + expect(data.messages).toHaveLength(3); + + const byKey = Object.fromEntries( + (data.messages ?? []).map((m) => [m.key, m]) + ); + expect(byKey.raw!.attributes?.format).toBe("NONE"); + expect(byKey.raw!.value).toBe("literal {x}"); + + expect(byKey.files!.attributes?.format).toBe("MF1"); + expect(byKey.files!.value).toMatch(/\{count,\s*plural,/); + expect(byKey.files!.value).toContain("# file"); + expect(byKey.files!.value).toContain("# files"); + + expect( + byKey.hello!.attributes?.format === "MF2" || + byKey.hello!.attributes?.format === undefined + ).toBe(true); + expect(byKey.hello!.value).toContain("Hello"); + }); + + test("MF2 .match export→import round-trip via PGS", () => { + const project = createProject("mf2App"); + const pluralSrc = `.input {$n :number} +.match $n +one {{One item}} +* {{{$n} items}}`; + const resource = MsgResource.create( + { + title: "R", + attributes: { lang: "en", dir: "ltr", dnt: false }, + messages: [{ key: "items", value: pluralSrc }], + }, + project + ); + + const exported = serializeResourceGroupsToXliff([ + { project: "mf2App", resources: [resource] }, + ])[0]!.xliff; + expect(exported).toContain('type="msg:MF2"'); + expect(exported).toContain('pgs:switch="plural:n"'); + + const bilingual = toBilingualXliff(exported, "zh"); + const parsed = parseXliff20(bilingual); + const xliffRoot = (parsed as Record).xliff as Record< + string, + unknown + >; + const fileEl = ( + Array.isArray(xliffRoot.file) ? xliffRoot.file[0] : xliffRoot.file + ) as Record; + + const imported = extractResourceFromXliffFile( + fileEl, + "zh", + project, + ["zh"] + ); + expect(imported).not.toBeNull(); + const msg = imported!.getData(true).messages![0]!; + expect(msg.value).toMatch(/\.match/); + expect(msg.value).toContain("One item"); + }); +}); diff --git a/src/tests/import.test.ts b/src/tests/import.test.ts index 7214acf..4683f7e 100644 --- a/src/tests/import.test.ts +++ b/src/tests/import.test.ts @@ -101,6 +101,39 @@ describe("Import command", () => { expect(content.messages[1].value).toBe("世界"); }); + test("imports NONE/MF1/MF2 formats and MF1 PGS plurals from XLIFF 2.2", async () => { + const { xliffDir, translationsDir, projectsDir } = + setupPackageWithI18nAndL10n(tmp); + copyFileSync( + join(FIXTURES_XLIFF, "formats.zh.xliff"), + join(xliffDir, "test.zh.xliff") + ); + copyFileSync( + join(FIXTURES_PROJECTS, "test.ts"), + join(projectsDir, "test.ts") + ); + writeFileSync(join(tmp, "tsconfig.json"), "{}"); + + await ImportCmd.run([], CLI_ROOT); + + const outputPath = join(translationsDir, "test", "zh", "Example.json"); + expect(existsSync(outputPath)).toBe(true); + const content = JSON.parse(readFileSync(outputPath, "utf-8")); + const byKey = Object.fromEntries( + content.messages.map((m: { key: string }) => [m.key, m]) + ); + expect(byKey.raw.attributes.format).toBe("NONE"); + expect(byKey.raw.value).toBe("字面 {x}"); + expect(byKey.files.attributes.format).toBe("MF1"); + expect(byKey.files.value).toMatch(/\{count,\s*plural,/); + expect(byKey.files.value).toContain("# 个文件"); + expect( + byKey.hello.attributes?.format === "MF2" || + byKey.hello.attributes?.format === undefined + ).toBe(true); + expect(byKey.hello.value).toBe("你好 {$name}"); + }); + test("import filtered to single project", async () => { const { xliffDir, translationsDir, projectsDir } = setupPackageWithI18nAndL10n(tmp); diff --git a/src/tests/pgs-mf1.test.ts b/src/tests/pgs-mf1.test.ts index d66bf95..51fdc18 100644 --- a/src/tests/pgs-mf1.test.ts +++ b/src/tests/pgs-mf1.test.ts @@ -53,6 +53,18 @@ describe("pgs-mf1", () => { expect(mf1MessageToPgsExport("Hello {name}")).toBeNull(); }); + test("returns null for malformed MF1", () => { + expect(mf1MessageToPgsExport("{count, plural, one {")).toBeNull(); + }); + + test("import returns null for empty segments or bad switch", () => { + expect(pgsImportToMf1Message("", [])).toBeNull(); + expect(pgsImportToMf1Message("plural:n", [])).toBeNull(); + expect( + pgsImportToMf1Message("plural:n", [{ caseAttr: "one two", body: "x" }]) + ).toBeNull(); + }); + test("round-trip plural via PGS import", () => { const src = "{count, plural, one {one file} other {# files}}"; const exp = mf1MessageToPgsExport(src); From 3e5f6e63ec5126be39082c60c9aeb62770a02951 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 20:33:34 -0600 Subject: [PATCH 8/9] document: describe format type and MF1/MF2 PGS in docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update README, export/import specs, and project info for unit@type (msg:*) and MF1 ICU ↔ PGS support. Refs #15. Co-authored-by: Cursor --- README.md | 26 ++++++++++++++++++-------- project/info.md | 5 ++++- src/specs/export-command.spec.md | 4 +++- src/specs/import-command.spec.md | 4 +++- 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e32cee9..2ebb8de 100644 --- a/README.md +++ b/README.md @@ -200,12 +200,22 @@ msg export -p myApp - **Resource attributes** — `dir` → file `srcDir`; `dnt` → file `translate="no"`. - **Message notes** — Emitted as unit-level `` with category (e.g. `description`, `context`, `parameters`). - **Message attributes** — `dnt` → unit `translate="no"`; message `dir` is serialized as the unit’s `srcDir` attribute (XLIFF text direction for the segment). +- **Message format** — Resolved `format` (`NONE` / `MF1` / `MF2`, including inheritance from resource/project) is written as the unit `type` attribute using the XLIFF custom form `msg:NONE`, `msg:MF1`, or `msg:MF2`. -**Plural, gender, select (PGS) and MessageFormat 2:** Messages written in Unicode MessageFormat 2 using `.match` are exported to the [XLIFF 2.2 Plural, Gender, and Select module](https://docs.oasis-open.org/xliff/xliff-core/v2.2/xliff-extended-v2.2-part2.html) when the selectors classify as `plural`, `ordinal`, `gender`, or `select` (see implementation in `pgs-mf2`). Such units use `xmlns:pgs="urn:oasis:names:tc:xliff:pgs:1.0"`, `pgs:switch` on the ``, and one `` per variant with `pgs:case`. Other messages stay a single segment with the full MF2 string in ``. Unsupported `.match` shapes fall back to that single-segment form. +**Plural, gender, select (PGS):** Classifiable plural/select messages are exported to the [XLIFF 2.2 Plural, Gender, and Select module](https://docs.oasis-open.org/xliff/xliff-core/v2.2/xliff-extended-v2.2-part2.html) (`xmlns:pgs="urn:oasis:names:tc:xliff:pgs:1.0"`): `pgs:switch` on the ``, and one `` per variant with `pgs:case`. + +- **MF2** — Unicode MessageFormat 2 `.match` messages (see `pgs-mf2`). Segment bodies keep MF2 pattern text. Unsupported `.match` shapes fall back to a single segment with the full string in ``. +- **MF1** — ICU MessageFormat 1 `plural` / `selectordinal` / `select` messages (see `pgs-mf1`). Segment bodies use ICU-friendly text (`#` for the plural/ordinal variable, `{name}` for others). +- **NONE** — Always a single segment with the raw string (no PGS). ### import -Import translations from bilingual XLIFF 2.0 or 2.2 files in `l10n/xliff` to JSON files in `l10n/translations`. Expects XLIFF files with `trgLang` (target language) and translated content in `` elements. Writes JSON files without notes for minimal size. Requires `package.json` with `directories.i18n` and `directories.l10n` (run `msg init` first). Units with `pgs:switch` are reassembled into a single MessageFormat 2 string per message key from the segment bodies (targets when present). +Import translations from bilingual XLIFF 2.0 or 2.2 files in `l10n/xliff` to JSON files in `l10n/translations`. Expects XLIFF files with `trgLang` (target language) and translated content in `` elements. Writes JSON files without notes for minimal size. Requires `package.json` with `directories.i18n` and `directories.l10n` (run `msg init` first). + +**Format and PGS on import:** + +- Unit `type` of `msg:NONE` / `msg:MF1` / `msg:MF2` (or bare `NONE` / `MF1` / `MF2`) is restored as the message `attributes.format`. +- Units with `pgs:switch` are reassembled from segment bodies (`` when present): **MF1** → nested ICU MessageFormat 1; otherwise (including missing type) → MessageFormat 2 `.match`. Single-segment units are unchanged. ```bash msg import [-p ] [-l ] @@ -246,9 +256,9 @@ msg import -l zh ### Example: plural messages (XLIFF 2.2 PGS) -Plural (and other classifiable `.match`) messages use the [XLIFF 2.2 PGS module](https://docs.oasis-open.org/xliff/xliff-core/v2.2/xliff-extended-v2.2-part2.html): export splits them into one `` per variant (`pgs:case`), and import merges `` text back into a single MessageFormat 2 string per message key. +Plural (and other classifiable select) messages use the [XLIFF 2.2 PGS module](https://docs.oasis-open.org/xliff/xliff-core/v2.2/xliff-extended-v2.2-part2.html): export splits them into one `` per variant (`pgs:case`), and import merges `` text back into a single message string per key. The unit `type` (`msg:MF1` or `msg:MF2`) selects ICU MessageFormat 1 vs MessageFormat 2 reconstruction. -**1. Message in the MsgResource** (Unicode MessageFormat 2 with `.input` / `.match` on a plural): +**1. Message in the MsgResource** (MF2 `.match`, or MF1 `{count, plural, ...}` with `format: "MF1"`): ```js // i18n/resources/messages.msg.js — excerpt @@ -271,7 +281,7 @@ The plural becomes a `pgs:switch` unit with one segment per case (abbreviated): - + One item @@ -288,7 +298,7 @@ The plural becomes a `pgs:switch` unit with one segment per case (abbreviated): ```xml - + One item Un élément @@ -302,7 +312,7 @@ The plural becomes a `pgs:switch` unit with one segment per case (abbreviated): ``` -**4. Import** — writes minimal JSON under `l10n/translations///.json`, with one MF2 message per key rebuilt from the PGS segments: +**4. Import** — writes minimal JSON under `l10n/translations///.json`, with one message per key rebuilt from the PGS segments (MF2 `.match` when `type` is `msg:MF2` or omitted; ICU MF1 when `type` is `msg:MF1`): ```bash msg import @@ -343,7 +353,7 @@ The CLI does not expose a programmatic API. For library usage, see [@worldware/m Source layout: - `src/commands/` — CLI commands (init, export, import, create/project, create/resource). -- `src/lib/` — Shared utilities: init-helpers, export-helpers, import-helpers, create-project-helpers, create-resource-helpers. +- `src/lib/` — Shared utilities: init-helpers, export-helpers, import-helpers, create-project-helpers, create-resource-helpers, msg-format, pgs-mf1, pgs-mf2. - `src/tests/` — Vitest tests and fixtures. ## License diff --git a/project/info.md b/project/info.md index daa846b..a2d8646 100644 --- a/project/info.md +++ b/project/info.md @@ -25,7 +25,9 @@ process in `project/process.md`. Keep it accurate — the process depends on it. (config in `vite.config.mjs`). - **Bundler:** [tsup](https://tsup.egoist.dev) (config in `tsup.config.ts`); emits both CJS (`.cjs`) and ESM (`.mjs`) plus type declarations to `dist/`. -- **Key dependencies:** `@worldware/msg`, `messageformat`, `fast-xml-parser`. +- **Key dependencies:** `@worldware/msg`, `messageformat`, + `@messageformat/icu-messageformat-1`, `@messageformat/parser`, + `fast-xml-parser`. ## Repository structure @@ -35,6 +37,7 @@ process in `project/process.md`. Keep it accurate — the process depends on it. `import.ts`, and the `create` topic (`create/project.ts`, `create/resource.ts`). - `src/lib/` — shared helpers: `init-helpers.ts`, `export-helpers.ts`, `import-helpers.ts`, `create-project-helpers.ts`, `create-resource-helpers.ts`, + `msg-format.ts` (MsgFormat ↔ XLIFF `unit@type`), `pgs-mf1.ts` (ICU MF1 ↔ PGS), and `pgs-mf2.ts` (MessageFormat 2 ↔ XLIFF 2.2 PGS conversion). - `src/specs/` — Markdown command specifications and `_template*.spec.md` templates (excluded from the build). diff --git a/src/specs/export-command.spec.md b/src/specs/export-command.spec.md index e96f342..5441f6f 100644 --- a/src/specs/export-command.spec.md +++ b/src/specs/export-command.spec.md @@ -1,6 +1,8 @@ ## 1. Summary -The `export` command serializes MsgResource objects, on a project-by-project basis, to **XLIFF 2.2** files suitable for translation. The command **does not send** the XLIFF files for translation. The msg system is agnostic in terms of the mechanism used to translate these files. XLIFF was chosen because it is a standard format for localization exchange, and the 2.2 core module preserves the same file- and unit-level notes and attributes as before. When a message value is a Unicode MessageFormat 2 `.match` message that classifies into the [Plural, Gender, and Select (PGS) module](https://docs.oasis-open.org/xliff/xliff-core/v2.2/xliff-extended-v2.2-part2.html#49-plural-gender-and-select-module) (`urn:oasis:names:tc:xliff:pgs:1.0`), the command emits `pgs:switch` on the unit and one segment per variant with `pgs:case`; otherwise it emits a single segment with the full MF2 string in ``. +The `export` command serializes MsgResource objects, on a project-by-project basis, to **XLIFF 2.2** files suitable for translation. The command **does not send** the XLIFF files for translation. The msg system is agnostic in terms of the mechanism used to translate these files. XLIFF was chosen because it is a standard format for localization exchange, and the 2.2 core module preserves the same file- and unit-level notes and attributes as before. + +Each unit’s resolved message `format` (`NONE` / `MF1` / `MF2`) is written as `type="msg:…"`. When a message classifies into the [Plural, Gender, and Select (PGS) module](https://docs.oasis-open.org/xliff/xliff-core/v2.2/xliff-extended-v2.2-part2.html#49-plural-gender-and-select-module) (`urn:oasis:names:tc:xliff:pgs:1.0`), the command emits `pgs:switch` on the unit and one segment per variant with `pgs:case`: **MF2** `.match` messages via `pgs-mf2`, **MF1** ICU `plural` / `selectordinal` / `select` via `pgs-mf1` (segment bodies use `#` / `{name}`). Otherwise it emits a single segment with the full source string in ``. **Important Note:** The `export` command involves a series of operations. The functionality for most of these operations has already implemented in `utilities.ts`, but that functionality should be considered deprecated and should only be used as a reference. In this iteration, the functionality should be moved to command helper functions, like the other commands. diff --git a/src/specs/import-command.spec.md b/src/specs/import-command.spec.md index fb8061e..0308cf9 100644 --- a/src/specs/import-command.spec.md +++ b/src/specs/import-command.spec.md @@ -1,6 +1,8 @@ ## 1. Summary -The `import` command extracts translations from **XLIFF 2.0 or 2.2** files and writes them to corresponding JSON files inside project name and locale directories inside `l10n/translations`. It writes a minimal JSON file in order to decrease size. For units that carry **PGS** attributes (`pgs:switch` / `pgs:case`), it reconstructs a single MessageFormat 2 string per message from the segments (using `` text when present); legacy single-segment units behave unchanged. +The `import` command extracts translations from **XLIFF 2.0 or 2.2** files and writes them to corresponding JSON files inside project name and locale directories inside `l10n/translations`. It writes a minimal JSON file in order to decrease size. + +Unit `type` values `msg:NONE` / `msg:MF1` / `msg:MF2` (or bare format tokens) are restored as message `attributes.format`. For units that carry **PGS** attributes (`pgs:switch` / `pgs:case`), it reconstructs a single message string per key from the segments (using `` text when present): **MF1** → ICU MessageFormat 1 (nested for multi-switch); otherwise → MessageFormat 2 `.match`. Legacy single-segment units behave unchanged. **Important Note:** The `import` command involves a series of operations. The functionality for most of these operations has already been implemented in `utilities.ts`, but that functionality should be considered deprecated and should only be used as a reference. In this iteration, the functionality should be moved to command helper functions, like the other commands. From 40149338fdefb913adf00135b4869377927dbe7d Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 20:36:00 -0600 Subject: [PATCH 9/9] refactor: clarify PGS dispatch and export SelectMessage type Extract pgsExportForFormat, simplify segment text join, and export SelectMessage for docs/typing consistency. Refs #15. Co-authored-by: Cursor --- src/lib/export-helpers.ts | 28 ++++++++++++++++++++-------- src/lib/import-helpers.ts | 9 ++------- src/lib/pgs-mf2.ts | 7 +++++-- 3 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/lib/export-helpers.ts b/src/lib/export-helpers.ts index c1489b3..ab9c8b8 100644 --- a/src/lib/export-helpers.ts +++ b/src/lib/export-helpers.ts @@ -9,7 +9,10 @@ import { type MsgFormat, } from "./msg-format.js"; import { mf1MessageToPgsExport } from "./pgs-mf1.js"; -import { selectMessageToPgsExport } from "./pgs-mf2.js"; +import { + selectMessageToPgsExport, + type PgsSegmentExport, +} from "./pgs-mf2.js"; /** Object grouping resources with their project name (spec: resource group object). */ export interface ResourceGroup { @@ -201,11 +204,25 @@ function renderNotes( return parts; } +/** + * Maps a message to PGS export data based on its resolved format. + * NONE and unclassified messages return null (single-segment fallback). + */ +function pgsExportForFormat( + format: MsgFormat, + value: string +): { switchAttr: string; segments: PgsSegmentExport[] } | null { + if (format === "MF1") return mf1MessageToPgsExport(value); + if (format === "MF2") return selectMessageToPgsExport(value); + return null; +} + /** * Serializes a single ResourceGroup to an XLIFF 2.2 document string. * Preserves message keys (as unit id and name), source text, resource and message * notes, attributes (lang, dir, dnt), and resource/file structure. - * MessageFormat 2 `.match` messages that map to PGS use `pgs:switch` / `pgs:case`. + * MessageFormat 2 `.match` and MF1 ICU plural/select messages that map to PGS + * use `pgs:switch` / `pgs:case`; resolved format is written as unit `type`. */ function resourceGroupToXliff22(group: ResourceGroup): string { const srcLang = group.resources[0]?.attributes?.lang ?? "en"; @@ -267,12 +284,7 @@ function resourceGroupToXliff22(group: ResourceGroup): string { ); msgAttrs.push(`type="${escapeXml(formatToUnitType(msgFormat))}"`); - const pgsExport = - msgFormat === "MF1" - ? mf1MessageToPgsExport(msg.value) - : msgFormat === "MF2" - ? selectMessageToPgsExport(msg.value) - : null; + const pgsExport = pgsExportForFormat(msgFormat, msg.value); if (pgsExport) { msgAttrs.push(`pgs:switch="${escapeXml(pgsExport.switchAttr)}"`); } diff --git a/src/lib/import-helpers.ts b/src/lib/import-helpers.ts index 0ac90fe..81a96dd 100644 --- a/src/lib/import-helpers.ts +++ b/src/lib/import-helpers.ts @@ -311,13 +311,8 @@ function processUnit( const pgsSwitch = unit["@_pgs:switch"] as string | undefined; - const joinSegmentTexts = (): string => { - const parts: string[] = []; - for (const seg of segments) { - parts.push(extractSegmentText(seg)); - } - return parts.join(""); - }; + const joinSegmentTexts = (): string => + segments.map((seg) => extractSegmentText(seg)).join(""); let value: string; if ( diff --git a/src/lib/pgs-mf2.ts b/src/lib/pgs-mf2.ts index 20df9d2..f05e181 100644 --- a/src/lib/pgs-mf2.ts +++ b/src/lib/pgs-mf2.ts @@ -50,7 +50,7 @@ export type VariantKey = | { type: "literal"; value: string }; /** Parsed MF2 select (`.match`) message shape from `messageformat`. */ -type SelectMessage = { +export type SelectMessage = { type: "select"; declarations: unknown[]; selectors: Array<{ type: string; name?: string }>; @@ -142,7 +142,10 @@ export function classifySelectMessageForPgs( const expr = decl.value as { type?: string; - functionRef?: { name?: string; options?: Map }; + functionRef?: { + name?: string; + options?: Map | Record; + }; }; if (expr.type !== "expression") return null;