diff --git a/experiments/stasm/third/README.md b/experiments/stasm/third/README.md index 8646c241d..d57489a9e 100644 --- a/experiments/stasm/third/README.md +++ b/experiments/stasm/third/README.md @@ -73,6 +73,7 @@ Run each example individually: npm run example phrases npm run example list npm run example number + npm run example opaque ## References diff --git a/experiments/stasm/third/example/example_glossary.ts b/experiments/stasm/third/example/example_glossary.ts index 59cd36efc..8fb20b3cc 100644 --- a/experiments/stasm/third/example/example_glossary.ts +++ b/experiments/stasm/third/example/example_glossary.ts @@ -1,6 +1,6 @@ import {Argument, Message, Parameter} from "../impl/model.js"; import {REGISTRY} from "../impl/registry.js"; -import {formatMessage, FormattingContext, StringValue} from "../impl/runtime.js"; +import {formatMessage, FormattingContext, formatToParts, StringValue} from "../impl/runtime.js"; import {get_term} from "./glossary.js"; REGISTRY["NOUN"] = function get_noun( @@ -140,6 +140,15 @@ console.log("==== English ===="); color: new StringValue("red"), }) ); + + console.log( + Array.of( + ...formatToParts(message, { + item: new StringValue("t-shirt"), + color: new StringValue("red"), + }) + ) + ); } { diff --git a/experiments/stasm/third/example/example_list.ts b/experiments/stasm/third/example/example_list.ts index 99dbd2d5d..c760b44d9 100644 --- a/experiments/stasm/third/example/example_list.ts +++ b/experiments/stasm/third/example/example_list.ts @@ -2,7 +2,9 @@ import {Argument, Message, Parameter} from "../impl/model.js"; import {REGISTRY} from "../impl/registry.js"; import { formatMessage, + FormattedPart, FormattingContext, + formatToParts, PluralValue, RuntimeValue, StringValue, @@ -18,9 +20,24 @@ class Person { } } -class PeopleValue extends RuntimeValue> { - format(ctx: FormattingContext): string { - throw new RangeError("Must be formatted via PEOPLE_LIST."); +// TODO(stasm): This is generic enough that it could be in impl/runtime.ts. +class ListValue extends RuntimeValue> { + private opts: Intl.ListFormatOptions; + + constructor(value: Array, opts: Intl.ListFormatOptions = {}) { + super(value); + this.opts = opts; + } + + formatToString(ctx: FormattingContext): string { + // TODO(stasm): Cache ListFormat. + let lf = new Intl.ListFormat(ctx.locale, this.opts); + return lf.format(this.value); + } + + *formatToParts(ctx: FormattingContext): IterableIterator { + let lf = new Intl.ListFormat(ctx.locale, this.opts); + yield* lf.formatToParts(this.value); } } @@ -30,7 +47,7 @@ REGISTRY["PLURAL_LEN"] = function ( opts: Record ): PluralValue { let elements = ctx.toRuntimeValue(args[0]); - if (!(elements instanceof PeopleValue)) { + if (!(elements instanceof ListValue)) { throw new TypeError(); } @@ -41,13 +58,13 @@ REGISTRY["PEOPLE_LIST"] = function ( ctx: FormattingContext, args: Array, opts: Record -): StringValue { +): ListValue { if (ctx.locale !== "ro") { throw new Error("Only Romanian supported"); } let elements = ctx.toRuntimeValue(args[0]); - if (!(elements instanceof PeopleValue)) { + if (!(elements instanceof ListValue)) { throw new TypeError(); } @@ -70,14 +87,21 @@ REGISTRY["PEOPLE_LIST"] = function ( break; } - // @ts-ignore - let lf = new Intl.ListFormat(ctx.locale, { - // TODO(stasm): Type-check these. + let list_style = ctx.toRuntimeValue(opts["STYLE"]); + if (!(list_style instanceof StringValue)) { + throw new TypeError(); + } + + let list_type = ctx.toRuntimeValue(opts["TYPE"]); + if (!(list_type instanceof StringValue)) { + throw new TypeError(); + } + + return new ListValue(names, { // TODO(stasm): Add default options. - style: ctx.toRuntimeValue(opts["STYLE"]).value, - type: ctx.toRuntimeValue(opts["TYPE"]).value, + style: list_style.value, + type: list_type.value, }); - return new StringValue(lf.format(names)); function decline(name: string): string { let declension = ctx.toRuntimeValue(opts["CASE"]); @@ -166,13 +190,24 @@ console.log("==== Romanian ===="); }; console.log( formatMessage(message, { - names: new PeopleValue([ + names: new ListValue([ new Person("Maria", "Stanescu"), new Person("Ileana", "Zamfir"), new Person("Petre", "Belu"), ]), }) ); + console.log( + Array.of( + ...formatToParts(message, { + names: new ListValue([ + new Person("Maria", "Stanescu"), + new Person("Ileana", "Zamfir"), + new Person("Petre", "Belu"), + ]), + }) + ) + ); } { @@ -235,7 +270,7 @@ console.log("==== Romanian ===="); }; console.log( formatMessage(message, { - names: new PeopleValue([ + names: new ListValue([ new Person("Maria", "Stanescu"), new Person("Ileana", "Zamfir"), new Person("Petre", "Belu"), diff --git a/experiments/stasm/third/example/example_number.ts b/experiments/stasm/third/example/example_number.ts index b242e74e3..7810bc7bd 100644 --- a/experiments/stasm/third/example/example_number.ts +++ b/experiments/stasm/third/example/example_number.ts @@ -1,5 +1,5 @@ import {Message} from "../impl/model.js"; -import {formatMessage, NumberValue} from "../impl/runtime.js"; +import {formatMessage, formatToParts, NumberValue} from "../impl/runtime.js"; console.log("==== English ===="); @@ -78,4 +78,11 @@ console.log("==== French ===="); payloadSize: new NumberValue(1.23), }) ); + console.log( + Array.of( + ...formatToParts(message, { + payloadSize: new NumberValue(1.23), + }) + ) + ); } diff --git a/experiments/stasm/third/example/example_opaque.ts b/experiments/stasm/third/example/example_opaque.ts new file mode 100644 index 000000000..505d5d4fb --- /dev/null +++ b/experiments/stasm/third/example/example_opaque.ts @@ -0,0 +1,53 @@ +import {Message} from "../impl/model.js"; +import {FormattingContext, formatToParts, OpaquePart, RuntimeValue} from "../impl/runtime.js"; + +// We want to pass it into the translation and get it back out unformatted, in +// the correct position in the sentence, via formatToParts. +class SomeUnstringifiableClass {} + +// TODO(stasm): This is generic enough that it could be in impl/runtime.ts. +class WrappedValue extends RuntimeValue { + formatToString(ctx: FormattingContext): string { + throw new Error("Method not implemented."); + } + *formatToParts(ctx: FormattingContext): IterableIterator { + yield {type: "opaque", value: this.value}; + } +} + +console.log("==== English ===="); + +{ + // "Ready? Then {$submitButton}!" + let message: Message = { + lang: "en", + id: "submit", + phrases: {}, + selectors: [ + { + expr: null, + default: {type: "StringLiteral", value: "default"}, + }, + ], + variants: [ + { + keys: [{type: "StringLiteral", value: "default"}], + value: [ + {type: "StringLiteral", value: "Ready? Then "}, + { + type: "VariableReference", + name: "submitButton", + }, + {type: "StringLiteral", value: "!"}, + ], + }, + ], + }; + console.log( + Array.of( + ...formatToParts(message, { + submitButton: new WrappedValue(new SomeUnstringifiableClass()), + }) + ) + ); +} diff --git a/experiments/stasm/third/example/example_phrases.ts b/experiments/stasm/third/example/example_phrases.ts index 82c5c5214..662629ca7 100644 --- a/experiments/stasm/third/example/example_phrases.ts +++ b/experiments/stasm/third/example/example_phrases.ts @@ -1,5 +1,5 @@ import {Message} from "../impl/model.js"; -import {formatMessage, NumberValue, StringValue} from "../impl/runtime.js"; +import {formatMessage, formatToParts, NumberValue, StringValue} from "../impl/runtime.js"; console.log("==== English ===="); @@ -90,6 +90,16 @@ console.log("==== English ===="); photoCount: new NumberValue(34), }) ); + + console.log( + Array.of( + ...formatToParts(message, { + userName: new StringValue("Mary"), + userGender: new StringValue("feminine"), + photoCount: new NumberValue(34), + }) + ) + ); } console.log("==== polski ===="); diff --git a/experiments/stasm/third/impl/intl.d.ts b/experiments/stasm/third/impl/intl.d.ts new file mode 100644 index 000000000..ed0c4a7a8 --- /dev/null +++ b/experiments/stasm/third/impl/intl.d.ts @@ -0,0 +1,25 @@ +declare namespace Intl { + interface ListFormatOptions { + // I added `string` to avoid having to validate the exact values of options. + localeMatcher?: string | "best fit" | "lookup"; + type?: string | "conjunction" | "disjunction | unit"; + style?: string | "long" | "short" | "narrow"; + } + + type ListFormatPartTypes = "literal" | "element"; + + interface ListFormatPart { + type: ListFormatPartTypes; + value: string; + } + + interface ListFormat { + format(value?: Array): string; + formatToParts(value?: Array): ListFormatPart[]; + } + + var ListFormat: { + new (locales?: string | string[], options?: ListFormatOptions): ListFormat; + (locales?: string | string[], options?: ListFormatOptions): ListFormat; + }; +} diff --git a/experiments/stasm/third/impl/registry.ts b/experiments/stasm/third/impl/registry.ts index 7ccd60b21..962cd1633 100644 --- a/experiments/stasm/third/impl/registry.ts +++ b/experiments/stasm/third/impl/registry.ts @@ -1,5 +1,12 @@ import {Argument, Parameter} from "./model.js"; -import {FormattingContext, NumberValue, PluralValue, RuntimeValue, StringValue} from "./runtime.js"; +import { + FormattingContext, + NumberValue, + PatternValue, + PluralValue, + RuntimeValue, + StringValue, +} from "./runtime.js"; export type RegistryFunc = ( ctx: FormattingContext, @@ -31,7 +38,7 @@ function get_phrase( ctx: FormattingContext, args: Array, opts: Record -): StringValue { +): PatternValue { let phrase_name = ctx.toRuntimeValue(args[0]); if (!(phrase_name instanceof StringValue)) { throw new TypeError(); @@ -39,7 +46,7 @@ function get_phrase( let phrase = ctx.message.phrases[phrase_name.value]; let variant = ctx.selectVariant(phrase.variants, phrase.selectors); - return new StringValue(ctx.formatPattern(variant.value)); + return new PatternValue(variant.value); } function format_number( diff --git a/experiments/stasm/third/impl/runtime.ts b/experiments/stasm/third/impl/runtime.ts index 5c8d5ad87..d1e9ac64b 100644 --- a/experiments/stasm/third/impl/runtime.ts +++ b/experiments/stasm/third/impl/runtime.ts @@ -1,14 +1,16 @@ -import { - Message, - Parameter, - PatternElement, - Phrase, - Selector, - StringLiteral, - Variant, -} from "./model.js"; +import {Message, Parameter, PatternElement, Selector, StringLiteral, Variant} from "./model.js"; import {REGISTRY} from "./registry.js"; +export interface FormattedPart { + type: string; + value: string; +} + +export interface OpaquePart { + type: "opaque"; + value: unknown; +} + // A value passed in as a variable to format() or to which literals are resolved // at runtime. There are 4 built-in runtime value types in this implementation: // StringValue, NumberValue, PluralValue, and BooleanValue. Other @@ -22,13 +24,18 @@ export abstract class RuntimeValue { this.value = value; } - abstract format(ctx: FormattingContext): string; + abstract formatToString(ctx: FormattingContext): string; + abstract formatToParts(ctx: FormattingContext): IterableIterator; } export class StringValue extends RuntimeValue { - format(ctx: FormattingContext): string { + formatToString(ctx: FormattingContext): string { return this.value; } + + *formatToParts(ctx: FormattingContext): IterableIterator { + yield {type: "literal", value: this.value}; + } } export class NumberValue extends RuntimeValue { @@ -39,10 +46,14 @@ export class NumberValue extends RuntimeValue { this.opts = opts; } - format(ctx: FormattingContext): string { + formatToString(ctx: FormattingContext): string { // TODO(stasm): Cache NumberFormat. return new Intl.NumberFormat(ctx.locale, this.opts).format(this.value); } + + *formatToParts(ctx: FormattingContext): IterableIterator { + yield* new Intl.NumberFormat(ctx.locale, this.opts).formatToParts(this.value); + } } export class PluralValue extends RuntimeValue { @@ -53,17 +64,37 @@ export class PluralValue extends RuntimeValue { this.opts = opts; } - format(ctx: FormattingContext): string { + formatToString(ctx: FormattingContext): string { // TODO(stasm): Cache PluralRules. let pr = new Intl.PluralRules(ctx.locale, this.opts); return pr.select(this.value); } + + *formatToParts(ctx: FormattingContext): IterableIterator { + throw new TypeError("Pluralvalue is not formattable to parts."); + } } export class BooleanValue extends RuntimeValue { - format(ctx: FormattingContext): string { + formatToString(ctx: FormattingContext): string { throw new TypeError("BooleanValue is not formattable."); } + + *formatToParts(ctx: FormattingContext): IterableIterator { + throw new TypeError("BooleanValue is not formattable to parts."); + } +} + +export class PatternValue extends RuntimeValue> { + formatToString(ctx: FormattingContext): string { + return ctx.formatPattern(this.value); + } + + *formatToParts(ctx: FormattingContext): IterableIterator { + for (let value of ctx.resolvePattern(this.value)) { + yield* value.formatToParts(ctx); + } + } } // Resolution context for a single formatMessage() call. @@ -81,12 +112,15 @@ export class FormattingContext { this.visited = new WeakSet(); } - formatPhrase(phrase: Phrase): string { - let variant = this.selectVariant(phrase.variants, phrase.selectors); - return this.formatPattern(variant.value); + formatPattern(pattern: Array): string { + let output = ""; + for (let value of this.resolvePattern(pattern)) { + output += value.formatToString(this); + } + return output; } - formatPattern(pattern: Array): string { + *resolvePattern(pattern: Array): IterableIterator> { if (this.visited.has(pattern)) { throw new RangeError("Recursive reference to a variant value."); } @@ -96,17 +130,15 @@ export class FormattingContext { for (let element of pattern) { switch (element.type) { case "StringLiteral": - result += element.value; + yield new StringValue(element.value); continue; case "VariableReference": { - let value = this.vars[element.name]; - result += value.format(this); + yield this.vars[element.name]; continue; } case "FunctionCall": { let callable = REGISTRY[element.name]; - let value = callable(this, element.args, element.opts); - result += value.format(this); + yield callable(this, element.args, element.opts); continue; } } @@ -141,7 +173,7 @@ export class FormattingContext { let value = this.vars[selector.expr.name]; resolved_selectors.push({ value: value.value, - string: value.format(this), + string: value.formatToString(this), default: selector.default.value, }); break; @@ -151,7 +183,7 @@ export class FormattingContext { let value = callable(this, selector.expr.args, selector.expr.opts); resolved_selectors.push({ value: value.value, - string: value.format(this), + string: value.formatToString(this), default: selector.default.value, }); break; @@ -209,3 +241,14 @@ export function formatMessage( let variant = ctx.selectVariant(message.variants, message.selectors); return ctx.formatPattern(variant.value); } + +export function* formatToParts( + message: Message, + vars: Record> +): IterableIterator { + let ctx = new FormattingContext(message.lang, message, vars); + let variant = ctx.selectVariant(message.variants, message.selectors); + for (let value of ctx.resolvePattern(variant.value)) { + yield* value.formatToParts(ctx); + } +} diff --git a/experiments/stasm/third/package.json b/experiments/stasm/third/package.json index 4f0d2b283..89e6b87ec 100644 --- a/experiments/stasm/third/package.json +++ b/experiments/stasm/third/package.json @@ -12,7 +12,7 @@ }, "scripts": { "example": "tsc && node example/index.js", - "start": "tsc && (node example/index.js glossary; node example/index.js list; node example/index.js phrases; node example/index.js number)", + "start": "tsc && (node example/index.js glossary; node example/index.js list; node example/index.js phrases; node example/index.js number; node example/index.js opaque)", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "Staś Małolepszy ",