feat(template): add template in compat layer (#681)

* Almost done

* Finish template

* Fix tsc error

* Split let and const

* Refactor

* Remove `toString`

* Refactor to use `string.matchAll`

* Remove circular dependency

* Fix lint error

* Add comment

* Fix compatibility problem

* docs

---------

Co-authored-by: raon0211 <raon0211@toss.im>
This commit is contained in:
Dayong Lee 2024-10-24 21:44:29 +09:00 committed by GitHub
parent bd7468fcd0
commit adbe18e34d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1000 additions and 1 deletions

View File

@ -0,0 +1,50 @@
import { bench, describe } from 'vitest';
import { template as templateToolkitCompat_ } from 'es-toolkit/compat';
import { template as templateLodash_ } from 'lodash';
const templateToolkit = templateToolkitCompat_;
const templateLodash = templateLodash_;
describe('template (interpolate)', () => {
bench('es-toolkit/template', () => {
const compiled = templateToolkit('hello <%= user %>!');
compiled({ user: 'fred' });
compiled({ user: 'pebbles' });
});
bench('lodash/template', () => {
const compiled = templateLodash('hello <%= user %>!');
compiled({ user: 'fred' });
compiled({ user: 'pebbles' });
});
});
describe('template (evaluate)', () => {
bench('es-toolkit/template', () => {
const compiled = templateToolkit('<% if (user === "fred") { %>hello fred!<% } else { %>hello pebbles!<% } %>');
compiled({ user: 'fred' });
compiled({ user: 'pebbles' });
});
bench('lodash/template', () => {
const compiled = templateLodash('<% if (user === "fred") { %>hello fred!<% } else { %>hello pebbles!<% } %>');
compiled({ user: 'fred' });
compiled({ user: 'pebbles' });
});
});
describe('template (escape)', () => {
bench('es-toolkit/template', () => {
const compiled = templateToolkit('<%- user %>');
compiled({ user: '<script>' });
compiled({ user: '<script type="text/javascript">alert("xss");</script>' });
});
bench('lodash/template', () => {
const compiled = templateLodash('<%- user %>');
compiled({ user: '<script>' });
compiled({ user: '<script type="text/javascript">alert("xss");</script>' });
});
});

View File

@ -0,0 +1,71 @@
# template
::: info
この関数は互換性のために `es-toolkit/compat` からのみインポートできます。代替可能なネイティブ JavaScript API があるか、まだ十分に最適化されていないためです。
`es-toolkit/compat` からこの関数をインポートすると、[lodash と完全に同じように動作](../../../compatibility.md)します。
:::
テンプレート文字列をレンダリングする関数を作成します。
返された関数は、値を安全にエスケープしたり、値を評価したり、値を結合して文字列を作成するために使用できます。変数名や関数呼び出しなどのJavaScript式を評価することができます。
## インターフェース
```typescript
function template(string: string, options?: TemplateOptions): ((data?: object) => string) & { source: string };
```
### パラメータ
- `string` (`string`): テンプレート文字列です。
- `options.escape` (`RegExp`): 「escape」デリミタの正規表現です。
- `options.evaluate` (`RegExp`): 「evaluate」デリミタの正規表現です。
- `options.interpolate` (`RegExp`): 「interpolate」デリミタの正規表現です。
- `options.variable` (`string`): データオブジェクトの変数名です。
- `options.imports` (`Record<string, unknown>`): インポートされた関数のオブジェクトです。
- `options.sourceURL` (`string`): テンプレートのソースURLです。
- `guard` (`unknown`): 関数が`options`付きで呼び出されたかどうかを検出するガードです。
### 戻り値
(`(data?: object) => string`): コンパイルされたテンプレート関数を返します。
## 例
```typescript
// Use the "escape" delimiter to escape data properties.
const compiled = template('<%- value %>');
compiled({ value: '<div>' }); // returns '&lt;div&gt;'
// Use the "interpolate" delimiter to interpolate data properties.
const compiled = template('<%= value %>');
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "evaluate" delimiter to evaluate JavaScript code.
const compiled = template('<% if (value) { %>Yes<% } else { %>No<% } %>');
compiled({ value: true }); // returns 'Yes'
// Use the "variable" option to specify the data object variable name.
const compiled = template('<%= data.value %>', { variable: 'data' });
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "imports" option to import functions.
const compiled = template('<%= _.toUpper(value) %>', { imports: { _: { toUpper } } });
compiled({ value: 'hello, world!' }); // returns 'HELLO, WORLD!'
// Use the custom "escape" delimiter.
const compiled = template('<@ value @>', { escape: /<@([\s\S]+?)@>/g });
compiled({ value: '<div>' }); // returns '&lt;div&gt;'
// Use the custom "evaluate" delimiter.
const compiled = template('<# if (value) { #>Yes<# } else { #>No<# } #>', { evaluate: /<#([\s\S]+?)#>/g });
compiled({ value: true }); // returns 'Yes'
// Use the custom "interpolate" delimiter.
const compiled = template('<$ value $>', { interpolate: /<\$([\s\S]+?)\$>/g });
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "sourceURL" option to specify the source URL of the template.
const compiled = template('hello <%= user %>!', { sourceURL: 'template.js' });
```

View File

@ -0,0 +1,71 @@
# template
::: info
이 함수는 호환성을 위한 `es-toolkit/compat` 에서만 가져올 수 있어요. 대체할 수 있는 네이티브 JavaScript API가 있거나, 아직 충분히 최적화되지 않았기 때문이에요.
`es-toolkit/compat`에서 이 함수를 가져오면, [lodash와 완전히 똑같이 동작](../../../compatibility.md)해요.
:::
템플릿 문자열을 렌더링하는 함수를 만들어요.
반환된 함수는 값을 쓰기 안전한 형태로 이스케이핑하거나, 값을 평가하거나, 값을 연결해서 문자열을 만드는 데에 사용할 수 있어요. 변수 이름이나 함수 호출과 같이 JavaScript 식을 평가할 수 있어요.
## 인터페이스
```typescript
function template(string: string, options?: TemplateOptions): ((data?: object) => string) & { source: string };
```
### 파라미터
- `string` (`string`): 템플릿 문자열.
- `options.escape` (`RegExp`): "escape" 구분 기호에 대한 정규 표현식.
- `options.evaluate` (`RegExp`): "evaluate" 구분 기호에 대한 정규 표현식.
- `options.interpolate` (`RegExp`): "interpolate" 구분 기호에 대한 정규 표현식.
- `options.variable` (`string`): 데이터 객체 변수 이름.
- `options.imports` (`Record<string, unknown>`): 가져온 함수의 객체.
- `options.sourceURL` (`string`): 템플릿의 소스 URL.
- `guard` (`unknown`): `options`와 함께 함수가 호출되면 이를 감지하는 보호 장치.
### 반환 값
(`(data?: object) => string`): 컴파일된 템플릿 함수를 반환합니다.
## 예시
```typescript
// Use the "escape" delimiter to escape data properties.
const compiled = template('<%- value %>');
compiled({ value: '<div>' }); // returns '&lt;div&gt;'
// Use the "interpolate" delimiter to interpolate data properties.
const compiled = template('<%= value %>');
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "evaluate" delimiter to evaluate JavaScript code.
const compiled = template('<% if (value) { %>Yes<% } else { %>No<% } %>');
compiled({ value: true }); // returns 'Yes'
// Use the "variable" option to specify the data object variable name.
const compiled = template('<%= data.value %>', { variable: 'data' });
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "imports" option to import functions.
const compiled = template('<%= _.toUpper(value) %>', { imports: { _: { toUpper } } });
compiled({ value: 'hello, world!' }); // returns 'HELLO, WORLD!'
// Use the custom "escape" delimiter.
const compiled = template('<@ value @>', { escape: /<@([\s\S]+?)@>/g });
compiled({ value: '<div>' }); // returns '&lt;div&gt;'
// Use the custom "evaluate" delimiter.
const compiled = template('<# if (value) { #>Yes<# } else { #>No<# } #>', { evaluate: /<#([\s\S]+?)#>/g });
compiled({ value: true }); // returns 'Yes'
// Use the custom "interpolate" delimiter.
const compiled = template('<$ value $>', { interpolate: /<\$([\s\S]+?)\$>/g });
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "sourceURL" option to specify the source URL of the template.
const compiled = template('hello <%= user %>!', { sourceURL: 'template.js' });
```

View File

@ -0,0 +1,72 @@
# template
::: info
This function is only available in `es-toolkit/compat` for compatibility reasons. It either has alternative native JavaScript APIs or isnt fully optimized yet.
When imported from `es-toolkit/compat`, it behaves exactly like lodash and provides the same functionalities, as detailed [here](../../../compatibility.md).
:::
Compiles a template string into a function that can interpolate data properties.
This function allows you to create a template with custom delimiters for escaping,
evaluating, and interpolating values. It can also handle custom variable names and
imported functions.
## Signature
```typescript
function template(string: string, options?: TemplateOptions): ((data?: object) => string) & { source: string };
```
### Parameters
- `string` (`string`): The template string.
- `options.escape` (`RegExp`): The regular expression for "escape" delimiter.
- `options.evaluate` (`RegExp`): The regular expression for "evaluate" delimiter.
- `options.interpolate` (`RegExp`): The regular expression for "interpolate" delimiter.
- `options.variable` (`string`): The data object variable name.
- `options.imports` (`Record<string, unknown>`): The object of imported functions.
- `options.sourceURL` (`string`): The source URL of the template.
### Returns
(`(data?: object) => string`): Returns the compiled template function.
## Examples
```typescript
// Use the "escape" delimiter to escape data properties.
const compiled = template('<%- value %>');
compiled({ value: '<div>' }); // returns '&lt;div&gt;'
// Use the "interpolate" delimiter to interpolate data properties.
const compiled = template('<%= value %>');
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "evaluate" delimiter to evaluate JavaScript code.
const compiled = template('<% if (value) { %>Yes<% } else { %>No<% } %>');
compiled({ value: true }); // returns 'Yes'
// Use the "variable" option to specify the data object variable name.
const compiled = template('<%= data.value %>', { variable: 'data' });
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "imports" option to import functions.
const compiled = template('<%= _.toUpper(value) %>', { imports: { _: { toUpper } } });
compiled({ value: 'hello, world!' }); // returns 'HELLO, WORLD!'
// Use the custom "escape" delimiter.
const compiled = template('<@ value @>', { escape: /<@([\s\S]+?)@>/g });
compiled({ value: '<div>' }); // returns '&lt;div&gt;'
// Use the custom "evaluate" delimiter.
const compiled = template('<# if (value) { #>Yes<# } else { #>No<# } #>', { evaluate: /<#([\s\S]+?)#>/g });
compiled({ value: true }); // returns 'Yes'
// Use the custom "interpolate" delimiter.
const compiled = template('<$ value $>', { interpolate: /<\$([\s\S]+?)\$>/g });
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "sourceURL" option to specify the source URL of the template.
const compiled = template('hello <%= user %>!', { sourceURL: 'template.js' });
```

View File

@ -0,0 +1,71 @@
# template
::: info
出于兼容性原因,此函数仅在 `es-toolkit/compat` 中提供。它可能具有替代的原生 JavaScript API或者尚未完全优化。
`es-toolkit/compat` 导入时,它的行为与 lodash 完全一致,并提供相同的功能,详情请见 [这里](../../../compatibility.md)。
:::
将模板字符串编译为一个可以插入数据属性的函数。
此函数允许您创建一个具有自定义定界符的模板,用于转义、评估和插入值。它还可以处理自定义变量名和导入的函数。
## 签名
```typescript
function template(string: string, options?: TemplateOptions): ((data?: object) => string) & { source: string };
```
### 参数
- `string` (`string`): 模板字符串。
- `options.escape` (`RegExp`): "escape" 定界符的正则表达式。
- `options.evaluate` (`RegExp`): "evaluate" 定界符的正则表达式。
- `options.interpolate` (`RegExp`): "interpolate" 定界符的正则表达式。
- `options.variable` (`string`): 数据对象的变量名。
- `options.imports` (`Record<string, unknown>`): 导入函数的对象。
- `options.sourceURL` (`string`): 模板的源 URL。
- `guard` (`unknown`): 检测函数是否使用 `options` 调用的保护。
### 返回值
(`(data?: object) => string`): 返回编译的模板函数。
## 示例
```typescript
// Use the "escape" delimiter to escape data properties.
const compiled = template('<%- value %>');
compiled({ value: '<div>' }); // returns '&lt;div&gt;'
// Use the "interpolate" delimiter to interpolate data properties.
const compiled = template('<%= value %>');
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "evaluate" delimiter to evaluate JavaScript code.
const compiled = template('<% if (value) { %>Yes<% } else { %>No<% } %>');
compiled({ value: true }); // returns 'Yes'
// Use the "variable" option to specify the data object variable name.
const compiled = template('<%= data.value %>', { variable: 'data' });
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "imports" option to import functions.
const compiled = template('<%= _.toUpper(value) %>', { imports: { _: { toUpper } } });
compiled({ value: 'hello, world!' }); // returns 'HELLO, WORLD!'
// Use the custom "escape" delimiter.
const compiled = template('<@ value @>', { escape: /<@([\s\S]+?)@>/g });
compiled({ value: '<div>' }); // returns '&lt;div&gt;'
// Use the custom "evaluate" delimiter.
const compiled = template('<# if (value) { #>Yes<# } else { #>No<# } #>', { evaluate: /<#([\s\S]+?)#>/g });
compiled({ value: true }); // returns 'Yes'
// Use the custom "interpolate" delimiter.
const compiled = template('<$ value $>', { interpolate: /<\$([\s\S]+?)\$>/g });
compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
// Use the "sourceURL" option to specify the source URL of the template.
const compiled = template('hello <%= user %>!', { sourceURL: 'template.js' });
```

View File

@ -0,0 +1 @@
export const numberTag = '[object Number]';

View File

@ -81,7 +81,8 @@ describe('intersection', () => {
it('should treat values that are not arrays or `arguments` objects as empty', () => { it('should treat values that are not arrays or `arguments` objects as empty', () => {
const array = [0, 1, null, 3]; const array = [0, 1, null, 3];
// @ts-ignore // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
expect(intersection(array, 3, { 0: 1 }, null)).toEqual([]); expect(intersection(array, 3, { 0: 1 }, null)).toEqual([]);
expect(intersection(null, array, null, [2, 3])).toEqual([]); expect(intersection(null, array, null, [2, 3])).toEqual([]);
expect(intersection(array, null, args, null)).toEqual([]); expect(intersection(array, null, args, null)).toEqual([]);

View File

@ -153,6 +153,7 @@ export { startCase } from './string/startCase.ts';
export { startsWith } from './string/startsWith.ts'; export { startsWith } from './string/startsWith.ts';
export { upperCase } from './string/upperCase.ts'; export { upperCase } from './string/upperCase.ts';
export { template, templateSettings } from './string/template.ts';
export { trim } from './string/trim.ts'; export { trim } from './string/trim.ts';
export { trimEnd } from './string/trimEnd.ts'; export { trimEnd } from './string/trimEnd.ts';
export { trimStart } from './string/trimStart.ts'; export { trimStart } from './string/trimStart.ts';

View File

@ -0,0 +1,471 @@
import { describe, expect, it } from 'vitest';
import { template, templateSettings } from './template';
import { numberTag } from '../_internal/numberTag';
import { stubFalse } from '../_internal/stubFalse';
import { stubString } from '../_internal/stubString';
import { stubTrue } from '../_internal/stubTrue';
import * as esToolkit from '../index';
describe('template', () => {
it('should escape values in "escape" delimiters', () => {
const strings = ['<p><%- value %></p>', '<p><%-value%></p>', '<p><%-\nvalue\n%></p>'];
const expected = strings.map(esToolkit.constant('<p>&amp;&lt;&gt;&quot;&#39;/</p>'));
const data = { value: '&<>"\'/' };
const actual = strings.map(string => template(string)(data));
expect(actual).toEqual(expected);
});
it('should not reference `_.escape` when "escape" delimiters are not used', () => {
const compiled = template('<%= typeof __e %>');
expect(compiled({})).toBe('undefined');
});
it('should evaluate JavaScript in "evaluate" delimiters', () => {
const compiled = template(
'<ul><%\
for (var key in collection) {\
%><li><%= collection[key] %></li><%\
} %></ul>'
);
const data = { collection: { a: 'A', b: 'B' } };
const actual = compiled(data);
expect(actual).toBe('<ul><li>A</li><li>B</li></ul>');
});
it('should support "evaluate" delimiters with single line comments (test production builds)', () => {
const compiled = template('<% // A code comment. %><% if (value) { %>yap<% } else { %>nope<% } %>');
const data = { value: true };
expect(compiled(data)).toBe('yap');
});
it('should support referencing variables declared in "evaluate" delimiters from other delimiters', () => {
const compiled = template('<% var b = a; %><%= b.value %>');
const data = { a: { value: 1 } };
expect(compiled(data)).toBe('1');
});
it('should interpolate data properties in "interpolate" delimiters', () => {
const strings = ['<%= a %>BC', '<%=a%>BC', '<%=\na\n%>BC'];
const expected = strings.map(esToolkit.constant('ABC'));
const data = { a: 'A' };
const actual = strings.map(string => template(string)(data));
expect(actual).toEqual(expected);
});
it('should support "interpolate" delimiters with escaped values', () => {
const compiled = template('<%= a ? "a=\\"A\\"" : "" %>');
const data = { a: true };
expect(compiled(data)).toBe('a="A"');
});
it('should support "interpolate" delimiters containing ternary operators', () => {
const compiled = template('<%= value ? value : "b" %>');
const data = { value: 'a' };
expect(compiled(data)).toBe('a');
});
it('should support "interpolate" delimiters containing global values', () => {
const compiled = template('<%= typeof Math.abs %>');
const actual = compiled();
expect(actual).toBe('function');
});
it('should support complex "interpolate" delimiters', () => {
Object.entries({
'<%= a + b %>': '3',
'<%= b - a %>': '1',
'<%= a = b %>': '2',
'<%= !a %>': 'false',
'<%= ~a %>': '-2',
'<%= a * b %>': '2',
'<%= a / b %>': '0.5',
'<%= a % b %>': '1',
'<%= a >> b %>': '0',
'<%= a << b %>': '4',
'<%= a & b %>': '0',
'<%= a ^ b %>': '3',
'<%= a | b %>': '3',
'<%= {}.toString.call(0) %>': numberTag,
'<%= a.toFixed(2) %>': '1.00',
'<%= obj["a"] %>': '1',
'<%= delete a %>': 'true',
'<%= "a" in obj %>': 'true',
'<%= obj instanceof Object %>': 'true',
'<%= new Boolean %>': 'false',
'<%= typeof a %>': 'number',
'<%= void a %>': '',
}).forEach(([key, value]) => {
const compiled = template(key);
const data = { a: 1, b: 2 };
expect(compiled(data)).toBe(value);
});
});
it('should support ES6 template delimiters', () => {
const data = { value: 2 };
expect(template('1${value}3')(data)).toBe('123');
expect(template('${"{" + value + "\\}"}')(data)).toBe('{2}');
});
it('should support the "imports" option', () => {
const compiled = template('<%= a %>', { imports: { a: 1 } });
expect(compiled({})).toBe('1');
});
it('should support the "variable" options', () => {
// We don't have `each` function.
// const compiled = template('<% _.each( data.a, function( value ) { %>' + '<%= value.valueOf() %>' + '<% }) %>', {
// variable: 'data',
// });
// const data = { a: [1, 2, 3] };
// expect(compiled(data)).toBe('123');
const compiled = template('<%= data.a %>', { variable: 'data' });
const data = { a: [1, 2, 3] };
expect(compiled(data)).toBe('1,2,3');
});
it('should support custom delimiters', () => {
esToolkit.times(2, index => {
const settingsClone = esToolkit.clone(templateSettings);
const settings = Object.assign(index ? templateSettings : {}, {
escape: /\{\{-([\s\S]+?)\}\}/g,
evaluate: /\{\{([\s\S]+?)\}\}/g,
interpolate: /\{\{=([\s\S]+?)\}\}/g,
});
const expected = '<ul><li>0: a &amp; A</li><li>1: b &amp; B</li></ul>';
// We don't have `each` function.
// const compiled = template(
// '<ul>{{ _.each(collection, function(value, index) {}}<li>{{= index }}: {{- value }}</li>{{}); }}</ul>',
// index ? null : settings
// );
const compiled = template(
'<ul>{{ collection.forEach((value, index) => {}}<li>{{= index }}: {{- value }}</li>{{}); }}</ul>',
index ? (null as any) : settings
);
const data = { collection: ['a & A', 'b & B'] };
expect(compiled(data)).toBe(expected);
Object.assign(templateSettings, settingsClone);
});
});
it('should support custom delimiters containing special characters', () => {
esToolkit.times(2, index => {
const settingsClone = esToolkit.clone(templateSettings);
const settings = Object.assign(index ? templateSettings : {}, {
escape: /<\?-([\s\S]+?)\?>/g,
evaluate: /<\?([\s\S]+?)\?>/g,
interpolate: /<\?=([\s\S]+?)\?>/g,
});
const expected = '<ul><li>0: a &amp; A</li><li>1: b &amp; B</li></ul>';
// We don't have `each` function.
// const compiled = template(
// '<ul><? _.each(collection, function(value, index) { ?><li><?= index ?>: <?- value ?></li><? }); ?></ul>',
// index ? null : settings
// );
const compiled = template(
'<ul><? collection.forEach((value, index) => { ?><li><?= index ?>: <?- value ?></li><? }); ?></ul>',
index ? (null as any) : settings
);
const data = { collection: ['a & A', 'b & B'] };
expect(compiled(data)).toBe(expected);
Object.assign(templateSettings, settingsClone);
});
});
it('should use a `with` statement by default', () => {
// We don't have `each` function.
// const compiled = template(
// '<%= index %><%= collection[index] %><% _.each(collection, function(value, index) { %><%= index %><% }); %>'
// );
const compiled = template(
'<%= index %><%= collection[index] %><% collection.forEach((value, index) => { %><%= index %><% }); %>'
);
const actual = compiled({ index: 1, collection: ['a', 'b', 'c'] });
expect(actual).toBe('1b012');
});
// We couldn't change imported modules because of bundling settings.
// it('should use `_.templateSettings.imports._.templateSettings`', () => {
// const toolkit = templateSettings.imports._;
// const settingsClone = esToolkit.clone(toolkit.templateSettings);
// toolkit.templateSettings = Object.assign(toolkit.templateSettings, {
// interpolate: /\{\{=([\s\S]+?)\}\}/g,
// });
// const compiled = template('{{= a }}');
// expect(compiled({ a: 1 })).toBe('1');
// Object.assign(toolkit.templateSettings, settingsClone);
// });
it('should fallback to `_.templateSettings`', () => {
const esToolkit = templateSettings.imports._;
const delimiter = templateSettings.interpolate;
templateSettings.imports._ = { escape: esToolkit.escape } as any;
templateSettings.interpolate = /\{\{=([\s\S]+?)\}\}/g;
const compiled = template('{{= a }}');
expect(compiled({ a: 1 })).toBe('1');
templateSettings.imports._ = esToolkit;
templateSettings.interpolate = delimiter;
});
it('should ignore `null` delimiters', () => {
const delimiter = {
escape: /\{\{-([\s\S]+?)\}\}/g,
evaluate: /\{\{([\s\S]+?)\}\}/g,
interpolate: /\{\{=([\s\S]+?)\}\}/g,
};
(
[
['escape', '{{- a }}'],
['evaluate', '{{ print(a) }}'],
['interpolate', '{{= a }}'],
] as const
).forEach(([key, value]) => {
const settings: any = { escape: null, evaluate: null, interpolate: null };
settings[key] = delimiter[key];
try {
const expected = '1 <%- a %> <% print(a) %> <%= a %>';
const compiled = template(`${value} <%- a %> <% print(a) %> <%= a %>`, settings);
const data = { a: 1 };
expect(compiled(data)).toBe(expected);
} catch (e) {
console.log(e);
}
});
});
it('should work without delimiters', () => {
const expected = 'abc';
expect(template(expected)({})).toBe(expected);
});
it('should work with `this` references', () => {
const compiled = template('a<%= this.String("b") %>c');
expect(compiled()).toBe('abc');
const object: any = { b: 'B' };
object.compiled = template('A<%= this.b %>C', { variable: 'obj' });
expect(object.compiled()).toBe('ABC');
});
it('should work with backslashes', () => {
const compiled = template('<%= a %> \\b');
const data = { a: 'A' };
expect(compiled(data)).toBe('A \\b');
});
it('should work with escaped characters in string literals', () => {
let compiled = template('<% print("\'\\n\\r\\t\\u2028\\u2029\\\\") %>');
expect(compiled()).toBe("'\n\r\t\u2028\u2029\\");
const data = { a: 'A' };
compiled = template('\'\n\r\t<%= a %>\u2028\u2029\\"');
expect(compiled(data)).toBe('\'\n\r\tA\u2028\u2029\\"');
});
it('should handle \\u2028 & \\u2029 characters', () => {
const compiled = template('\u2028<%= "\\u2028\\u2029" %>\u2029');
expect(compiled()).toBe('\u2028\u2028\u2029\u2029');
});
it('should work with statements containing quotes', () => {
const compiled = template(
'<%\
if (a === \'A\' || a === "a") {\
%>\'a\',"A"<%\
} %>'
);
const data = { a: 'A' };
expect(compiled(data), '\'a\').toBe("A"');
});
it('should work with templates containing newlines and comments', () => {
const compiled = template(
'<%\n\
// A code comment.\n\
if (value) { value += 3; }\n\
%><p><%= value %></p>'
);
expect(compiled({ value: 3 })).toBe('<p>6</p>');
});
it('should tokenize delimiters', () => {
const compiled = template('<span class="icon-<%= type %>2"></span>');
const data = { type: 1 };
expect(compiled(data)).toBe('<span class="icon-12"></span>');
});
it('should evaluate delimiters once', () => {
const actual: any[] = [];
const compiled = template('<%= func("a") %><%- func("b") %><% func("c") %>');
const data = {
func: function (value: any) {
actual.push(value);
},
};
compiled(data);
expect(actual).toEqual(['a', 'b', 'c']);
});
it('should match delimiters before escaping text', () => {
const compiled = template('<<\n a \n>>', { evaluate: /<<(.*?)>>/g });
expect(compiled()).toBe('<<\n a \n>>');
});
it('should resolve nullish values to an empty string', () => {
let compiled = template('<%= a %><%- a %>');
let data: any = { a: null };
expect(compiled(data)).toBe('');
data = { a: undefined };
expect(compiled(data)).toBe('');
data = { a: {} };
compiled = template('<%= a.b %><%- a.b %>');
expect(compiled(data)).toBe('');
});
it('should return an empty string for empty values', () => {
// eslint-disable-next-line no-sparse-arrays
const values = [, null, undefined, ''];
const expected = values.map(stubString);
const data = { a: 1 };
const actual = values.map((value, index) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const compiled = index ? template(value) : template();
return compiled(data);
});
expect(actual).toEqual(expected);
});
it('should parse delimiters without newlines', () => {
const expected = '<<\nprint("<p>" + (value ? "yes" : "no") + "</p>")\n>>';
const compiled = template(expected, { evaluate: /<<(.+?)>>/g });
const data = { value: true };
expect(compiled(data)).toBe(expected);
});
it('should support recursive calls', () => {
const compiled = template('<%= a %><% a = _.template(c)(obj) %><%= a %>');
const data = { a: 'A', b: 'B', c: '<%= b %>' };
expect(compiled(data)).toBe('AB');
});
it('should coerce `text` to a string', () => {
const object = { toString: esToolkit.constant('<%= a %>') };
const data = { a: 1 };
expect(template(object as string)(data)).toBe('1');
});
it('should not modify the `options` object', () => {
const options = {};
template('', options);
expect(options).toEqual({});
});
it('should not modify `_.templateSettings` when `options` are given', () => {
const data = { a: 1 };
expect('a' in templateSettings).toBe(false);
template('', {}, data);
expect('a' in templateSettings).toBe(false);
});
it('should not error for non-object `data` and `options` values', () => {
template('')(1 as any);
expect(true, '`data` value');
template('', 1 as any)(1 as any);
expect(true, '`options` value');
});
it('should expose the source on compiled templates', () => {
const compiled = template('x');
const values = [String(compiled), compiled.source];
const expected = values.map(stubTrue);
const actual = values.map(value => esToolkit.includes(value, '__p'));
expect(actual).toEqual(expected);
});
it('should expose the source on SyntaxErrors', () => {
try {
template('<% if x %>');
} catch (e: any) {
if (e instanceof SyntaxError) {
expect(esToolkit.includes((e as any).source, '__p')).toBe(true);
}
}
});
it('should not include sourceURLs in the source', () => {
const options = { sourceURL: '/a/b/c' };
const compiled = template('x', options);
const values = [compiled.source, undefined];
try {
template('<% if x %>', options);
} catch (e: any) {
values[1] = e.source;
}
const expected = values.map(stubFalse);
const actual = values.map(value => esToolkit.includes(value as any, 'sourceURL'));
expect(actual).toEqual(expected);
});
it('should work as an iteratee for methods like `_.map`', () => {
const array = ['<%= a %>', '<%- b %>', '<% print(c) %>'];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const compiles = array.map(template);
const data = { a: 'one', b: '"two"', c: 'three' };
const actual = compiles.map(compiled => compiled(data));
expect(actual).toEqual(['one', '&quot;two&quot;', 'three']);
});
});

View File

@ -0,0 +1,190 @@
import { escape } from './escape.ts';
import { attempt } from '../function/attempt.ts';
import { defaults } from '../object/defaults.ts';
import { toString } from '../util/toString.ts';
// A regular expression for matching literal string in ES template string.
const esTemplateRegExp = /\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g;
// A regular expression for matching unescaped characters in string.
const unEscapedRegExp = /['\n\r\u2028\u2029\\]/g;
// A regular expression for matching no match.
const noMatchExp = /($^)/;
const escapeMap = new Map([
['\\', '\\'],
["'", "'"],
['\n', 'n'],
['\r', 'r'],
['\u2028', 'u2028'],
['\u2029', 'u2029'],
]);
function escapeString(match: string): string {
return `\\${escapeMap.get(match)}`;
}
// Only import the necessary functions for preventing circular dependencies.(lodash-es also does this)
export const templateSettings = {
escape: /<%-([\s\S]+?)%>/g,
evaluate: /<%([\s\S]+?)%>/g,
interpolate: /<%=([\s\S]+?)%>/g,
variable: '',
imports: {
_: {
escape,
template,
},
},
};
interface TemplateOptions {
escape?: RegExp;
evaluate?: RegExp;
interpolate?: RegExp;
variable?: string;
imports?: Record<string, unknown>;
sourceURL?: string;
}
/**
* Compiles a template string into a function that can interpolate data properties.
*
* This function allows you to create a template with custom delimiters for escaping,
* evaluating, and interpolating values. It can also handle custom variable names and
* imported functions.
*
* @param {string} string - The template string.
* @param {TemplateOptions} [options] - The options object.
* @param {RegExp} [options.escape] - The regular expression for "escape" delimiter.
* @param {RegExp} [options.evaluate] - The regular expression for "evaluate" delimiter.
* @param {RegExp} [options.interpolate] - The regular expression for "interpolate" delimiter.
* @param {string} [options.variable] - The data object variable name.
* @param {Record<string, unknown>} [options.imports] - The object of imported functions.
* @param {string} [options.sourceURL] - The source URL of the template.
* @param {unknown} [guard] - The guard to detect if the function is called with `options`.
* @returns {(data?: object) => string} Returns the compiled template function.
*
* @example
* // Use the "escape" delimiter to escape data properties.
* const compiled = template('<%- value %>');
* compiled({ value: '<div>' }); // returns '&lt;div&gt;'
*
* @example
* // Use the "interpolate" delimiter to interpolate data properties.
* const compiled = template('<%= value %>');
* compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
*
* @example
* // Use the "evaluate" delimiter to evaluate JavaScript code.
* const compiled = template('<% if (value) { %>Yes<% } else { %>No<% } %>');
* compiled({ value: true }); // returns 'Yes'
*
* @example
* // Use the "variable" option to specify the data object variable name.
* const compiled = template('<%= data.value %>', { variable: 'data' });
* compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
*
* @example
* // Use the "imports" option to import functions.
* const compiled = template('<%= _.toUpper(value) %>', { imports: { _: { toUpper } } });
* compiled({ value: 'hello, world!' }); // returns 'HELLO, WORLD!'
*
* @example
* // Use the custom "escape" delimiter.
* const compiled = template('<@ value @>', { escape: /<@([\s\S]+?)@>/g });
* compiled({ value: '<div>' }); // returns '&lt;div&gt;'
*
* @example
* // Use the custom "evaluate" delimiter.
* const compiled = template('<# if (value) { #>Yes<# } else { #>No<# } #>', { evaluate: /<#([\s\S]+?)#>/g });
* compiled({ value: true }); // returns 'Yes'
*
* @example
* // Use the custom "interpolate" delimiter.
* const compiled = template('<$ value $>', { interpolate: /<\$([\s\S]+?)\$>/g });
* compiled({ value: 'Hello, World!' }); // returns 'Hello, World!'
*
* @example
* // Use the "sourceURL" option to specify the source URL of the template.
* const compiled = template('hello <%= user %>!', { sourceURL: 'template.js' });
*/
export function template(
string: string,
options?: TemplateOptions,
guard?: unknown
): ((data?: object) => string) & { source: string } {
string = toString(string);
if (guard) {
options = templateSettings;
}
options = defaults({ ...options }, templateSettings);
const delimitersRegExp = new RegExp(
[
options.escape?.source ?? noMatchExp.source,
options.interpolate?.source ?? noMatchExp.source,
options.interpolate ? esTemplateRegExp.source : noMatchExp.source,
options.evaluate?.source ?? noMatchExp.source,
'$',
].join('|'),
'g'
);
let lastIndex = 0;
let isEvaluated = false;
let source = `__p += ''`;
for (const match of string.matchAll(delimitersRegExp)) {
const [fullMatch, escapeValue, interpolateValue, esTemplateValue, evaluateValue] = match;
const { index } = match;
source += ` + '${string.slice(lastIndex, index).replace(unEscapedRegExp, escapeString)}'`;
if (escapeValue) {
source += ` + _.escape(${escapeValue})`;
}
if (interpolateValue) {
source += ` + ((${interpolateValue}) == null ? '' : ${interpolateValue})`;
} else if (esTemplateValue) {
source += ` + ((${esTemplateValue}) == null ? '' : ${esTemplateValue})`;
}
if (evaluateValue) {
source += `;\n${evaluateValue};\n __p += ''`;
isEvaluated = true;
}
lastIndex = index + fullMatch.length;
}
const imports = defaults({ ...options.imports }, templateSettings.imports);
const importsKeys = Object.keys(imports);
const importValues = Object.values(imports);
const sourceURL = `//# sourceURL=${
options.sourceURL ? String(options.sourceURL).replace(/[\r\n]/g, ' ') : `es-toolkit.templateSource[${Date.now()}]`
}\n`;
const compiledFunction = `function(${options.variable || 'obj'}) {
let __p = '';
${options.variable ? '' : 'if (obj == null) { obj = {}; }'}
${isEvaluated ? `function print() { __p += Array.prototype.join.call(arguments, ''); }` : ''}
${options.variable ? source : `with(obj) {\n${source}\n}`}
return __p;
}`;
const result = attempt(() => new Function(...importsKeys, `${sourceURL}return ${compiledFunction}`)(...importValues));
result.source = compiledFunction;
if (result instanceof Error) {
throw result;
}
return result;
}