Commit 0c4b7a12 authored by nanahira's avatar nanahira

middleware & i18n

parent e8cc135c
...@@ -5,3 +5,4 @@ export * from './src/round-robin'; ...@@ -5,3 +5,4 @@ export * from './src/round-robin';
export * from './src/abortable'; export * from './src/abortable';
export * from './src/types'; export * from './src/types';
export * from './src/middleware-dispatcher'; export * from './src/middleware-dispatcher';
export * from './src/i18n';
import { MiddlewareDispatcher } from '../middleware-dispatcher';
import { parseI18n } from '../utility/parse-i18n';
import { I18nMiddleware, I18nOptions } from './types';
export class I18n<Ex extends any[] = []> {
constructor(private options: I18nOptions) {}
locales = new Set(this.options.locales);
defaultLocale = this.options.defaultLocale ?? this.options.locales[0];
private mw = new MiddlewareDispatcher<
(locale: string, text: string, ...ex: Ex) => string | undefined
>({
acceptResult: (res) => res != null,
errorHandler: (e) => {
throw e;
},
});
private shadowMiddlewares: I18nMiddleware<Ex>[] = [];
middleware(mw: I18nMiddleware<Ex>, prior = false) {
this.mw.middleware((locale, text, ...args) => {
const ex = args.slice(0, -1) as Ex;
const next = args[args.length - 1] as () => Promise<string | undefined>;
return mw(locale, text, next, ...ex);
}, prior);
if (prior) {
this.shadowMiddlewares.unshift(mw);
} else {
this.shadowMiddlewares.push(mw);
}
return this;
}
removeMiddleware(mw: I18nMiddleware<Ex>) {
const idx = this.shadowMiddlewares.indexOf(mw);
if (idx >= 0) {
this.shadowMiddlewares.splice(idx, 1);
this.mw.middlewares.splice(idx, 1);
}
return this;
}
getExactLocale(locale: string) {
const input = (locale ?? '').trim();
if (!input) return this.defaultLocale;
if (this.locales.has(input)) return input;
// 小写化比较,保留原大小写
const entries = Array.from(this.locales).map((l) => ({
orig: l,
lower: l.toLowerCase(),
}));
const lower = input.toLowerCase();
// 1) 精确匹配(大小写不敏感)
const exact = entries.find((e) => e.lower === lower);
if (exact) return exact.orig;
// 2) 按 '-' 拆分,依次尝试去掉最右边的段
// zh-Hans-CN → zh-Hans → zh
const parts = lower.split('-');
while (parts.length > 1) {
parts.pop();
const candidate = parts.join('-');
const hit = entries.find((e) => e.lower === candidate);
if (hit) return hit.orig;
}
// 3) 兜底
return this.defaultLocale;
}
private buildFallbackChain(locale: string): string[] {
const best = this.getExactLocale(locale); // 你的“最长匹配”函数
// 拆分 zh-Hans-CN -> ['zh-Hans-CN','zh-Hans','zh']
const parts: string[] = [];
const segs = best.split('-');
for (let i = segs.length; i > 1; i--)
parts.push(segs.slice(0, i).join('-'));
parts.push(segs[0]); // 'zh'
// 附加默认语言
if (!parts.includes(this.defaultLocale)) parts.push(this.defaultLocale);
// 去重
return Array.from(new Set(parts)).filter((p) => this.locales.has(p));
}
private async applyMiddlewares(
locale: string,
text: string,
...ex: Ex
): Promise<string | undefined> {
const tryLocale = (locale: string) => this.mw.dispatch(locale, text, ...ex);
for (const loc of this.buildFallbackChain(locale)) {
const result = await tryLocale(loc);
if (result != null) {
return result;
}
}
return undefined;
}
async translateString(
locale: string,
text: string,
...ex: Ex
): Promise<string> {
if (!text) return text;
locale = this.getExactLocale(locale);
const pieces = parseI18n(text);
if (!pieces.some((p) => p.type === 'ph')) {
return pieces
.map((p) => (p.type === 'raw' ? p.value : `#{${p.rawInner}}`))
.join('');
}
const promises: Array<Promise<string | undefined | null>> = [];
for (const p of pieces) {
if (p.type === 'ph') {
promises.push(this.applyMiddlewares(locale, p.key, ...ex));
}
}
const results = await Promise.all(promises);
let out = '';
let k = 0;
for (const p of pieces) {
if (p.type === 'raw') {
out += p.value;
} else {
const r = results[k++];
out += r == null ? `#{${p.rawInner}}` : r;
}
}
return out;
}
async translate<T>(locale: string, obj: T, ...ex: Ex): Promise<T> {
const visited = new WeakSet<object>();
const isBuiltInObject = (v: any): boolean => {
if (v == null || typeof v !== 'object') return false;
if (
v instanceof Date ||
v instanceof RegExp ||
v instanceof Map ||
v instanceof Set ||
v instanceof WeakMap ||
v instanceof WeakSet ||
v instanceof ArrayBuffer ||
v instanceof DataView ||
ArrayBuffer.isView(v) // TypedArray / Buffer
)
return true;
const tag = Object.prototype.toString.call(v);
switch (tag) {
case '[object URL]':
case '[object URLSearchParams]':
case '[object Error]':
case '[object Blob]':
case '[object File]':
case '[object FormData]':
return true;
default:
return false;
}
};
const translateObjectPreservingProto = async (value: any): Promise<any> => {
const proto = Object.getPrototypeOf(value);
const out = Object.create(proto);
const keys = Reflect.ownKeys(value);
await Promise.all(
keys.map(async (key) => {
const desc = Object.getOwnPropertyDescriptor(value, key as any);
if (!desc) return;
// 数据属性:并发递归其 value,保持原属性特性
if ('value' in desc) {
const newVal = await visit(desc.value);
Object.defineProperty(out, key, { ...desc, value: newVal });
return;
}
// 访问器属性:先复制 getter/setter,再尝试读值→翻译→写回
Object.defineProperty(out, key, desc);
// 没有 getter 就无需处理;有 getter 但抛错也忽略
let current: any = undefined;
if (typeof desc.get === 'function') {
try {
current = desc.get.call(value);
} catch {
/* ignore */
}
}
if (current === undefined) return;
// 递归翻译 getter 返回的值;若有 setter 则写回
try {
const newVal = await visit(current);
if (typeof desc.set === 'function') {
try {
desc.set.call(out, newVal);
} catch {
/* ignore */
}
}
} catch {
// 翻译失败不影响其他键
}
}),
);
return out;
};
const isTranslatable = (
v: any,
): { ok: false } | { ok: true; kind: 'string' | 'object' } => {
// 1) 所有 falsy 原样(null/undefined/false/0/''/NaN)
if (!v) return { ok: false };
// 2) 基本类型过滤
const t = typeof v;
if (
t === 'number' ||
t === 'bigint' ||
t === 'symbol' ||
t === 'function'
) {
return { ok: false };
}
// 3) 可翻译:字符串 or 对象(对象再由后续逻辑决定是否深入)
if (t === 'string') return { ok: true, kind: 'string' };
if (t === 'object') {
return { ok: true, kind: 'object' };
}
// 其它(boolean 等已包含在 falsy/基本类型里)
return { ok: false };
};
const visit = async (value: any): Promise<any> => {
// 1) 检查是否可翻译
const check = isTranslatable(value);
// 2) 不可翻译的原样返回
if (!check.ok) {
return value;
}
if (check.kind === 'string') {
// 3) 字符串:翻译
return this.translateString(locale, value, ...ex);
}
// 4) Promise:不隐式展开(保持语义)
if (value instanceof Promise) {
return value.then((resolved) => visit(resolved));
}
// 5) 对象类:过滤内置对象
if (typeof value === 'object') {
if (!Array.isArray(value) && isBuiltInObject(value)) return value;
// 防环
if (visited.has(value)) return value;
visited.add(value);
// 数组:元素级递归
if (Array.isArray(value)) {
const out = await Promise.all(value.map((v) => visit(v)));
return out as any;
}
// 其他对象(含类实例/DTO):保留原型并递归自有属性
return translateObjectPreservingProto(value);
}
// 其余类型(boolean 等)原样返回
return value;
};
return visit(obj);
}
}
export * from './i18n';
export * from './middlewares';
export * from './types';
export * from './lookup';
import { I18nMiddleware } from '../types';
import { Awaitable } from '../../types';
export type I18nDictionary = Record<string, Record<string, string>>;
type MatchType = 'exact' | 'hierarchy' | 'startsWith';
export const I18nLookupMiddleware = <Ex extends any[] = []>(
dict:
| I18nDictionary
| ((locale: string, key: string, ...ex: Ex) => Awaitable<I18nDictionary>),
options?: { matchType?: MatchType },
): I18nMiddleware<Ex> => {
const matchType: MatchType = options?.matchType ?? 'exact';
const dictFactory = typeof dict === 'function' ? dict : () => dict;
// 基于 locales 列表做“精确→层级回退(zh-Hans-CN→zh-Hans→zh)”的最长匹配
const pickBestByHierarchy = (
input: string,
locales: string[],
): string | undefined => {
if (!input) return undefined;
const entries = locales.map((l) => ({ orig: l, lower: l.toLowerCase() }));
const lower = input.toLowerCase();
// 精确匹配(大小写不敏感)
const exact = entries.find((e) => e.lower === lower);
if (exact) return exact.orig;
// 逐级回退:zh-Hans-CN -> zh-Hans -> zh
const parts = lower.split('-');
while (parts.length > 1) {
parts.pop();
const candidate = parts.join('-');
const hit = entries.find((e) => e.lower === candidate);
if (hit) return hit.orig;
}
return undefined;
};
return async (locale, key, next, ...ex) => {
const dictResolved = await dictFactory(locale, key, ...ex);
let dictionary = dictResolved[locale];
if (!dictionary) {
if (matchType === 'hierarchy') {
const best = pickBestByHierarchy(locale, Object.keys(dictResolved));
if (best) dictionary = dictResolved[best];
} else if (matchType === 'startsWith') {
const keys = Object.keys(dictResolved).filter((k) =>
locale.startsWith(k),
);
if (keys.length) {
const best = keys.reduce((a, b) => (b.length > a.length ? b : a));
dictionary = dictResolved[best];
}
}
}
// 命中判断:允许空字符串 '';仅 null/undefined 视为未命中
if (dictionary && Object.prototype.hasOwnProperty.call(dictionary, key)) {
const val = dictionary[key];
if (val != null) {
return val;
}
}
return next();
};
};
export const createI18nLookupMiddleware = <Ex extends any[]>() =>
I18nLookupMiddleware as typeof I18nLookupMiddleware<Ex>;
import { Awaitable } from '../types';
export type I18nMiddleware<Ex extends any[] = []> = (
locale: string,
text: string,
next: () => Promise<string | undefined>,
...ex: Ex
) => Awaitable<string | undefined>;
export interface I18nOptions {
locales: string[];
defaultLocale?: string;
}
...@@ -15,10 +15,10 @@ export type Middleware<F extends AnyFunc> = ( ...@@ -15,10 +15,10 @@ export type Middleware<F extends AnyFunc> = (
...args: MiddlewareArgs<F> ...args: MiddlewareArgs<F>
) => MiddlewareReturn<F>; ) => MiddlewareReturn<F>;
type MiddlewareAcceptResult<F extends AnyFunc> = ( export type MiddlewareAcceptResult<F extends AnyFunc> = (
s: MiddlewareValue<F>, s: MiddlewareValue<F>,
) => Awaitable<boolean>; ) => Awaitable<boolean>;
type MiddlewareErrorHandler<F extends AnyFunc> = ( export type MiddlewareErrorHandler<F extends AnyFunc> = (
e: any, e: any,
args: Parameters<F>, args: Parameters<F>,
next: MiddlewareNext<F>, next: MiddlewareNext<F>,
......
// —— 解析结果结构 —— //
export type I18nPiece =
| { type: 'raw'; value: string }
| { type: 'ph'; rawInner: string; key: string };
/**
* 栈式解析 #{ ... } 占位。支持内部成对花括号,如 #{ foo {{ bar }} }。
* - 未闭合时:把从上次位置 i 到结尾整体作为 raw 放回(避免被拆成两段)
* - key = trim(rawInner)
*/
export const parseI18n = (text: string): I18nPiece[] => {
const pieces: I18nPiece[] = [];
if (!text) return pieces;
let i = 0;
const n = text.length;
while (i < n) {
const start = text.indexOf('#{', i);
if (start === -1) {
// 没有更多占位符
if (i < n) pieces.push({ type: 'raw', value: text.slice(i) });
break;
}
// 先尝试匹配这个占位符是否闭合(此时不立即推入前导 raw)
let j = start + 2; // 指向 '#{' 后第一个字符
let depth = 1;
while (j < n && depth > 0) {
const ch = text.charCodeAt(j);
if (ch === 123 /* '{' */) depth++;
else if (ch === 125 /* '}' */) depth--;
j++;
}
if (depth !== 0) {
// 未闭合:把从 i 到末尾整体当作 raw(包含前导 + '#{' 尾巴)
pieces.push({ type: 'raw', value: text.slice(i) });
break;
}
// 到这里说明占位闭合:先推入前导 raw,再推占位片段
if (start > i) {
pieces.push({ type: 'raw', value: text.slice(i, start) });
}
const rawInner = text.slice(start + 2, j - 1);
const key = rawInner.trim();
pieces.push({ type: 'ph', rawInner, key });
i = j; // 继续向后扫描
}
return pieces;
};
// i18n-lookup.spec.ts
import { I18nLookupMiddleware } from '../src/i18n';
import { MiddlewareDispatcher } from '../src/middleware-dispatcher';
type F = (locale: string, key: string) => string | undefined;
describe('I18nLookupMiddleware - matching strategies', () => {
it('exact strategy uses only exact branch', async () => {
const dict = {
en: { k: 'EN' },
'zh-Hans': { k: 'ZH-Hans' },
};
const mw = I18nLookupMiddleware(() => dict, { matchType: 'exact' });
const d = new MiddlewareDispatcher<F>({
acceptResult: (res) => res != null,
});
d.middleware(mw);
const a = await d.dispatch('zh-Hans-CN', 'k'); // no exact "zh-Hans-CN"
expect(a).toBeUndefined();
const b = await d.dispatch('zh-Hans', 'k');
expect(b).toBe('ZH-Hans');
});
it('hierarchy strategy finds closest branch by reducing segments', async () => {
const dict = {
zh: { k: 'ZH' },
'zh-Hans': { k: 'ZH-Hans' },
};
const d = new MiddlewareDispatcher<F>({
acceptResult: (res) => res != null,
});
d.middleware(I18nLookupMiddleware(() => dict, { matchType: 'hierarchy' }));
const a = await d.dispatch('zh-Hans-CN', 'k');
expect(a).toBe('ZH-Hans');
});
it('startsWith chooses the longest prefix branch', async () => {
const dict = {
zh: { k: 'ZH' },
'zh-Hans': { k: 'ZH-Hans' },
};
const d = new MiddlewareDispatcher<F>({
acceptResult: (res) => res != null,
});
d.middleware(I18nLookupMiddleware(() => dict, { matchType: 'startsWith' }));
const a = await d.dispatch('zh-Hans-CN', 'k');
expect(a).toBe('ZH-Hans');
});
it('allows empty string values and treats null/undefined as misses', async () => {
const dict = {
en: { a: '', b: null as any, c: undefined as any },
};
const d = new MiddlewareDispatcher<F>({
acceptResult: (res) => res != null,
});
d.middleware(I18nLookupMiddleware(() => dict));
const a = await d.dispatch('en', 'a');
expect(a).toBe(''); // valid hit
const b = await d.dispatch('en', 'b');
const c = await d.dispatch('en', 'c');
expect(b).toBeUndefined();
expect(c).toBeUndefined();
});
});
// i18n.spec.ts
import {
createI18nLookupMiddleware,
I18n,
I18nDictionary,
I18nLookupMiddleware,
} from '../src/i18n';
type Ctx = {
dict?: I18nDictionary;
name?: string;
};
describe('I18n - end-to-end behavior', () => {
const locales = ['en', 'zh', 'zh-Hans', 'zh-Hans-CN'] as const;
const baseDict: I18nDictionary = {
en: { hello: 'Hello', price: '', bye: 'Bye' },
'zh-Hans': { hello: '你好', user: '用户:${name}' },
zh: { hi: '' },
};
const makeI18n = () =>
new I18n<[ctx?: Ctx]>({
locales: locales as unknown as string[],
defaultLocale: 'en',
});
it('translates placeholders via lookup middleware with hierarchy fallback', async () => {
const i18n = makeI18n();
i18n.middleware(
I18nLookupMiddleware<[Ctx]>(() => baseDict, { matchType: 'hierarchy' }),
);
const s = await i18n.translateString(
'zh-Hans-CN',
'Say: #{hello} / #{price} / #{miss}',
);
expect(s).toBe('Say: 你好 / / #{miss}');
});
it('keeps raw string unchanged when no placeholders exist', async () => {
const i18n = makeI18n();
i18n.middleware(
I18nLookupMiddleware(() => baseDict, { matchType: 'hierarchy' }),
);
const s = await i18n.translateString('en', 'Plain text only');
expect(s).toBe('Plain text only');
});
it('prefers exact locale over hierarchy fallback', async () => {
const i18n = makeI18n();
const dict: I18nDictionary = {
zh: { hello: '嗨(zh)' },
'zh-Hans': { hello: '你好(zh-Hans)' },
};
i18n.middleware(
I18nLookupMiddleware(() => dict, { matchType: 'hierarchy' }),
);
const s1 = await i18n.translateString('zh-Hans', '=> #{hello}');
expect(s1).toBe('=> 你好(zh-Hans)');
const s2 = await i18n.translateString('zh-Hans-CN', '=> #{hello}');
expect(s2).toBe('=> 你好(zh-Hans)'); // fell back from zh-Hans-CN to zh-Hans
});
it('supports startsWith strategy and picks the longest matching prefix', async () => {
const i18n = makeI18n();
const dict: I18nDictionary = {
zh: { greet: '嗨(zh)' },
'zh-Hans': { greet: '嗨(zh-Hans)' },
};
i18n.middleware(
I18nLookupMiddleware(() => dict, { matchType: 'startsWith' }),
);
const s = await i18n.translateString('zh-Hans-CN', '=> #{greet}');
expect(s).toBe('=> 嗨(zh-Hans)'); // chooses the longest prefix "zh-Hans"
});
it('awaits async dictionary factory', async () => {
const i18n = makeI18n();
const dict: I18nDictionary = { en: { hello: 'Hello(async)' } };
i18n.middleware(
I18nLookupMiddleware(async () => {
await new Promise((r) => setTimeout(r, 10));
return dict;
}),
);
const s = await i18n.translateString('en', 'Result: #{hello}');
expect(s).toBe('Result: Hello(async)');
});
it('respects empty string translations', async () => {
const i18n = makeI18n();
const dict: I18nDictionary = { en: { price: '' } };
i18n.middleware(I18nLookupMiddleware(() => dict));
const s = await i18n.translateString('en', 'Price: #{price}');
expect(s).toBe('Price: '); // empty string is a valid hit
});
it('leaves placeholder intact for missing keys', async () => {
const i18n = makeI18n();
i18n.middleware(I18nLookupMiddleware(() => baseDict));
const s = await i18n.translateString('en', 'Unknown: #{nope}');
expect(s).toBe('Unknown: #{nope}');
});
it('middleware order: prior insertion runs first and can short-circuit', async () => {
const i18n = makeI18n();
const hits: string[] = [];
// normal dictionary lookup at tail
i18n.middleware(
I18nLookupMiddleware(() => baseDict, { matchType: 'hierarchy' }),
);
// prior middleware: short-circuit for a specific key
i18n.middleware((locale, key, next) => {
hits.push(`prior:${key}`);
if (key === 'special') return 'SPECIAL!';
return next();
}, true);
const a = await i18n.translateString(
'zh-Hans-CN',
'A #{special} B #{hello}',
);
expect(a).toBe('A SPECIAL! B 你好');
expect(hits).toEqual(['prior:special', 'prior:hello']); // even when not short-circuiting, prior runs before others
});
it('removeMiddleware detaches the correct middleware', async () => {
const i18n = makeI18n();
const logs: string[] = [];
const mwA = (locale: string, key: string, next: any) => {
logs.push('A');
return next();
};
const mwB = (locale: string, key: string, next: any) => {
logs.push('B');
return next();
};
const mwC = I18nLookupMiddleware(() => baseDict, {
matchType: 'hierarchy',
});
i18n.middleware(mwA);
i18n.middleware(mwB);
i18n.middleware(mwC);
i18n.removeMiddleware(mwB); // remove middle
const s = await i18n.translateString('zh-Hans-CN', '=> #{hello}');
expect(s).toBe('=> 你好');
expect(logs).toEqual(['A']); // B was removed, so never logged
});
it('translate() preserves prototype and translates own data properties', async () => {
class Person {
constructor(
public first: string,
public last: string,
) {}
get full() {
return `${this.first} ${this.last}`;
}
}
const p = new Person('Foo #{hello}', 'Bar');
const i18n = makeI18n();
i18n.middleware(
I18nLookupMiddleware(() => baseDict, { matchType: 'hierarchy' }),
);
const out = await i18n.translate('zh-Hans-CN', p);
expect(Object.getPrototypeOf(out)).toBe(Person.prototype);
expect(out.first).toBe('Foo 你好');
expect(out.last).toBe('Bar');
// accessors are copied as-is; translateObjectPreservingProto mutates via setter only if present
expect(out.full).toBe('Foo 你好 Bar');
});
it('translate() keeps built-in objects as-is and handles cycles', async () => {
const i18n = makeI18n();
i18n.middleware(
I18nLookupMiddleware(() => baseDict, { matchType: 'hierarchy' }),
);
const cyc: any = { a: 'X #{hello}', date: new Date(), rx: /abc/ };
cyc.self = cyc; // cycle
const out = await i18n.translate('zh-Hans-CN', cyc);
expect(out.a).toBe('X 你好');
expect(out.date).toBe(cyc.date);
expect(out.rx).toBe(cyc.rx);
expect(out.self).toBe(cyc); // already visited -> original reference (not deeply cloned)
});
it('translate() resolves Promises and translates resolved values', async () => {
const i18n = makeI18n();
i18n.middleware(
I18nLookupMiddleware(() => baseDict, { matchType: 'hierarchy' }),
);
const obj = {
title: Promise.resolve('Head #{hello}'),
nested: {
x: Promise.resolve('N #{bye}'),
},
};
const out = await i18n.translate('zh-Hans-CN', obj);
expect(await out.title).toBe('Head 你好'); // promise value gets translated after resolution
expect(await out.nested.x).toBe('N Bye');
});
it('createI18nLookupMiddleware returns a generic-bound factory', async () => {
const i18n = makeI18n();
// bind Ex = [Ctx] once
const makeLookup = createI18nLookupMiddleware<[Ctx]>();
const lookup = makeLookup(async (locale, key, ctx) => ctx.dict!, {
matchType: 'hierarchy',
});
i18n.middleware(lookup);
const s = await i18n.translateString('zh-Hans-CN', 'User: #{user}', {
dict: baseDict,
name: 'Karin',
});
// note: our dict has '用户:${name}', but we didn't add a formatter middleware here,
// so ${name} stays literal. The point of this test is that generic binding works and lookup hits.
expect(s).toBe('User: 用户:${name}');
});
it('placeholders not found remain intact while raw segments are preserved', async () => {
const i18n = makeI18n();
i18n.middleware(
I18nLookupMiddleware(() => baseDict, { matchType: 'hierarchy' }),
);
const s = await i18n.translateString(
'en',
'Alpha #{missing} Beta #{hello} Gamma',
);
expect(s).toBe('Alpha #{missing} Beta Hello Gamma');
});
});
import { I18nPiece, parseI18n } from '../src/utility/parse-i18n';
function createTest(
title: string,
{ input, expected }: { input: string; expected: I18nPiece[] },
) {
test(title, () => {
expect(parseI18n(input)).toEqual(expected);
});
}
describe('parseI18n', () => {
createTest('raw text only', {
input: 'hello world',
expected: [{ type: 'raw', value: 'hello world' }],
});
createTest('single placeholder', {
input: 'hi #{name}!',
expected: [
{ type: 'raw', value: 'hi ' },
{ type: 'ph', rawInner: 'name', key: 'name' },
{ type: 'raw', value: '!' },
],
});
createTest('trim spaces inside placeholder', {
input: 'greet #{ user.name }',
expected: [
{ type: 'raw', value: 'greet ' },
{ type: 'ph', rawInner: ' user.name ', key: 'user.name' },
],
});
createTest('nested braces inside placeholder', {
input: '#{ foo {{ bar }} }',
expected: [
{ type: 'ph', rawInner: ' foo {{ bar }} ', key: 'foo {{ bar }}' },
],
});
createTest('placeholder contains #{{ sequence', {
input: '#{ foo #{{ bar }} }',
expected: [
{ type: 'ph', rawInner: ' foo #{{ bar }} ', key: 'foo #{{ bar }}' },
],
});
createTest('stray braces outside placeholders', {
input: '#{ foo } }}}}} {{{{{ #{ bar }',
expected: [
{ type: 'ph', rawInner: ' foo ', key: 'foo' },
{ type: 'raw', value: ' }}}}} {{{{{ ' },
{ type: 'ph', rawInner: ' bar ', key: 'bar' },
],
});
createTest('placeholder inside double curly braces', {
input: 'We {{ #{blue} }} sky',
expected: [
{ type: 'raw', value: 'We {{ ' },
{ type: 'ph', rawInner: 'blue', key: 'blue' },
{ type: 'raw', value: ' }} sky' },
],
});
createTest('nested double curly braces inside key', {
input: 'We {{ #{ blue sky with {{ ocean }} } }}',
expected: [
{ type: 'raw', value: 'We {{ ' },
{
type: 'ph',
rawInner: ' blue sky with {{ ocean }} ',
key: 'blue sky with {{ ocean }}',
},
{ type: 'raw', value: ' }}' },
],
});
createTest('unclosed placeholder tail treated as raw', {
input: 'abc #{ foo } def #{ bar',
expected: [
{ type: 'raw', value: 'abc ' },
{ type: 'ph', rawInner: ' foo ', key: 'foo' },
{ type: 'raw', value: ' def #{ bar' },
],
});
createTest('JSON inside placeholder', {
input: '#{ {"a":1,"b":{"c":2}} }',
expected: [
{
type: 'ph',
rawInner: ' {"a":1,"b":{"c":2}} ',
key: '{"a":1,"b":{"c":2}}',
},
],
});
createTest('multiple placeholders with surrounding text', {
input: 'X #{a} Y #{b} Z',
expected: [
{ type: 'raw', value: 'X ' },
{ type: 'ph', rawInner: 'a', key: 'a' },
{ type: 'raw', value: ' Y ' },
{ type: 'ph', rawInner: 'b', key: 'b' },
{ type: 'raw', value: ' Z' },
],
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment