From adbe18e34dcff2814df026295365232ea260d573 Mon Sep 17 00:00:00 2001 From: Dayong Lee Date: Thu, 24 Oct 2024 21:44:29 +0900 Subject: [PATCH] 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 --- benchmarks/performance/template.bench.ts | 50 ++ docs/ja/reference/compat/string/template.md | 71 +++ docs/ko/reference/compat/string/template.md | 71 +++ docs/reference/compat/string/template.md | 72 +++ .../reference/compat/string/template.md | 71 +++ src/compat/_internal/numberTag.ts | 1 + src/compat/array/intersection.spec.ts | 3 +- src/compat/index.ts | 1 + src/compat/string/template.spec.ts | 471 ++++++++++++++++++ src/compat/string/template.ts | 190 +++++++ 10 files changed, 1000 insertions(+), 1 deletion(-) create mode 100644 benchmarks/performance/template.bench.ts create mode 100644 docs/ja/reference/compat/string/template.md create mode 100644 docs/ko/reference/compat/string/template.md create mode 100644 docs/reference/compat/string/template.md create mode 100644 docs/zh_hans/reference/compat/string/template.md create mode 100644 src/compat/_internal/numberTag.ts create mode 100644 src/compat/string/template.spec.ts create mode 100644 src/compat/string/template.ts diff --git a/benchmarks/performance/template.bench.ts b/benchmarks/performance/template.bench.ts new file mode 100644 index 00000000..a6421ef8 --- /dev/null +++ b/benchmarks/performance/template.bench.ts @@ -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: '' }); + }); + + bench('lodash/template', () => { + const compiled = templateLodash('<%- user %>'); + compiled({ user: '' }); + }); +}); diff --git a/docs/ja/reference/compat/string/template.md b/docs/ja/reference/compat/string/template.md new file mode 100644 index 00000000..d03f24d5 --- /dev/null +++ b/docs/ja/reference/compat/string/template.md @@ -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`): インポートされた関数のオブジェクトです。 +- `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: '
' }); // 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: '
' }); // 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' }); +``` diff --git a/docs/ko/reference/compat/string/template.md b/docs/ko/reference/compat/string/template.md new file mode 100644 index 00000000..e2aa2156 --- /dev/null +++ b/docs/ko/reference/compat/string/template.md @@ -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`): 가져온 함수의 객체. +- `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: '
' }); // 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: '
' }); // 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' }); +``` diff --git a/docs/reference/compat/string/template.md b/docs/reference/compat/string/template.md new file mode 100644 index 00000000..e0e15658 --- /dev/null +++ b/docs/reference/compat/string/template.md @@ -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`): 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: '
' }); // 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: '
' }); // 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' }); +``` diff --git a/docs/zh_hans/reference/compat/string/template.md b/docs/zh_hans/reference/compat/string/template.md new file mode 100644 index 00000000..7032df9a --- /dev/null +++ b/docs/zh_hans/reference/compat/string/template.md @@ -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`): 导入函数的对象。 +- `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: '
' }); // 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: '
' }); // 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' }); +``` diff --git a/src/compat/_internal/numberTag.ts b/src/compat/_internal/numberTag.ts new file mode 100644 index 00000000..6b820110 --- /dev/null +++ b/src/compat/_internal/numberTag.ts @@ -0,0 +1 @@ +export const numberTag = '[object Number]'; diff --git a/src/compat/array/intersection.spec.ts b/src/compat/array/intersection.spec.ts index 86363f51..6e337af4 100644 --- a/src/compat/array/intersection.spec.ts +++ b/src/compat/array/intersection.spec.ts @@ -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([]); diff --git a/src/compat/index.ts b/src/compat/index.ts index fb5b0503..c1c1aa2f 100644 --- a/src/compat/index.ts +++ b/src/compat/index.ts @@ -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'; diff --git a/src/compat/string/template.spec.ts b/src/compat/string/template.spec.ts new file mode 100644 index 00000000..9cba952c --- /dev/null +++ b/src/compat/string/template.spec.ts @@ -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 = ['

<%- value %>

', '

<%-value%>

', '

<%-\nvalue\n%>

']; + const expected = strings.map(esToolkit.constant('

&<>"'/

')); + 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( + '
    <%\ + for (var key in collection) {\ + %>
  • <%= collection[key] %>
  • <%\ + } %>
' + ); + + const data = { collection: { a: 'A', b: 'B' } }; + const actual = compiled(data); + + expect(actual).toBe('
  • A
  • B
'); + }); + + 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 = '
  • 0: a & A
  • 1: b & B
'; + // We don't have `each` function. + // const compiled = template( + // '
    {{ _.each(collection, function(value, index) {}}
  • {{= index }}: {{- value }}
  • {{}); }}
', + // index ? null : settings + // ); + const compiled = template( + '
    {{ collection.forEach((value, index) => {}}
  • {{= index }}: {{- value }}
  • {{}); }}
', + 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 = '
  • 0: a & A
  • 1: b & B
'; + // We don't have `each` function. + // const compiled = template( + // '
  • :
', + // index ? null : settings + // ); + const compiled = template( + '
    { ?>
  • :
', + 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\ + %>

<%= value %>

' + ); + + expect(compiled({ value: 3 })).toBe('

6

'); + }); + + it('should tokenize delimiters', () => { + const compiled = template(''); + const data = { type: 1 }; + + expect(compiled(data)).toBe(''); + }); + + 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("

" + (value ? "yes" : "no") + "

")\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']); + }); +}); diff --git a/src/compat/string/template.ts b/src/compat/string/template.ts new file mode 100644 index 00000000..8628d235 --- /dev/null +++ b/src/compat/string/template.ts @@ -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; + 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} [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: '
' }); // 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: '
' }); // 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; +}