mirror of
https://github.com/toss/es-toolkit.git
synced 2024-11-24 03:32:58 +03:00
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:
parent
bd7468fcd0
commit
adbe18e34d
50
benchmarks/performance/template.bench.ts
Normal file
50
benchmarks/performance/template.bench.ts
Normal 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>' });
|
||||
});
|
||||
});
|
71
docs/ja/reference/compat/string/template.md
Normal file
71
docs/ja/reference/compat/string/template.md
Normal 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 '<div>'
|
||||
|
||||
// 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 '<div>'
|
||||
|
||||
// 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' });
|
||||
```
|
71
docs/ko/reference/compat/string/template.md
Normal file
71
docs/ko/reference/compat/string/template.md
Normal 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 '<div>'
|
||||
|
||||
// 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 '<div>'
|
||||
|
||||
// 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' });
|
||||
```
|
72
docs/reference/compat/string/template.md
Normal file
72
docs/reference/compat/string/template.md
Normal 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 isn’t 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 '<div>'
|
||||
|
||||
// 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 '<div>'
|
||||
|
||||
// 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' });
|
||||
```
|
71
docs/zh_hans/reference/compat/string/template.md
Normal file
71
docs/zh_hans/reference/compat/string/template.md
Normal 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 '<div>'
|
||||
|
||||
// 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 '<div>'
|
||||
|
||||
// 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' });
|
||||
```
|
1
src/compat/_internal/numberTag.ts
Normal file
1
src/compat/_internal/numberTag.ts
Normal file
@ -0,0 +1 @@
|
||||
export const numberTag = '[object Number]';
|
@ -81,7 +81,8 @@ describe('intersection', () => {
|
||||
|
||||
it('should treat values that are not arrays or `arguments` objects as empty', () => {
|
||||
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(null, array, null, [2, 3])).toEqual([]);
|
||||
expect(intersection(array, null, args, null)).toEqual([]);
|
||||
|
@ -153,6 +153,7 @@ export { startCase } from './string/startCase.ts';
|
||||
export { startsWith } from './string/startsWith.ts';
|
||||
export { upperCase } from './string/upperCase.ts';
|
||||
|
||||
export { template, templateSettings } from './string/template.ts';
|
||||
export { trim } from './string/trim.ts';
|
||||
export { trimEnd } from './string/trimEnd.ts';
|
||||
export { trimStart } from './string/trimStart.ts';
|
||||
|
471
src/compat/string/template.spec.ts
Normal file
471
src/compat/string/template.spec.ts
Normal 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>&<>"'/</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 & A</li><li>1: b & 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 & A</li><li>1: b & 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', '"two"', 'three']);
|
||||
});
|
||||
});
|
190
src/compat/string/template.ts
Normal file
190
src/compat/string/template.ts
Normal 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 '<div>'
|
||||
*
|
||||
* @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 '<div>'
|
||||
*
|
||||
* @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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user