diff --git a/backend/src/packages/chaiNNer_standard/__init__.py b/backend/src/packages/chaiNNer_standard/__init__.py index cb66abe37..3ed2caf81 100644 --- a/backend/src/packages/chaiNNer_standard/__init__.py +++ b/backend/src/packages/chaiNNer_standard/__init__.py @@ -70,8 +70,8 @@ Dependency( display_name="ChaiNNer Extensions", pypi_name="chainner_ext", - version="0.0.0", - size_estimate=1.1 * MB, + version="0.1.0", + size_estimate=1.5 * MB, ), ], ) diff --git a/backend/src/packages/chaiNNer_standard/utility/text/regex_replace.py b/backend/src/packages/chaiNNer_standard/utility/text/regex_replace.py new file mode 100644 index 000000000..63d3d9cd3 --- /dev/null +++ b/backend/src/packages/chaiNNer_standard/utility/text/regex_replace.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from enum import Enum + +from chainner_ext import MatchGroup, RustRegex + +from nodes.properties.inputs import EnumInput, TextInput +from nodes.properties.outputs import TextOutput +from nodes.utils.replacement import ReplacementString + +from .. import text_group + + +class ReplacementMode(Enum): + REPLACE_ALL = 0 + REPLACE_FIRST = 1 + + +@text_group.register( + schema_id="chainner:utility:regex_replace", + name="Regex Replace", + description="Replace occurrences of some regex with a replacement text. Either or all occurrences or the first occurrence will be replaced", + icon="MdTextFields", + inputs=[ + TextInput("Text", min_length=0), + TextInput("Regex", min_length=0, placeholder=r'E.g. "\b\w+\b"'), + TextInput("Replacement Pattern", min_length=0, placeholder=r'E.g. "found {0}"'), + EnumInput( + ReplacementMode, + label="Replace mode", + default_value=ReplacementMode.REPLACE_ALL, + ), + ], + outputs=[ + TextOutput( + "Text", + output_type=""" + let count = match Input3 { + ReplacementMode::ReplaceAll => inf, + ReplacementMode::ReplaceFirst => 1, + }; + regexReplace(Input0, Input1, Input2, count) + """, + ).with_never_reason( + "Either the regex pattern or the replacement pattern is invalid" + ), + ], +) +def regex_replace( + text: str, + regex_pattern: str, + replacement_pattern: str, + mode: ReplacementMode, +) -> str: + # parse the inputs before we do any actual work + r = RustRegex(regex_pattern) + replacement = ReplacementString(replacement_pattern) + + matches = r.findall(text) + if len(matches) == 0: + return text + + if mode == ReplacementMode.REPLACE_FIRST: + matches = matches[:1] + + def get_group_text(group: MatchGroup | None) -> str: + if group is not None: + return text[group.start : group.end] + else: + return "" + + result = "" + last_end = 0 + for match in matches: + result += text[last_end : match.start] + + replacements: dict[str, str] = {} + for i in range(r.groups + 1): + replacements[str(i)] = get_group_text(match.get(i)) + for name, i in r.groupindex.items(): + replacements[name] = get_group_text(match.get(i)) + + result += replacement.replace(replacements) + last_end = match.end + + result += text[last_end:] + return result diff --git a/package-lock.json b/package-lock.json index d007b18c2..2dc1ea2ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "react-markdown": "^8.0.3", "react-time-ago": "^7.2.1", "reactflow": "^11.5.6", - "rregex": "^1.5.1", + "rregex": "^1.7.0", "scheduler": "^0.22.0", "semver": "^7.3.7", "systeminformation": "^5.11.16", @@ -87,6 +87,7 @@ "babel-loader": "^8.2.5", "babel-plugin-i18next-extract": "^0.9.0", "concurrently": "^7.2.1", + "copy-webpack-plugin": "^11.0.0", "cross-env": "^7.0.3", "css-loader": "^6.7.1", "electron": "^23.0.0", @@ -8374,6 +8375,126 @@ "toggle-selection": "^1.0.6" } }, + "node_modules/copy-webpack-plugin": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", + "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "dev": true, + "dependencies": { + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.1", + "globby": "^13.1.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/copy-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/globby": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.0.tgz", + "integrity": "sha512-jWsQfayf13NvqKUIL3Ta+CIqMnvlaIDFveWE/dpOZ9+3AMEJozsxDvKA02zync9UuvOM8rOXzsD5GqKP4OnWPQ==", + "dev": true, + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.11", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/copy-webpack-plugin/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/copy-webpack-plugin/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/core-js-pure": { "version": "3.26.1", "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.26.1.tgz", @@ -20972,9 +21093,9 @@ "optional": true }, "node_modules/rregex": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/rregex/-/rregex-1.5.1.tgz", - "integrity": "sha512-jt4xOJrLz05utn5tPnupM9+dIPH1O7PQnWQ+7xQoiNOZmvNBbEhEiXHO0Di7NOQPApSI0HmcfGoztukrKBj43g==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/rregex/-/rregex-1.7.0.tgz", + "integrity": "sha512-K95C6W+fyl538UGLz1VB2oRWw7CytMNH0Y0Xm1z/VkINbYlU5QK0jnABbwyc9H9cTQPO6/9Vf4uTMwZHsGn2sQ==" }, "node_modules/run-parallel": { "version": "1.2.0", @@ -30920,6 +31041,89 @@ "toggle-selection": "^1.0.6" } }, + "copy-webpack-plugin": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", + "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "dev": true, + "requires": { + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.1", + "globby": "^13.1.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + }, + "globby": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.0.tgz", + "integrity": "sha512-jWsQfayf13NvqKUIL3Ta+CIqMnvlaIDFveWE/dpOZ9+3AMEJozsxDvKA02zync9UuvOM8rOXzsD5GqKP4OnWPQ==", + "dev": true, + "requires": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.11", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^4.0.0" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + } + }, + "slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true + } + } + }, "core-js-pure": { "version": "3.26.1", "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.26.1.tgz", @@ -40215,9 +40419,9 @@ } }, "rregex": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/rregex/-/rregex-1.5.1.tgz", - "integrity": "sha512-jt4xOJrLz05utn5tPnupM9+dIPH1O7PQnWQ+7xQoiNOZmvNBbEhEiXHO0Di7NOQPApSI0HmcfGoztukrKBj43g==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/rregex/-/rregex-1.7.0.tgz", + "integrity": "sha512-K95C6W+fyl538UGLz1VB2oRWw7CytMNH0Y0Xm1z/VkINbYlU5QK0jnABbwyc9H9cTQPO6/9Vf4uTMwZHsGn2sQ==" }, "run-parallel": { "version": "1.2.0", diff --git a/package.json b/package.json index 6239d8774..92407d702 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "babel-loader": "^8.2.5", "babel-plugin-i18next-extract": "^0.9.0", "concurrently": "^7.2.1", + "copy-webpack-plugin": "^11.0.0", "cross-env": "^7.0.3", "css-loader": "^6.7.1", "electron": "^23.0.0", @@ -136,7 +137,7 @@ "react-markdown": "^8.0.3", "react-time-ago": "^7.2.1", "reactflow": "^11.5.6", - "rregex": "^1.5.1", + "rregex": "^1.7.0", "scheduler": "^0.22.0", "semver": "^7.3.7", "systeminformation": "^5.11.16", diff --git a/src/common/rust-regex.ts b/src/common/rust-regex.ts new file mode 100644 index 000000000..3ca76d1ff --- /dev/null +++ b/src/common/rust-regex.ts @@ -0,0 +1,17 @@ +import { isRenderer } from './env'; +import { log } from './log'; + +let imports; +if (isRenderer) { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + imports = require('rregex/lib/browser') as typeof import('rregex'); + + // This is not good, but I can't think of a better way. + // We are racing loading the wasm module and using it. + imports.default().catch(log.error); +} else { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + imports = require('rregex/lib/commonjs') as typeof import('rregex'); +} + +export class RRegex extends imports.RRegex {} diff --git a/src/common/types/chainner-builtin.ts b/src/common/types/chainner-builtin.ts index 48c71c4a3..782b0620f 100644 --- a/src/common/types/chainner-builtin.ts +++ b/src/common/types/chainner-builtin.ts @@ -4,6 +4,7 @@ import { IntIntervalType, Intrinsic, NeverType, + NumberPrimitive, StringLiteralType, StringPrimitive, StringType, @@ -12,11 +13,16 @@ import { handleNumberLiterals, intersect, literal, + wrapQuaternary, wrapTernary, wrapUnary, } from '@chainner/navi'; import path from 'path'; import { ColorJson } from '../common-types'; +import { log } from '../log'; +import { RRegex } from '../rust-regex'; +import { assertNever } from '../util'; +import type { Group, Hir } from 'rregex'; type ReplacementToken = | { type: 'literal'; value: string } @@ -25,6 +31,11 @@ type ReplacementToken = class ReplacementString { readonly tokens: readonly ReplacementToken[]; + /** + * The names of all replacements in this string. + * + * Example: `foo {4} bar {baz}` will have the names `4` and `baz`. + */ readonly names: ReadonlySet; constructor(pattern: string) { @@ -143,6 +154,159 @@ export const formatTextPattern = ( return Intrinsic.concat(...concatArgs); }; +/** + * Iterates over all groups in the given hir in order. + */ +function* iterGroups(hir: Hir): Iterable { + const { kind } = hir; + if (kind['@variant'] === 'Group') { + yield kind['@values'][0]; + } + + switch (kind['@variant']) { + case 'Anchor': + case 'Class': + case 'Empty': + case 'Literal': + case 'WordBoundary': { + break; + } + case 'Group': + case 'Repetition': { + yield* iterGroups(kind['@values'][0].hir); + break; + } + case 'Alternation': + case 'Concat': { + for (const h of kind['@values'][0]) { + yield* iterGroups(h); + } + break; + } + + default: + return assertNever(kind); + } +} +interface CapturingGroupInfo { + count: number; + names: string[]; +} +const getCapturingGroupInfo = (regex: RRegex): CapturingGroupInfo => { + let count = 0; + const names: string[] = []; + + for (const { kind } of iterGroups(regex.syntax())) { + if (kind['@variant'] === 'NonCapturing') { + // eslint-disable-next-line no-continue + continue; + } + if (kind['@variant'] === 'CaptureName') { + names.push(kind.name); + } + count += 1; + } + + return { count, names }; +}; +const regexReplaceImpl = ( + text: string, + regexPattern: string, + replacementPattern: string, + count: number +): string | undefined => { + // parse and validate before doing actual work + const regex = new RRegex(regexPattern); + const replacement = new ReplacementString(replacementPattern); + + // check replacement keys + const captures = getCapturingGroupInfo(regex); + const availableNames = new Set([ + ...captures.names, + '0', + ...Array.from({ length: captures.count }, (_, i) => String(i + 1)), + ]); + for (const name of replacement.names) { + if (!availableNames.has(name)) { + throw new Error( + 'Invalid replacement pattern.' + + ` "{${name}}" is not a valid replacement.` + + ` Available replacements: ${[...availableNames].join(', ')}.` + ); + } + } + + // do actual work + if (count === 0) { + return text; + } + const matches = regex.findAll(text).slice(0, Math.max(0, count || 0)); + if (matches.length === 0) { + return text; + } + + // rregex doesn't support captures right now, so we can only support {0} + // https://github.com/2fd/rregex/issues/32 + if (replacement.names.size > 0) { + const [first] = replacement.names; + if (replacement.names.size > 1 || first !== '0') { + return undefined; + } + } + + // rregex currently only supports byte offsets in matches. So we have to + // match spans on UTF8 and then convert it back to Unicode. + const utf8 = Buffer.from(text, 'utf8'); + const toUTF16 = (offset: number) => { + return utf8.toString('utf8', 0, offset).length; + }; + + let result = ''; + let lastIndex = 0; + for (const match of matches) { + result += text.slice(lastIndex, toUTF16(match.start)); + + const replacements = new Map(); + replacements.set('0', match.value); + result += replacement.replace(replacements); + + lastIndex = toUTF16(match.end); + } + result += text.slice(lastIndex); + + return result; +}; +export const regexReplace = wrapQuaternary< + StringPrimitive, + StringPrimitive, + StringPrimitive, + NumberPrimitive, + StringPrimitive +>((text, regexPattern, replacementPattern, count) => { + if ( + text.type === 'literal' && + regexPattern.type === 'literal' && + replacementPattern.type === 'literal' && + count.type === 'literal' + ) { + try { + const result = regexReplaceImpl( + text.value, + regexPattern.value, + replacementPattern.value, + count.value + ); + if (result !== undefined) { + return new StringLiteralType(result); + } + } catch (error) { + log.debug('regexReplaceImpl', error); + return NeverType.instance; + } + } + return StringType.instance; +}); + // Python-conform padding implementations. // The challenge here is that JS string lengths count UTF-16 char codes, // while python string lengths count Unicode code points. diff --git a/src/common/types/chainner-scope.ts b/src/common/types/chainner-scope.ts index 25a3bc839..9c97a4df9 100644 --- a/src/common/types/chainner-scope.ts +++ b/src/common/types/chainner-scope.ts @@ -15,6 +15,7 @@ import { padEnd, padStart, parseColorJson, + regexReplace, splitFilePath, } from './chainner-builtin'; @@ -139,6 +140,7 @@ struct SplitFilePath { } intrinsic def formatPattern(pattern: string, ...args: string | null): string; +intrinsic def regexReplace(text: string, regex: string, replacement: string, count: uint | inf): string; intrinsic def padStart(text: string, width: uint, padding: string): string; intrinsic def padEnd(text: string, width: uint, padding: string): string; intrinsic def padCenter(text: string, width: uint, padding: string): string; @@ -151,6 +153,7 @@ export const getChainnerScope = lazy((): Scope => { const intrinsic: Record Type> = { formatPattern: formatTextPattern, + regexReplace, padStart, padEnd, padCenter, diff --git a/src/renderer/helpers/nodeSearchFuncs.ts b/src/renderer/helpers/nodeSearchFuncs.ts index 000cff350..98f11c381 100644 --- a/src/renderer/helpers/nodeSearchFuncs.ts +++ b/src/renderer/helpers/nodeSearchFuncs.ts @@ -1,12 +1,7 @@ -import init, { RRegex } from 'rregex'; import { NodeSchema } from '../../common/common-types'; -import { log } from '../../common/log'; +import { RRegex } from '../../common/rust-regex'; import { lazy } from '../../common/util'; -// This is not good, but I can't think of a better way. -// We are racing loading the wasm module and using it. -init().catch(log.error); - const isLetter = lazy(() => new RRegex('(?is)^[a-z]$')); export const createSearchPredicate = (query: string): ((name: string) => boolean) => { if (!query) return () => true; diff --git a/webpack.main.config.js b/webpack.main.config.js index f5df6ab92..bb586038e 100644 --- a/webpack.main.config.js +++ b/webpack.main.config.js @@ -1,3 +1,6 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +const CopyPlugin = require('copy-webpack-plugin'); + /** @type {import("webpack").Configuration} */ module.exports = { /** @@ -12,6 +15,11 @@ module.exports = { // eslint-disable-next-line global-require rules: require('./webpack.rules'), }, + plugins: [ + new CopyPlugin({ + patterns: [{ from: 'node_modules/rregex/lib/rregex.wasm' }], + }), + ], resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx'], },