Skip to content

Variable casting and Temporal support #635

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions fluent-bundle/src/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FluentValue, FluentNone, FluentFunction } from "./types.js";
import { Message, Term, Pattern } from "./ast.js";
import { NUMBER, DATETIME } from "./builtins.js";
import { getMemoizerForLocale, IntlCache } from "./memoizer.js";
import { defaultCaster, FluentCast, FluentCastRegistry } from "./cast.js";

export type TextTransform = (text: string) => string;
export type FluentVariable = FluentValue | string | number | Date;
Expand All @@ -28,6 +29,8 @@ export class FluentBundle {
public _transform: TextTransform;
/** @ignore */
public _intls: IntlCache;
/** @ignore */
public _caster: FluentCastRegistry;

/**
* Create an instance of `FluentBundle`.
Expand All @@ -52,10 +55,12 @@ export class FluentBundle {
constructor(
locales: string | Array<string>,
{
cast,
functions,
useIsolating = true,
transform = (v: string): string => v,
}: {
cast?: FluentCast;
/** Additional functions available to translations as builtins. */
functions?: Record<string, FluentFunction>;
/**
Expand All @@ -77,6 +82,7 @@ export class FluentBundle {
this._useIsolating = useIsolating;
this._transform = transform;
this._intls = getMemoizerForLocale(locales);
this._caster = new FluentCastRegistry(defaultCaster, cast);
}

/**
Expand Down Expand Up @@ -157,6 +163,38 @@ export class FluentBundle {
return errors;
}

/**
* Adds a conversion rule for specific variable types.
*
* @example Conversion function for a custom class
* ```js
* class MyNumber {
* constructor(value) { this.value = value; }
* }
*
* bundle.addCast(MyNumber, (num) => new FluentNumber(num.value));
* ```
*
* @example Registering a custom type
* ```js
* // Custom type to format Intl.Locale instances
* class FluentLanguage extends FluentType {
* toString(scope) {
* const dnf = scope.memoizeIntlObject(Intl.DisplayNames, { type: "language" });
* return dnf.of(this.value);
* }
* }
*
* // Register for automatic conversion
* bundle.addCast(Intl.Locale, FluentLanguage);
* ```
*
* @param args see {@link FluentCastRegistry.prototype.add}
*/
addCast(...args: Parameters<typeof FluentCastRegistry.prototype.add>): void {
this._caster.add(...args);
}

/**
* Format a `Pattern` to a string.
*
Expand Down
104 changes: 104 additions & 0 deletions fluent-bundle/src/cast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { FluentNumber, FluentDateTime, FluentType, FluentValue } from "./types.js";

type Class<T> = new (...args: any[]) => T;
type Guard = (value: unknown) => boolean

export type FluentTypeClass<T = unknown> = Class<FluentType<T>>;
export type FluentTypeFunction<T = unknown> = (value: T) => FluentValue | undefined;
export type FluentTypeCast<T = unknown> = FluentTypeClass<T> | FluentTypeFunction<T>;
export type FluentCast = FluentTypeFunction | FluentCaster

function generateGuard(guardValue: any): Guard {
switch (typeof guardValue) {
case "function":
return (value: unknown) => value instanceof guardValue;
case "string":
return (value: unknown) => typeof value === guardValue;
default:
return (value: unknown) => value === guardValue;
}
}

/**
* Abstract class for implementing a type casting.
* @see {@link FluentCastRegistry} for the default implementation.
*/
export abstract class FluentCaster {
abstract castValue(value: unknown): FluentValue | undefined
}

export class FluentCastRegistry extends FluentCaster {
/** @ignore */
public _casters: Array<FluentTypeFunction> = [];

/** @ignore */
constructor(...args: Array<FluentCast | undefined>) {
super();
for (const arg of args) {
if(arg) this.add(arg);
}
}

/**
* Register a new type casting rule for a specific class.
*
* @param rawType class or type that will be converted into a Fluent value
* @param fluentType either a function called for casting or a FluentType class
*/
add<T = unknown>(rawType: Class<T> | string, fluentType: FluentTypeCast<T>): void;

/**
* Register a new type casting rule tried out for every value.
*
* @param caster either a function called for casting or a FluentCaster instance
*/
add(caster: FluentCast): void;

add<T = unknown>(...args: any[]): void {
let caster: FluentTypeCast<T> | FluentCaster;
let guard: Guard | undefined;

if (args.length === 1) {
caster = args[0];
} else if (args.length === 2) {
caster = args[1];
guard = generateGuard(args[0]);
} else {
throw new Error("Invalid arguments");
}

if (caster instanceof FluentCaster) {
caster = caster.castValue.bind(caster);
} else if (caster.prototype instanceof FluentType) {
const fluentTypeClass = caster as FluentTypeClass<T>;
caster = (value: T) => new fluentTypeClass(value);
}

if (guard !== undefined) {
const guarded = caster as FluentTypeFunction<T>;
caster = (value: unknown) => guard!(value) ? guarded(value as T) : undefined;
}

this._casters.unshift(caster as FluentTypeFunction);
}

/**
* Casts an unknown value to a FluentValue.
* Returns `undefined` if the value cannot be cast.
*/
castValue(value: unknown): FluentValue | undefined {
for (const caster of this._casters) {
const result = caster(value);
if (result !== undefined) return result;
}
}
}

/**
* Default FluentCaster with built-in types, used by every {@link FluentBundle}.
* Turns numbers into {@link FluentNumber} and dates into {@link FluentDateTime}.
*/
export const defaultCaster = new FluentCastRegistry();

defaultCaster.add("number", FluentNumber);
defaultCaster.add(Date, (value: Date) => new FluentDateTime(value.getTime()));
1 change: 1 addition & 0 deletions fluent-bundle/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type { Message } from "./ast.js";
export { FluentBundle, FluentVariable, TextTransform } from "./bundle.js";
export { FluentResource } from "./resource.js";
export type { Scope } from "./scope.js";
export { FluentCaster, FluentCastRegistry, defaultCaster } from "./cast.js";
export {
FluentValue,
FluentType,
Expand Down
29 changes: 9 additions & 20 deletions fluent-bundle/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import {
FluentType,
FluentNone,
FluentNumber,
FluentDateTime,
} from "./types.js";
import { Scope } from "./scope.js";
import {
Expand Down Expand Up @@ -175,28 +174,18 @@ function resolveVariableReference(
return new FluentNone(`$${name}`);
}

// Return early if the argument already is an instance of FluentType.
if (arg instanceof FluentType) {
if (arg instanceof FluentType || typeof arg === "string") {
return arg;
}

// Convert the argument to a Fluent type.
switch (typeof arg) {
case "string":
return arg;
case "number":
return new FluentNumber(arg);
case "object":
if (arg instanceof Date) {
return new FluentDateTime(arg.getTime());
}
// eslint-disable-next-line no-fallthrough
default:
scope.reportError(
new TypeError(`Variable type not supported: $${name}, ${typeof arg}`)
);
return new FluentNone(`$${name}`);
}
const result = scope.bundle._caster.castValue(arg);
if (result) return result;

scope.reportError(
new TypeError(`Variable type not supported: $${name}, ${typeof arg}`)
);

return new FluentNone(`$${name}`);
}

/** Resolve a reference to another message. */
Expand Down
Loading