From 0dc4784cebb3fadf405c89ae9651b87018d9192e Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 14:32:52 -0600 Subject: [PATCH 1/8] scaffold: add MsgFormat type and project-level format Introduce the `MsgFormat` union ('MF1' | 'MF2' | 'NONE') and a `format` attribute, plus a project-level `format` (default MF2) that resources will inherit. Align messageformat to 4.0.0 stable and add @messageformat/icu-messageformat-1 for MF1 support. Refs #33 Co-authored-by: Cursor --- package-lock.json | 56 +++++++++++++++++++++--- package.json | 3 +- src/classes/MsgInterface/MsgInterface.ts | 16 +++++++ src/classes/MsgProject/MsgProject.ts | 13 +++++- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8b8ffa9..9a0eb22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.7.1", "license": "MIT", "dependencies": { - "messageformat": "4.0.0-10", + "@messageformat/icu-messageformat-1": "^0.12.0", + "messageformat": "^4.0.0", "pseudo-localization": "^2.4.0" }, "devDependencies": { @@ -561,6 +562,42 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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/@rollup/rollup-android-arm-eabi": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", @@ -1568,10 +1605,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/mlly": { "version": "1.8.0", @@ -1586,6 +1626,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", diff --git a/package.json b/package.json index 0d58aae..07519e2 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "vitest": "^4.0.15" }, "dependencies": { - "messageformat": "4.0.0-10", + "@messageformat/icu-messageformat-1": "^0.12.0", + "messageformat": "^4.0.0", "pseudo-localization": "^2.4.0" }, "scripts": { diff --git a/src/classes/MsgInterface/MsgInterface.ts b/src/classes/MsgInterface/MsgInterface.ts index e2ae694..512eccc 100644 --- a/src/classes/MsgInterface/MsgInterface.ts +++ b/src/classes/MsgInterface/MsgInterface.ts @@ -5,10 +5,26 @@ export type MsgNote = { content: string; } +/** + * The message formatting syntax a string is written in. + * + * - `MF1` — ICU MessageFormat 1 (formatted via `@messageformat/icu-messageformat-1`). + * - `MF2` — Unicode MessageFormat 2 (the default, for backwards compatibility). + * - `NONE` — not a formatted message; the raw string is returned as-is. + */ +export type MsgFormat = 'MF1' | 'MF2' | 'NONE'; + +/** + * The format applied when a message does not specify (or inherit) one. + * Kept at MF2 for backwards compatibility. + */ +export const MSG_DEFAULT_FORMAT: MsgFormat = 'MF2'; + export type MsgAttributes = { lang?: string dir?: string dnt?: boolean + format?: MsgFormat } export const DEFAULT_ATTRIBUTES: MsgAttributes = { diff --git a/src/classes/MsgProject/MsgProject.ts b/src/classes/MsgProject/MsgProject.ts index 2597ed7..4cec1ec 100644 --- a/src/classes/MsgProject/MsgProject.ts +++ b/src/classes/MsgProject/MsgProject.ts @@ -1,8 +1,10 @@ import { type MsgResourceData } from "../MsgResource/MsgResource.js"; +import { type MsgFormat, MSG_DEFAULT_FORMAT } from "../MsgInterface/MsgInterface.js"; type MsgProjectSettings = { name: string version?: number + format?: MsgFormat }; type MsgTargetLocalesSettings = { @@ -25,7 +27,8 @@ export type MsgProjectData = { const defaultProjectSettings: MsgProjectSettings = { name: 'messages', - version: 1 + version: 1, + format: MSG_DEFAULT_FORMAT }; const defaultLocalesSettings: MsgLocalesSettings = { @@ -68,6 +71,14 @@ export class MsgProject { return this._loader; } + /** + * The project-wide default message format. Resources (and, through them, + * messages) inherit this value unless they specify their own. + */ + public get format(): MsgFormat { + return this._project.format ?? MSG_DEFAULT_FORMAT; + } + public getTargetLocale(locale: string): string[] | undefined { return this._locales.targetLocales[locale]; } From 060e0c8c39277b892ccafb6475d1de89463306d9 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 14:33:01 -0600 Subject: [PATCH 2/8] scaffold: add failing MF1/MF2/NONE format tests Cover project/resource/message format inheritance, MF1/MF2/NONE formatting behavior, and format-aware serialization. Also update the MF2 formatToParts snapshot for messageformat 4.0.0 (the `number` part no longer includes a `source` field). Refs #33 Co-authored-by: Cursor --- src/tests/MsgFormat.test.ts | 224 +++++++++++++++++++++++++++++++++++ src/tests/MsgMessage.test.ts | 1 - 2 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/tests/MsgFormat.test.ts diff --git a/src/tests/MsgFormat.test.ts b/src/tests/MsgFormat.test.ts new file mode 100644 index 0000000..14070f0 --- /dev/null +++ b/src/tests/MsgFormat.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from 'vitest'; +import { MsgProject, MsgProjectData } from '../classes/MsgProject/MsgProject.js'; +import { MsgResource } from '../classes/MsgResource/MsgResource.js'; +import { MsgMessage } from '../classes/MsgMessage/MsgMessage.js'; + +const makeProjectData = (format?: 'MF1' | 'MF2' | 'NONE'): MsgProjectData => ({ + project: { + name: 'format-test', + version: 1, + ...(format ? { format } : {}) + }, + locales: { + sourceLocale: 'en', + pseudoLocale: 'en-XA', + targetLocales: { en: ['en'] } + }, + loader: async () => ({ title: 'T', attributes: { lang: 'en', dir: 'ltr' }, messages: [] }) +}); + +describe('MsgFormat: project-level format', () => { + test('defaults to MF2 when not configured', () => { + const project = MsgProject.create(makeProjectData()); + expect(project.format).toBe('MF2'); + }); + + test('respects a configured format', () => { + const project = MsgProject.create(makeProjectData('MF1')); + expect(project.format).toBe('MF1'); + }); +}); + +describe('MsgFormat: resource inheritance', () => { + test('resource inherits the project format when unspecified', () => { + const project = MsgProject.create(makeProjectData('MF1')); + const resource = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + + expect(resource.attributes.format).toBe('MF1'); + }); + + test('resource defaults to MF2 when the project defaults', () => { + const project = MsgProject.create(makeProjectData()); + const resource = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + + expect(resource.attributes.format).toBe('MF2'); + }); + + test('resource overrides the inherited project format', () => { + const project = MsgProject.create(makeProjectData('MF1')); + const resource = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr', format: 'NONE' } + }, project); + + expect(resource.attributes.format).toBe('NONE'); + }); +}); + +describe('MsgFormat: message inheritance', () => { + test('message inherits the resource format', () => { + const project = MsgProject.create(makeProjectData('MF1')); + const resource = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + resource.add('key', '{count, plural, one {# file} other {# files}}'); + + expect(resource.get('key')?.attributes.format).toBe('MF1'); + }); + + test('message overrides the inherited resource format', () => { + const project = MsgProject.create(makeProjectData('MF1')); + const resource = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + resource.add('key', 'raw string', { format: 'NONE' }); + + expect(resource.get('key')?.attributes.format).toBe('NONE'); + }); +}); + +describe('MsgFormat: MsgMessage.format', () => { + test('formats MF2 by default when no format attribute is present', () => { + const msg = MsgMessage.create({ + key: 'k', + value: 'Hello', + attributes: { lang: 'en' } + }); + expect(msg.format({})).toBe('Hello'); + }); + + test('formats an MF2 message when format is MF2', () => { + const msg = MsgMessage.create({ + key: 'k', + value: '.input {$count :number}\n .match $count\n one {{One file.}} *{{{$count} files.}}', + attributes: { lang: 'en', format: 'MF2' } + }); + expect(msg.format({ count: 1 })).toBe('One file.'); + expect(msg.format({ count: 3 })).toBe('3 files.'); + }); + + test('formats an MF1 plural message when format is MF1', () => { + const msg = MsgMessage.create({ + key: 'k', + value: '{count, plural, one {# file} other {# files}}', + attributes: { lang: 'en', format: 'MF1' } + }); + expect(msg.format({ count: 1 })).toBe('1 file'); + expect(msg.format({ count: 5 })).toBe('5 files'); + }); + + test('formats an MF1 select message when format is MF1', () => { + const msg = MsgMessage.create({ + key: 'k', + value: '{gender, select, male {He} female {She} other {They}} liked this', + attributes: { lang: 'en', format: 'MF1' } + }); + expect(msg.format({ gender: 'female' })).toBe('She liked this'); + }); + + test('returns the raw string when format is NONE', () => { + const raw = '{count, plural, one {# file} other {# files}}'; + const msg = MsgMessage.create({ + key: 'k', + value: raw, + attributes: { lang: 'en', format: 'NONE' } + }); + expect(msg.format({ count: 5 })).toBe(raw); + }); +}); + +describe('MsgFormat: MsgMessage.formatToParts', () => { + test('returns MF1 parts when format is MF1', () => { + const msg = MsgMessage.create({ + key: 'k', + value: '{count, plural, one {# file} other {# files}}', + attributes: { lang: 'en', format: 'MF1' } + }); + const parts = msg.formatToParts({ count: 5 }); + const text = parts.map(p => ('value' in p ? p.value : '')).join(''); + expect(text).toContain('5 files'); + }); + + test('returns a single text part when format is NONE', () => { + const raw = '{count, plural, one {# file} other {# files}}'; + const msg = MsgMessage.create({ + key: 'k', + value: raw, + attributes: { lang: 'en', format: 'NONE' } + }); + expect(msg.formatToParts({})).toStrictEqual([{ type: 'text', value: raw }]); + }); +}); + +describe('MsgFormat: serialization', () => { + test('resource omits format when it equals the project format', () => { + const project = MsgProject.create(makeProjectData('MF2')); + const resource = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + + const data = resource.getData(); + expect(data.attributes.format).toBeUndefined(); + }); + + test('resource includes format when it differs from the project format', () => { + const project = MsgProject.create(makeProjectData('MF2')); + const resource = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr', format: 'MF1' } + }, project); + + const data = resource.getData(); + expect(data.attributes.format).toBe('MF1'); + }); + + test('message omits format when it equals the resource format but keeps other differing attributes', () => { + const project = MsgProject.create(makeProjectData('MF1')); + const resource = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + // format inherited (MF1, matches resource), but lang differs + resource.add('key', 'value', { lang: 'fr' }); + + const data = resource.getData(); + const msgData = data.messages!.find(m => m.key === 'key')!; + expect(msgData.attributes?.lang).toBe('fr'); + expect(msgData.attributes?.format).toBeUndefined(); + }); + + test('message includes format when it differs from the resource format', () => { + const project = MsgProject.create(makeProjectData('MF2')); + const resource = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + resource.add('key', 'value', { format: 'MF1' }); + + const data = resource.getData(); + const msgData = data.messages!.find(m => m.key === 'key')!; + expect(msgData.attributes?.format).toBe('MF1'); + }); + + test('message with fully matching attributes still omits its attributes entirely', () => { + const project = MsgProject.create(makeProjectData('MF1')); + const resource = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + resource.add('key', 'value'); + + const data = resource.getData(); + const msgData = data.messages!.find(m => m.key === 'key')!; + expect(msgData.attributes).toBeUndefined(); + }); +}); diff --git a/src/tests/MsgMessage.test.ts b/src/tests/MsgMessage.test.ts index 842d2d7..65fde42 100644 --- a/src/tests/MsgMessage.test.ts +++ b/src/tests/MsgMessage.test.ts @@ -134,7 +134,6 @@ describe('MsgMessage tests', () => { "value": "5", }, ], - "source": "$count", "type": "number", }, { From 4550c54260b236cb2680784a899f9019a7ad4caf Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 14:38:49 -0600 Subject: [PATCH 3/8] implement: branch MsgMessage formatting on format attribute format/formatToParts now resolve the message's format (default MF2): MF1 compiles via mf1ToMessage, MF2 uses MessageFormat, and NONE returns the raw string / a single text part. Fix the MF1 formatToParts test to match the real nested number part shape. Refs #33 Co-authored-by: Cursor --- src/classes/MsgMessage/MsgMessage.ts | 33 ++++++++++++++++++++++------ src/tests/MsgFormat.test.ts | 6 +++-- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/classes/MsgMessage/MsgMessage.ts b/src/classes/MsgMessage/MsgMessage.ts index 0bbe833..d637f38 100644 --- a/src/classes/MsgMessage/MsgMessage.ts +++ b/src/classes/MsgMessage/MsgMessage.ts @@ -1,5 +1,6 @@ import { MessageFormat, type MessageFormatOptions } from "messageformat"; -import { MsgInterface, DEFAULT_ATTRIBUTES, type MsgAttributes, type MsgNote } from "../MsgInterface/MsgInterface.js"; +import { mf1ToMessage } from "@messageformat/icu-messageformat-1"; +import { MsgInterface, DEFAULT_ATTRIBUTES, MSG_DEFAULT_FORMAT, type MsgAttributes, type MsgFormat, type MsgNote } from "../MsgInterface/MsgInterface.js"; export type MsgMessageData = { key: string @@ -55,18 +56,36 @@ export class MsgMessage implements MsgInterface { this.notes.push(note); } - public format(data: Record, options?: MessageFormatOptions) { + /** + * The message's resolved format, defaulting to MF2 when not set or inherited. + */ + private resolveFormat(): MsgFormat { + return this.attributes.format ?? MSG_DEFAULT_FORMAT; + } + + private getFormatter(options?: MessageFormatOptions): MessageFormat { if (!this._mf) { - this._mf = new MessageFormat(this.attributes.lang, this.value, options) + this._mf = this.resolveFormat() === 'MF1' + ? mf1ToMessage(this.attributes.lang, this.value, options) + : new MessageFormat(this.attributes.lang, this.value, options); + } + return this._mf; + } + + public format(data: Record, options?: MessageFormatOptions) { + // NONE messages are not formatted; the raw string is returned as-is. + if (this.resolveFormat() === 'NONE') { + return this.value; } - return this._mf.format(data); + return this.getFormatter(options).format(data); } public formatToParts(data: Record, options?: MessageFormatOptions) { - if (!this._mf) { - this._mf = new MessageFormat(this.attributes.lang, this.value, options) + // NONE messages have no placeholders to resolve; return the raw text part. + if (this.resolveFormat() === 'NONE') { + return [{ type: 'text' as const, value: this.value }]; } - return this._mf?.formatToParts(data); + return this.getFormatter(options).formatToParts(data); } public getData(stripNotes: boolean = false) { diff --git a/src/tests/MsgFormat.test.ts b/src/tests/MsgFormat.test.ts index 14070f0..fa6961d 100644 --- a/src/tests/MsgFormat.test.ts +++ b/src/tests/MsgFormat.test.ts @@ -143,8 +143,10 @@ describe('MsgFormat: MsgMessage.formatToParts', () => { attributes: { lang: 'en', format: 'MF1' } }); const parts = msg.formatToParts({ count: 5 }); - const text = parts.map(p => ('value' in p ? p.value : '')).join(''); - expect(text).toContain('5 files'); + + // The `#` resolves to a nested number part; " files" is a text part. + expect(parts.some(p => p.type === 'number')).toBe(true); + expect(parts.some(p => p.type === 'text' && p.value === ' files')).toBe(true); }); test('returns a single text part when format is NONE', () => { From 133c610f28fc061afef27660fd9a9896171a1c66 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 14:38:49 -0600 Subject: [PATCH 4/8] implement: inherit and serialize resource/message format Resources inherit the project's format at creation; hasMatchingAttributes accounts for format so inherited values collapse on serialization. getData omits format on a resource when it equals the project's and on a message when it equals its resource's. Refs #33 Co-authored-by: Cursor --- src/classes/MsgResource/MsgResource.ts | 33 ++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/classes/MsgResource/MsgResource.ts b/src/classes/MsgResource/MsgResource.ts index 1185a2f..03c9335 100644 --- a/src/classes/MsgResource/MsgResource.ts +++ b/src/classes/MsgResource/MsgResource.ts @@ -1,7 +1,7 @@ import { parseMessage, stringifyMessage, visit } from "messageformat"; import { localize } from "pseudo-localization"; import { type MsgMessageData, MsgMessage } from "../MsgMessage/MsgMessage.js"; -import { DEFAULT_ATTRIBUTES, MsgInterface, type MsgAttributes, type MsgNote } from "../MsgInterface/MsgInterface.js"; +import { DEFAULT_ATTRIBUTES, MSG_DEFAULT_FORMAT, MsgInterface, type MsgAttributes, type MsgNote } from "../MsgInterface/MsgInterface.js"; import { MsgProject } from "../MsgProject/MsgProject.js"; export type MsgResourceData = { @@ -39,7 +39,8 @@ export class MsgResource extends Map implements MsgInterface super(); this._title = title; - this._attributes = {...DEFAULT_ATTRIBUTES, ...attributes}; + // Inherit the project's format unless the resource specifies its own. + this._attributes = {...DEFAULT_ATTRIBUTES, format: project.format, ...attributes}; this._project = project; if (notes) { @@ -51,7 +52,14 @@ export class MsgResource extends Map implements MsgInterface private hasMatchingAttributes(message: MsgMessage): boolean { const res = this.attributes; const msg = message.attributes; - return res.lang === msg.lang && res.dir === msg.dir && res.dnt === msg.dnt; + return res.lang === msg.lang + && res.dir === msg.dir + && res.dnt === msg.dnt + && this.resolveFormat(res) === this.resolveFormat(msg); + } + + private resolveFormat(attributes: MsgAttributes) { + return attributes.format ?? MSG_DEFAULT_FORMAT; } private pseudoLocalizeMF2( @@ -201,6 +209,9 @@ export class MsgResource extends Map implements MsgInterface public getData(stripNotes: boolean = false): MsgResourceData { + const resourceFormat = this.resolveFormat(this.attributes); + const projectFormat = this._project.format; + const messages: MsgMessageData[] = []; this.forEach(msg => { if (this.hasMatchingAttributes(msg)) { @@ -212,13 +223,25 @@ export class MsgResource extends Map implements MsgInterface }; messages.push(data); } else { - messages.push(msg.getData(stripNotes)) + const data = msg.getData(stripNotes); + // omit the message's format when it matches the resource's format + if (data.attributes && this.resolveFormat(data.attributes) === resourceFormat) { + const { format, ...rest } = data.attributes; + data.attributes = rest; + } + messages.push(data); } }); + // omit the resource's format when it matches the project's format + const attributes: MsgAttributes = { ...this.attributes }; + if (this.resolveFormat(attributes) === projectFormat) { + delete attributes.format; + } + return { title: this.title, - attributes: this.attributes, + attributes, notes: !stripNotes && this.notes.length > 0 ? this.notes : undefined, messages } From 3758bc536a0edb1afc4ed3d1a1b5f78476c614a2 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 14:43:14 -0600 Subject: [PATCH 5/8] optimize: preserve format through resource translation translate() now carries the source resource's and each source message's format into the translated copy unless the translation explicitly sets its own, so MF1/NONE settings survive translation. Refs #33 Co-authored-by: Cursor --- src/classes/MsgResource/MsgResource.ts | 24 ++++++++++- src/tests/MsgFormat.test.ts | 59 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/classes/MsgResource/MsgResource.ts b/src/classes/MsgResource/MsgResource.ts index 03c9335..24e88b8 100644 --- a/src/classes/MsgResource/MsgResource.ts +++ b/src/classes/MsgResource/MsgResource.ts @@ -137,7 +137,8 @@ export class MsgResource extends Map implements MsgInterface const translated = MsgResource.create({ title, - attributes, + // preserve the source resource's format unless the translation overrides it + attributes: this.preserveFormat(attributes, this.attributes.format), notes: this.notes, // transfer the notes }, this._project); @@ -151,7 +152,8 @@ export class MsgResource extends Map implements MsgInterface const msg = MsgMessage.create({ key, value, - attributes, + // preserve the source message's format unless the translation overrides it + attributes: this.preserveFormat(attributes, this.get(key)?.attributes.format), }); const notes = this.get(key)?.notes || []; // transfer the notes notes.forEach(note => { @@ -163,6 +165,24 @@ export class MsgResource extends Map implements MsgInterface return translated; } + /** + * Merge a `format` into an attribute set unless one is already present, + * so translations inherit the source's format without overriding an + * explicitly translated one. Returns the input unchanged when there is + * no format to preserve. + */ + private preserveFormat(attributes: MsgAttributes, format: MsgAttributes['format']): MsgAttributes; + private preserveFormat(attributes: MsgAttributes | undefined, format: MsgAttributes['format']): MsgAttributes | undefined; + private preserveFormat( + attributes: MsgAttributes | undefined, + format: MsgAttributes['format'] + ): MsgAttributes | undefined { + if (format === undefined || attributes?.format !== undefined) { + return attributes; + } + return { ...attributes, format }; + } + public async getTranslation(lang: string) { const project = this._project; diff --git a/src/tests/MsgFormat.test.ts b/src/tests/MsgFormat.test.ts index fa6961d..fe73b27 100644 --- a/src/tests/MsgFormat.test.ts +++ b/src/tests/MsgFormat.test.ts @@ -224,3 +224,62 @@ describe('MsgFormat: serialization', () => { expect(msgData.attributes).toBeUndefined(); }); }); + +describe('MsgFormat: translation preserves format', () => { + test('translated resource keeps the source resource format', () => { + const project = MsgProject.create(makeProjectData('MF2')); + const source = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr', format: 'MF1' } + }, project); + + const translated = source.translate({ + title: 'R', + attributes: { lang: 'zh', dir: 'ltr' } + }); + + expect(translated.attributes.format).toBe('MF1'); + }); + + test('translated message keeps the source message format', () => { + const project = MsgProject.create(makeProjectData('MF1')); + const source = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr' }, + messages: [ + { key: 'test-1', value: '{count, plural, one {# file} other {# files}}' } + ] + }, project); + + const translated = source.translate({ + title: 'R', + attributes: { lang: 'zh', dir: 'ltr' }, + messages: [ + { key: 'test-1', value: '{count, plural, one {# 个文件} other {# 个文件}}' } + ] + }); + + expect(translated.get('test-1')?.attributes.format).toBe('MF1'); + }); + + test('an explicit translation format overrides the preserved format', () => { + const project = MsgProject.create(makeProjectData('MF1')); + const source = MsgResource.create({ + title: 'R', + attributes: { lang: 'en', dir: 'ltr' }, + messages: [ + { key: 'test-1', value: 'raw' } + ] + }, project); + + const translated = source.translate({ + title: 'R', + attributes: { lang: 'zh', dir: 'ltr' }, + messages: [ + { key: 'test-1', value: 'raw', attributes: { format: 'NONE' } } + ] + }); + + expect(translated.get('test-1')?.attributes.format).toBe('NONE'); + }); +}); From 189e976da5bad9fef913081029a4ce5c9f330a40 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 14:44:40 -0600 Subject: [PATCH 6/8] validate: add mixed-format integration tests, reach 100% coverage End-to-end coverage of MF1/MF2/NONE formatting, format-aware serialization with round-trip, and translate preservation across a single project, plus the default-fallback branches. Refs #33 Co-authored-by: Cursor --- src/tests/format.integration.test.ts | 139 +++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/tests/format.integration.test.ts diff --git a/src/tests/format.integration.test.ts b/src/tests/format.integration.test.ts new file mode 100644 index 0000000..e2fecdf --- /dev/null +++ b/src/tests/format.integration.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'vitest'; +import { MsgProject, MsgProjectData } from '../classes/MsgProject/MsgProject.js'; +import { MsgResource, MsgResourceData } from '../classes/MsgResource/MsgResource.js'; +import { MsgMessage } from '../classes/MsgMessage/MsgMessage.js'; + +const projectData = (format?: 'MF1' | 'MF2' | 'NONE'): MsgProjectData => ({ + project: { name: 'app', version: 1, ...(format ? { format } : {}) }, + locales: { + sourceLocale: 'en', + pseudoLocale: 'en-XA', + targetLocales: { en: ['en'], zh: ['zh'] } + }, + loader: async () => ({ title: 'T', attributes: { lang: 'en', dir: 'ltr' }, messages: [] }) +}); + +describe('format integration: mixed MF1/MF2/NONE within one project', () => { + test('formats each message according to its resolved format', () => { + // Project defaults to MF1; resource inherits it. + const project = MsgProject.create(projectData('MF1')); + const resource = MsgResource.create({ + title: 'Home', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + + resource.add('files', '{count, plural, one {# file} other {# files}}'); // inherits MF1 + resource.add('raw', 'literal {value}', { format: 'NONE' }); // NONE override + resource.add('greeting', 'Hello!', { format: 'MF2' }); // MF2 override + + expect(resource.get('files')?.format({ count: 2 })).toBe('2 files'); + expect(resource.get('raw')?.format({ value: 'x' })).toBe('literal {value}'); + expect(resource.get('greeting')?.format({})).toBe('Hello!'); + }); + + test('serializes with inherited formats omitted and overrides retained, and round-trips', () => { + const project = MsgProject.create(projectData('MF1')); + const resource = MsgResource.create({ + title: 'Home', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + + resource.add('files', '{count, plural, one {# file} other {# files}}'); // inherits MF1 + resource.add('raw', 'literal', { format: 'NONE' }); // NONE override + + const data = resource.getData(); + + // Resource format equals the project's -> omitted. + expect(data.attributes.format).toBeUndefined(); + + const files = data.messages!.find(m => m.key === 'files')!; + const raw = data.messages!.find(m => m.key === 'raw')!; + // 'files' inherits the resource format -> attributes omitted entirely. + expect(files.attributes).toBeUndefined(); + // 'raw' differs -> format retained. + expect(raw.attributes?.format).toBe('NONE'); + + // Round-trip: rebuild from serialized data under the same project. + const rebuilt = MsgResource.create(data as MsgResourceData, project); + expect(rebuilt.get('files')?.format({ count: 1 })).toBe('1 file'); + expect(rebuilt.get('raw')?.format({})).toBe('literal'); + }); + + test('MF2 remains the default so existing behavior is unchanged', () => { + const project = MsgProject.create(projectData()); // no format -> MF2 + const resource = MsgResource.create({ + title: 'Home', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + resource.add('m', '.input {$n :number}\n .match $n\n one {{1 item}} *{{{$n} items}}'); + + expect(resource.get('m')?.attributes.format).toBe('MF2'); + expect(resource.get('m')?.format({ n: 4 })).toBe('4 items'); + }); + + test('format survives an end-to-end translate round-trip', () => { + const project = MsgProject.create(projectData('MF1')); + const source = MsgResource.create({ + title: 'Home', + attributes: { lang: 'en', dir: 'ltr' }, + messages: [ + { key: 'files', value: '{count, plural, one {# file} other {# files}}' } + ] + }, project); + + const translated = source.translate({ + title: 'Home', + attributes: { lang: 'zh', dir: 'ltr' }, + messages: [ + { key: 'files', value: '{count, plural, other {# 个文件}}' } + ] + }); + + expect(translated.get('files')?.attributes.format).toBe('MF1'); + // Still formatted as MF1 (the plural resolves); bidi isolation chars may + // wrap the number since the translated message carries no explicit lang. + const formatted = translated.get('files')?.format({ count: 3 }); + expect(formatted).toContain('3'); + expect(formatted).toContain('个文件'); + }); +}); + +describe('format coverage: default fallbacks', () => { + test('project format falls back to MF2 when explicitly undefined', () => { + const data = projectData(); + data.project.format = undefined; + const project = MsgProject.create(data); + expect(project.format).toBe('MF2'); + }); + + test('a message without a format attribute resolves to MF2 on serialization', () => { + const project = MsgProject.create(projectData()); + const resource = MsgResource.create({ + title: 'Home', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + + // Insert a message that carries no format attribute at all. + const bare = MsgMessage.create({ key: 'bare', value: 'x', attributes: { lang: 'en', dir: 'ltr', dnt: false } }); + resource.set('bare', bare); + + const data = resource.getData(); + const bareData = data.messages!.find(m => m.key === 'bare')!; + // resolves to MF2 (resource default) -> attributes omitted as fully matching. + expect(bareData.attributes).toBeUndefined(); + }); + + test('a matching message with notes retains its notes on serialization', () => { + const project = MsgProject.create(projectData()); + const resource = MsgResource.create({ + title: 'Home', + attributes: { lang: 'en', dir: 'ltr' } + }, project); + resource.add('m', 'value', undefined, [{ type: 'DESCRIPTION', content: 'note' }]); + + const data = resource.getData(); + const msgData = data.messages!.find(m => m.key === 'm')!; + expect(msgData.attributes).toBeUndefined(); + expect(msgData.notes).toStrictEqual([{ type: 'DESCRIPTION', content: 'note' }]); + }); +}); From 5a85919c281c3c8024a99274005bbd0c7533f7e7 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 14:46:03 -0600 Subject: [PATCH 7/8] ci: run type-check, tests, and build on pull requests Add a CI workflow triggered on pull_request and pushes to main so the Definition-of-done checks gate every PR (there was previously only a release-triggered publish workflow). Refs #33 Co-authored-by: Cursor --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..67ae094 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +# Runs the Definition-of-done checks on pull requests and pushes to main +# so every PR has green CI (tests, type-check, and build) before review. + +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npx tsc --noEmit + - run: npm test + - run: npm run build From ce112a26c7fe81072ccc5497e3de3aa646d63e86 Mon Sep 17 00:00:00 2001 From: Joel Sahleen Date: Sun, 19 Jul 2026 14:49:58 -0600 Subject: [PATCH 8/8] document: document the format attribute and MF1 support Update README for the inheritable format attribute (MF1/MF2/NONE), add a GETTING_STARTED guide, export the shared types (MsgFormat, MsgAttributes, ...) from the package root, and add a typedoc `docs` script. Generated docs/ output is gitignored like dist/coverage. Refs #33 Co-authored-by: Cursor --- .gitignore | 3 + GETTING_STARTED.md | 98 ++++++++++++++++ README.md | 64 +++++++++-- package-lock.json | 259 +++++++++++++++++++++++++++++++++++++++++++ package.json | 4 +- src/classes/index.ts | 1 + 6 files changed, 418 insertions(+), 11 deletions(-) create mode 100644 GETTING_STARTED.md diff --git a/.gitignore b/.gitignore index 9a5aced..02893e3 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,9 @@ out .nuxt dist +# Generated API documentation (regenerate with `npm run docs`) +docs + # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md new file mode 100644 index 0000000..0a79a58 --- /dev/null +++ b/GETTING_STARTED.md @@ -0,0 +1,98 @@ +# Getting Started with `@worldware/msg` + +This guide walks you from installation to formatting your first messages, +including choosing between MessageFormat 2 (MF2), MessageFormat 1 (MF1), and +unformatted (`NONE`) strings. For the full API, see [`README.md`](./README.md). + +## 1. Install + +```bash +npm install @worldware/msg +``` + +The MF1 and MF2 formatters (`messageformat` and +`@messageformat/icu-messageformat-1`) are bundled as dependencies — you do not +need to install them separately. + +## 2. Create a project + +A project holds shared configuration: locales, a translation loader, and the +default message `format` that resources and messages inherit. + +```typescript +import { MsgProject } from '@worldware/msg'; + +const loader = async (project, title, language) => { + const path = `../l10n/translations/${project}/${language}/${title}.json`; + const module = await import(path, { with: { type: 'json' } }); + return module.default; +}; + +const project = MsgProject.create({ + project: { name: 'my-app', version: 1 }, // format defaults to 'MF2' + locales: { + sourceLocale: 'en', + pseudoLocale: 'en-XA', + targetLocales: { en: ['en'], es: ['es'] } + }, + loader +}); +``` + +## 3. Create a resource and add messages + +A resource is a keyed collection of messages. It inherits the project's +`format` unless you set your own on the resource or a message. + +```typescript +import { MsgResource } from '@worldware/msg'; + +const resource = MsgResource.create({ + title: 'CommonMessages', + attributes: { lang: 'en', dir: 'ltr' }, // inherits format: 'MF2' + messages: [ + { key: 'greeting', value: 'Hello, {$name}!' } + ] +}, project); + +resource.add('itemCount', 'You have {$count} items'); +``` + +## 4. Format messages + +```typescript +resource.get('greeting')?.format({ name: 'Alice' }); // "Hello, Alice!" +``` + +## 5. Choose a format + +Set `format` to `'MF1'`, `'MF2'`, or `'NONE'` on a project, resource, or +message. It is a TypeScript union type (there is no enum), and lower levels +inherit from higher levels unless they override it. + +```typescript +// MF1 syntax (ICU MessageFormat 1) +resource.add('files', '{count, plural, one {# file} other {# files}}', { format: 'MF1' }); +resource.get('files')?.format({ count: 3 }); // "3 files" + +// NONE — returned verbatim, no interpolation +resource.add('token', 'build:{sha}', { format: 'NONE' }); +resource.get('token')?.format({ sha: 'abc' }); // "build:{sha}" +``` + +Because the default is `MF2`, existing MF2 code keeps working with no changes. + +## 6. Load translations + +```typescript +const spanish = await resource.getTranslation('es'); +``` + +Translations preserve each message's `format`, so an MF1 or `NONE` message stays +MF1 or `NONE` after translation unless the translation overrides it. + +## Next steps + +- Read [`README.md`](./README.md) for the complete API, serialization rules, + language fallback chains, and pseudo-localization. +- Generate browsable API docs locally with `npm run docs` (output in `docs/`). diff --git a/README.md b/README.md index 8250edd..ef3c1ae 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ A TypeScript library for managing internationalization (i18n) messages with supp ## Overview -`msg` provides a structured approach to managing translatable messages in your application. It integrates with [MessageFormat 2](https://messageformat.unicode.org/) (MF2) for advanced message formatting and supports: +`msg` provides a structured approach to managing translatable messages in your application. It integrates with [MessageFormat 2](https://messageformat.unicode.org/) (MF2) and [ICU MessageFormat 1](https://messageformat.github.io/) (MF1) for message formatting and supports: - **Message Management**: Organize messages into resources with keys and values - **Translation Loading**: Load translations from external sources via customizable loaders - **Pseudo Localization**: Request a pseudolocalized resource for UI testing via `getTranslation(pseudoLocale)` -- **Message Formatting**: Format messages with parameters using MessageFormat 2 (MF2) syntax +- **Message Formatting**: Format messages with parameters using MessageFormat 2 (MF2) or MessageFormat 1 (MF1) syntax, or pass strings through unformatted +- **Configurable Format**: Choose `MF1`, `MF2`, or `NONE` per project, resource, or message via an inheritable `format` attribute (defaults to `MF2`) - **Attributes & Notes**: Attach metadata (language, direction, do-not-translate flags) and notes to messages - **Project Configuration**: Configure projects with locale settings and translation loaders @@ -25,6 +26,7 @@ npm install @worldware/msg A project configuration that defines: - Project name and version +- The default message `format` (`MF1` | `MF2` | `NONE`, defaults to `MF2`) inherited by resources and messages - Source and target locales (with language fallback chains) - Pseudo locale (for pseudolocalized output via `getTranslation`) - A translation loader function @@ -41,10 +43,10 @@ A collection of messages (extends `Map`) representing a reso An individual message with: - A key (identifier) -- A value (the message text, supports MessageFormat 2 (MF2) syntax) -- Attributes (lang, dir, dnt) +- A value (the message text, in MF2, MF1, or plain syntax depending on its `format`) +- Attributes (lang, dir, dnt, format) - Notes -- Formatting methods using MessageFormat 2 +- Formatting methods that honor the resolved `format` (MF2, MF1, or NONE) ## Usage @@ -128,6 +130,42 @@ const formatted = greetingMsg?.format({ name: 'Alice' }); // Result: "Hello, Alice!" ``` +### Message Formats (MF1, MF2, NONE) + +Every message is formatted according to its resolved `format` attribute: + +- `MF2` (default) — [Unicode MessageFormat 2](https://messageformat.unicode.org/) syntax, e.g. `Hello, {$name}!`. +- `MF1` — [ICU MessageFormat 1](https://messageformat.github.io/) syntax, e.g. `{count, plural, one {# file} other {# files}}`, formatted via [`@messageformat/icu-messageformat-1`](https://www.npmjs.com/package/@messageformat/icu-messageformat-1). +- `NONE` — the value is returned verbatim, with no parsing or interpolation. + +The `format` is inheritable: a resource inherits its project's `format` unless it sets its own, and a message inherits its resource's `format` unless it sets its own. The default is `MF2`, so existing code keeps working unchanged. Use a TypeScript union (`'MF1' | 'MF2' | 'NONE'`) — there is no enum. + +```typescript +import { MsgProject, MsgResource } from '@worldware/msg'; + +// A project whose messages are MF1 by default +const project = MsgProject.create({ + project: { name: 'legacy-app', version: 1, format: 'MF1' }, + locales: { sourceLocale: 'en', pseudoLocale: 'en-XA', targetLocales: { en: ['en'] } }, + loader +}); + +const resource = MsgResource.create({ + title: 'Files', + attributes: { lang: 'en', dir: 'ltr' } // inherits format: 'MF1' from the project +}, project); + +resource.add('files', '{count, plural, one {# file} other {# files}}'); // MF1 (inherited) +resource.add('brand', 'msg {version}', { format: 'NONE' }); // passed through +resource.add('hi', 'Hello, {$name}!', { format: 'MF2' }); // MF2 (override) + +resource.get('files')?.format({ count: 2 }); // "2 files" +resource.get('brand')?.format({ version: 1 }); // "msg {version}" +resource.get('hi')?.format({ name: 'Ada' }); // "Hello, Ada!" +``` + +When serializing, an inherited `format` is omitted to keep output compact: a resource omits `format` when it equals the project's, and a message omits `format` when it equals its resource's. + ### Loading Translations ```typescript @@ -214,9 +252,10 @@ const data = resource.getData(); - `create(data: MsgProjectData): MsgProject` - Create a new project instance **Properties:** -- `project: MsgProjectSettings` - Project name and version +- `project: MsgProjectSettings` - Project name, version, and default `format` - `locales: MsgLocalesSettings` - Locale configuration - `loader: MsgTranslationLoader` - Translation loader function +- `format: MsgFormat` - The project-wide default format (`'MF1' | 'MF2' | 'NONE'`), defaulting to `'MF2'`; resources (and, through them, messages) inherit this value unless they specify their own **Methods:** - `getTargetLocale(locale: string): string[] | undefined` - Returns the language fallback chain (array of locale codes) for the specified locale, or `undefined` if the locale is not configured in `targetLocales` @@ -231,7 +270,7 @@ const data = resource.getData(); - `translate(data: MsgResourceData): MsgResource` - Create a translated version - `getTranslation(lang: string): Promise` - Load and apply translations. When `lang` matches the project's `pseudoLocale`, returns a resource with pseudolocalized message values instead of loading from the loader. - `getProject(): MsgProject` - Returns the project instance associated with the resource -- `getData(stripNotes?: boolean): MsgResourceData` - Get resource data. Message objects in the output omit `attributes` when they match the resource's attributes (to avoid redundancy) +- `getData(stripNotes?: boolean): MsgResourceData` - Get resource data. Message objects in the output omit `attributes` when they match the resource's attributes (to avoid redundancy). The resource's `format` is omitted when it equals the project's, and a message's `format` is omitted when it equals the resource's - `toJSON(stripNotes?: boolean): string` - Serialize to JSON **Properties:** @@ -245,8 +284,8 @@ const data = resource.getData(); - `create(data: MsgMessageData): MsgMessage` - Create a new message **Methods:** -- `format(data: Record, options?: MessageFormatOptions): string` - Format the message -- `formatToParts(data: Record, options?: MessageFormatOptions): MessagePart[]` - Format to parts +- `format(data: Record, options?: MessageFormatOptions): string` - Format the message according to its resolved `format`: `MF2` uses MessageFormat 2, `MF1` compiles via `@messageformat/icu-messageformat-1`, and `NONE` returns the raw value +- `formatToParts(data: Record, options?: MessageFormatOptions): MessagePart[]` - Format to parts (for `NONE`, a single `{ type: 'text', value }` part) - `addNote(note: MsgNote): void` - Add a note - `getData(stripNotes?: boolean): MsgMessageData` - Get message data - `toJSON(stripNotes?: boolean): string` - Serialize to JSON @@ -254,9 +293,14 @@ const data = resource.getData(); **Properties:** - `key: string` - Message key - `value: string` - Message value -- `attributes: MsgAttributes` - Message attributes (lang, dir, dnt) +- `attributes: MsgAttributes` - Message attributes (lang, dir, dnt, format) - `notes: MsgNote[]` - Message notes +### Types + +- `MsgFormat` - `'MF1' | 'MF2' | 'NONE'`; the formatting syntax for a message. +- `MsgAttributes` - `{ lang?: string; dir?: string; dnt?: boolean; format?: MsgFormat }`. + ## Development ```bash diff --git a/package-lock.json b/package-lock.json index 9a0eb22..95b5813 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/typescript": "^0.4.29", "@vitest/coverage-v8": "^4.0.15", "tsup": "^8.5.1", + "typedoc": "^0.28.20", "typescript": "^5.9.3", "vitest": "^4.0.15" } @@ -523,6 +524,20 @@ "node": ">=18" } }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -906,6 +921,55 @@ "win32" ] }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -938,6 +1002,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/typescript": { "version": "0.4.29", "resolved": "https://registry.npmjs.org/@types/typescript/-/typescript-0.4.29.tgz", @@ -945,6 +1019,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitest/coverage-v8": { "version": "4.0.15", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.15.tgz", @@ -1132,6 +1213,13 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1154,6 +1242,29 @@ "js-tokens": "^9.0.1" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1289,6 +1400,19 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1556,6 +1680,26 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", @@ -1566,6 +1710,13 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1604,6 +1755,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, "node_modules/messageformat": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/messageformat/-/messageformat-4.0.0.tgz", @@ -1613,6 +1799,22 @@ "node": "^20.19 || ^22.12 || >=24" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -1840,6 +2042,16 @@ "node": ">=4.2.0" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2659,6 +2871,30 @@ "dev": true, "license": "MIT" }, + "node_modules/typedoc": { + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.3.0", + "minimatch": "^10.2.5", + "yaml": "^2.9.0" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2673,6 +2909,13 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", @@ -2876,6 +3119,22 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 07519e2..020481b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@types/typescript": "^0.4.29", "@vitest/coverage-v8": "^4.0.15", "tsup": "^8.5.1", + "typedoc": "^0.28.20", "typescript": "^5.9.3", "vitest": "^4.0.15" }, @@ -46,6 +47,7 @@ "build": "tsup", "test": "vitest run", "test:watch": "vitest", - "coverage": "vitest run --coverage" + "coverage": "vitest run --coverage", + "docs": "typedoc src/index.ts" } } diff --git a/src/classes/index.ts b/src/classes/index.ts index c7da807..73076eb 100644 --- a/src/classes/index.ts +++ b/src/classes/index.ts @@ -1,3 +1,4 @@ +export * from './MsgInterface/MsgInterface.js'; export * from './MsgMessage/MsgMessage.js'; export * from './MsgResource/MsgResource.js'; export * from './MsgProject/MsgProject.js';