diff --git a/compiler/bundle.sh b/compiler/bundle.sh new file mode 100755 index 0000000..201628d --- /dev/null +++ b/compiler/bundle.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +deno bundle index.ts compiler.js \ No newline at end of file diff --git a/compiler/compiler.js b/compiler/compiler.js new file mode 100644 index 0000000..a24fb1d --- /dev/null +++ b/compiler/compiler.js @@ -0,0 +1,125 @@ +// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. + +// A script preamble that provides the ability to load a single outfile +// TypeScript "bundle" where a main module is loaded which recursively +// instantiates all the other modules in the bundle. This code is used to load +// bundles when creating snapshots, but is also used when emitting bundles from +// Deno cli. + +// @ts-nocheck + +/** + * @type {(name: string, deps: ReadonlyArray, factory: (...deps: any[]) => void) => void=} + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +let define; + +/** + * @type {(mod: string) => any=} + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +let instantiate; + +/** + * @callback Factory + * @argument {...any[]} args + * @returns {object | void} + */ + +/** + * @typedef ModuleMetaData + * @property {ReadonlyArray} dependencies + * @property {(Factory | object)=} factory + * @property {object} exports + */ + +(function() { + /** + * @type {Map} + */ + const modules = new Map(); + + /** + * Bundles in theory can support "dynamic" imports, but for internal bundles + * we can't go outside to fetch any modules that haven't been statically + * defined. + * @param {string[]} deps + * @param {(...deps: any[]) => void} resolve + * @param {(err: any) => void} reject + */ + const require = (deps, resolve, reject) => { + try { + if (deps.length !== 1) { + throw new TypeError("Expected only a single module specifier."); + } + if (!modules.has(deps[0])) { + throw new RangeError(`Module "${deps[0]}" not defined.`); + } + resolve(getExports(deps[0])); + } catch (e) { + if (reject) { + reject(e); + } else { + throw e; + } + } + }; + + define = (id, dependencies, factory) => { + if (modules.has(id)) { + throw new RangeError(`Module "${id}" has already been defined.`); + } + modules.set(id, { + dependencies, + factory, + exports: {} + }); + }; + + /** + * @param {string} id + * @returns {any} + */ + function getExports(id) { + const module = modules.get(id); + if (!module) { + // because `$deno$/ts_global.d.ts` looks like a real script, it doesn't + // get erased from output as an import, but it doesn't get defined, so + // we don't have a cache for it, so because this is an internal bundle + // we can just safely return an empty object literal. + return {}; + } + if (!module.factory) { + return module.exports; + } else if (module.factory) { + const { factory, exports } = module; + delete module.factory; + if (typeof factory === "function") { + const dependencies = module.dependencies.map(id => { + if (id === "require") { + return require; + } else if (id === "exports") { + return exports; + } + return getExports(id); + }); + factory(...dependencies); + } else { + Object.assign(exports, factory); + } + return exports; + } + } + + instantiate = dep => { + define = undefined; + const result = getExports(dep); + // clean up, or otherwise these end up in the runtime environment + instantiate = undefined; + return result; + }; +})(); + +console.log('foo'); + +instantiate("file:///Users/kaisermann/Projects/open-source/svelte-i18n/compiler/index"); diff --git a/compiler/deps/parser.ts b/compiler/deps/parser.ts new file mode 100644 index 0000000..3498336 --- /dev/null +++ b/compiler/deps/parser.ts @@ -0,0 +1,2 @@ +// @deno-types="https://unpkg.com/intl-messageformat-parser@3.6.3/lib/index.d.ts" +export * from 'https://unpkg.com/intl-messageformat-parser@3.6.3/lib/index.js' diff --git a/compiler/formatters.ts b/compiler/formatters.ts new file mode 100644 index 0000000..1a3cd8f --- /dev/null +++ b/compiler/formatters.ts @@ -0,0 +1,270 @@ +import { + convertNumberSkeletonToNumberFormatOptions, + isArgumentElement, + isDateElement, + isDateTimeSkeleton, + isLiteralElement, + isNumberElement, + isNumberSkeleton, + isPluralElement, + isPoundElement, + isSelectElement, + isTimeElement, + MessageFormatElement, + parseDateTimeSkeleton, +} from './deps/parser.ts' + +export interface Formats { + number: Record + date: Record + time: Record +} + +export interface FormatterCache { + number: Record + dateTime: Record + pluralRules: Record +} + +export interface Formatters { + getNumberFormat( + ...args: ConstructorParameters + ): Intl.NumberFormat + getDateTimeFormat( + ...args: ConstructorParameters + ): Intl.DateTimeFormat + getPluralRules( + ...args: ConstructorParameters + ): Intl.PluralRules +} + +export const enum PART_TYPE { + literal, + argument, +} + +export interface LiteralPart { + type: PART_TYPE.literal + value: string +} + +export interface ArgumentPart { + type: PART_TYPE.argument + value: any +} + +export type MessageFormatPart = LiteralPart | ArgumentPart + +export type PrimitiveType = string | number | boolean | null | undefined | Date + +class FormatError extends Error { + public readonly variableId?: string + constructor(msg?: string, variableId?: string) { + super(msg) + this.variableId = variableId + } +} + +function mergeLiteral(parts: MessageFormatPart[]): MessageFormatPart[] { + if (parts.length < 2) { + return parts + } + return parts.reduce((all, part) => { + const lastPart = all[all.length - 1] + if ( + !lastPart || + lastPart.type !== PART_TYPE.literal || + part.type !== PART_TYPE.literal + ) { + all.push(part) + } else { + lastPart.value += part.value + } + return all + }, [] as MessageFormatPart[]) +} + +// TODO(skeleton): add skeleton support +export function formatToParts( + els: MessageFormatElement[], + locales: string | string[], + formatters: Formatters, + formats: Formats, + values?: Record, + currentPluralValue?: number, + // For debugging + originalMessage?: string +): MessageFormatPart[] { + // Hot path for straight simple msg translations + if (els.length === 1 && isLiteralElement(els[0])) { + return [ + { + type: PART_TYPE.literal, + value: els[0].value, + }, + ] + } + const result: MessageFormatPart[] = [] + for (const el of els) { + // Exit early for string parts. + if (isLiteralElement(el)) { + result.push({ + type: PART_TYPE.literal, + value: el.value, + }) + continue + } + // TODO: should this part be literal type? + // Replace `#` in plural rules with the actual numeric value. + if (isPoundElement(el)) { + if (typeof currentPluralValue === 'number') { + result.push({ + type: PART_TYPE.literal, + value: formatters.getNumberFormat(locales).format(currentPluralValue), + }) + } + continue + } + + const { value: varName } = el + + // Enforce that all required values are provided by the caller. + if (!(values && varName in values)) { + throw new FormatError( + `The intl string context variable "${varName}" was not provided to the string "${originalMessage}"` + ) + } + + let value = values[varName] + if (isArgumentElement(el)) { + if (!value || typeof value === 'string' || typeof value === 'number') { + value = + typeof value === 'string' || typeof value === 'number' + ? String(value) + : '' + } + result.push({ + type: PART_TYPE.argument, + value, + }) + continue + } + + // Recursively format plural and select parts' option — which can be a + // nested pattern structure. The choosing of the option to use is + // abstracted-by and delegated-to the part helper object. + if (isDateElement(el)) { + const style = + typeof el.style === 'string' ? formats.date[el.style] : undefined + result.push({ + type: PART_TYPE.literal, + value: formatters + .getDateTimeFormat(locales, style) + .format(value as number), + }) + continue + } + if (isTimeElement(el)) { + const style = + typeof el.style === 'string' + ? formats.time[el.style] + : isDateTimeSkeleton(el.style) + ? parseDateTimeSkeleton(el.style.pattern) + : undefined + result.push({ + type: PART_TYPE.literal, + value: formatters + .getDateTimeFormat(locales, style) + .format(value as number), + }) + continue + } + if (isNumberElement(el)) { + const style = + typeof el.style === 'string' + ? formats.number[el.style] + : isNumberSkeleton(el.style) + ? convertNumberSkeletonToNumberFormatOptions(el.style.tokens) + : undefined + result.push({ + type: PART_TYPE.literal, + value: formatters + .getNumberFormat(locales, style) + .format(value as number), + }) + continue + } + if (isSelectElement(el)) { + const opt = el.options[value as string] || el.options.other + if (!opt) { + throw new RangeError( + `Invalid values for "${ + el.value + }": "${value}". Options are "${Object.keys(el.options).join('", "')}"` + ) + } + result.push( + ...formatToParts(opt.value, locales, formatters, formats, values) + ) + continue + } + if (isPluralElement(el)) { + let opt = el.options[`=${value}`] + if (!opt) { + if (!Intl.PluralRules) { + throw new FormatError(`Intl.PluralRules is not available in this environment. +Try polyfilling it using "@formatjs/intl-pluralrules" +`) + } + const rule = formatters + .getPluralRules(locales, { type: el.pluralType }) + .select((value as number) - (el.offset || 0)) + opt = el.options[rule] || el.options.other + } + if (!opt) { + throw new RangeError( + `Invalid values for "${ + el.value + }": "${value}". Options are "${Object.keys(el.options).join('", "')}"` + ) + } + result.push( + ...formatToParts( + opt.value, + locales, + formatters, + formats, + values, + value - (el.offset || 0) + ) + ) + continue + } + } + return mergeLiteral(result) +} + +export function formatToString( + els: MessageFormatElement[], + locales: string | string[], + formatters: Formatters, + formats: Formats, + values?: Record, + // For debugging + originalMessage?: string +): string { + const parts = formatToParts( + els, + locales, + formatters, + formats, + values, + undefined, + originalMessage + ) + // Hot path for straight simple msg translations + if (parts.length === 1) { + return parts[0].value + } + return parts.reduce((all, part) => (all += part.value), '') +} diff --git a/compiler/index.ts b/compiler/index.ts new file mode 100644 index 0000000..62b57c7 --- /dev/null +++ b/compiler/index.ts @@ -0,0 +1,9 @@ +import { parse } from './deps/parser.ts' + +const log_json = (o: object) => console.log(JSON.stringify(o, null, 2)) +const ast = parse(`{taxableArea, select, +yes {An additional {taxRate, number, percent} tax will be collected.} +other {No taxes apply.} +}`) + +log_json(ast)