From 9b7922c80cecbd18d33dc94af4984f30371f161b Mon Sep 17 00:00:00 2001 From: Blake Embrey Date: Mon, 2 Sep 2024 19:22:38 -0700 Subject: [PATCH] Rewrite to parser --- package.json | 1 + src/index.bench.ts | 10 +++ src/index.spec.ts | 22 ++++-- src/index.ts | 181 +++++++++++++++++++++++++++++++++----------- tsconfig.build.json | 2 +- 5 files changed, 161 insertions(+), 55 deletions(-) create mode 100644 src/index.bench.ts diff --git a/package.json b/package.json index 378e133..3807073 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dist/" ], "scripts": { + "bench": "vitest bench", "build": "ts-scripts build", "format": "ts-scripts format", "lint": "ts-scripts lint", diff --git a/src/index.bench.ts b/src/index.bench.ts new file mode 100644 index 0000000..9fb7711 --- /dev/null +++ b/src/index.bench.ts @@ -0,0 +1,10 @@ +import { describe, bench } from "vitest"; +import { template } from "./index"; + +describe("template", () => { + const fn = template("Hello {{name}}!"); + + bench("exec", () => { + fn({ name: "Blake" }); + }); +}); diff --git a/src/index.spec.ts b/src/index.spec.ts index 5d4ba70..fcbf46a 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -20,21 +20,27 @@ describe("string-template", () => { expect(fn({ test: "are" })).toEqual("\"Some things\" are 'quoted'"); }); - it("should escape backslashes", () => { + it("should handle backslashes", () => { const fn = template("test\\"); - expect(fn({})).toEqual("test\\"); + expect(fn({})).toEqual("test"); }); - it("should allow functions", () => { - const fn = template("{{test()}}"); + it("should handle escaped characters", () => { + const fn = template("foo\\bar"); - expect(fn({ test: () => "help" })).toEqual("help"); + expect(fn({})).toEqual("foobar"); }); - it("should allow bracket syntax reference", () => { - const fn = template("{{['test']}}"); + it("should allow nested reference", () => { + const fn = template("{{foo.bar}}"); - expect(fn({ test: "hello" })).toEqual("hello"); + expect(fn({ foo: { bar: "hello" } })).toEqual("hello"); + }); + + it("should not access prototype properties", () => { + const fn = template("{{toString}}"); + + expect(() => fn({})).toThrow(TypeError); }); }); diff --git a/src/index.ts b/src/index.ts index 21fbb72..abdc291 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,62 +1,151 @@ -const INPUT_VAR_NAME = "it"; -const QUOTE_CHAR = '"'; -const ESCAPE_CHAR = "\\"; - export type Template = (data: T) => string; -/** - * Stringify a template into a function. - */ -export function compile(value: string) { - let result = QUOTE_CHAR; - for (let i = 0; i < value.length; i++) { - const char = value[i]; - - // Escape special characters due to quoting. - if (char === QUOTE_CHAR || char === ESCAPE_CHAR) { - result += ESCAPE_CHAR; - } - - // Process template param. - if (char === "{" && value[i + 1] === "{") { - const start = i + 2; - let end = 0; - let withinString = ""; - - for (let j = start; j < value.length; j++) { - const char = value[j]; - if (withinString) { - if (char === ESCAPE_CHAR) j++; - else if (char === withinString) withinString = ""; - continue; - } else if (char === "}" && value[j + 1] === "}") { - i = j + 1; - end = j; - break; - } else if (char === '"' || char === "'" || char === "`") { - withinString = char; - } - } +function* parse(value: string): Generator { + let index = 0; - if (!end) throw new TypeError(`Template parameter not closed at ${i}`); + while (index < value.length) { + if (value[index] === "\\") { + yield { type: "ESCAPED", index, value: value[index + 1] || "" }; + index += 2; + continue; + } + + if (value[index] === "{" && value[index + 1] === "{") { + yield { type: "{{", index, value: "{{" }; + index += 2; + continue; + } - const param = value.slice(start, end).trim(); - const sep = param[0] === "[" ? "" : "."; - result += `${QUOTE_CHAR} + (${INPUT_VAR_NAME}${sep}${param}) + ${QUOTE_CHAR}`; + if (value[index] === "}" && value[index + 1] === "}") { + yield { type: "}}", index, value: "{{" }; + index += 2; continue; } - result += char; + yield { type: "CHAR", index, value: value[index++] }; + } + + return { type: "END", index, value: "" }; +} + +interface Token { + type: "{{" | "}}" | "CHAR" | "ESCAPED" | "END"; + index: number; + value: string; +} + +class It { + #peek?: Token; + + constructor(private tokens: Generator) {} + + peek(): Token { + if (!this.#peek) { + const next = this.tokens.next(); + this.#peek = next.value; + } + return this.#peek; + } + + tryConsume(type: Token["type"]): Token | undefined { + const token = this.peek(); + if (token.type !== type) return undefined; + this.#peek = undefined; + return token; } - result += QUOTE_CHAR; - return `function (${INPUT_VAR_NAME}) { return ${result}; }`; + consume(type: Token["type"]): Token { + const token = this.peek(); + if (token.type !== type) { + throw new TypeError( + `Unexpected ${token.type} at index ${token.index}, expected ${type}`, + ); + } + this.#peek = undefined; + return token; + } } /** * Fast and simple string templates. */ export function template(value: string) { - const body = compile(value); - return new Function(`return (${body});`)() as Template; + const it = new It(parse(value)); + const values: Array> = []; + let text = ""; + + while (true) { + const value = it.tryConsume("CHAR") || it.tryConsume("ESCAPED"); + if (value) { + text += value.value; + continue; + } + + if (text) { + values.push(text); + text = ""; + } + + if (it.tryConsume("{{")) { + const path: string[] = []; + let key = ""; + + while (true) { + const escaped = it.tryConsume("ESCAPED"); + if (escaped) { + key += escaped.value; + continue; + } + + const char = it.tryConsume("CHAR"); + if (char) { + if (char.value === ".") { + path.push(key); + key = ""; + continue; + } + key += char.value; + continue; + } + + path.push(key); + it.consume("}}"); + break; + } + + values.push(getter(path)); + continue; + } + + it.consume("END"); + break; + } + + return (data: T) => { + let result = ""; + for (const value of values) { + result += typeof value === "string" ? value : value(data); + } + return result; + }; +} + +const hasOwnProperty = Object.prototype.hasOwnProperty; + +function getter(path: string[]) { + return (data: any) => { + let value = data; + for (const key of path) { + if (hasOwnProperty.call(value, key)) { + value = value[key]; + } else { + throw new TypeError(`Missing ${path.map(escape).join(".")} in data`); + } + } + return value; + }; +} + +function escape(key: string) { + return key.replace(/\./g, "\\."); } diff --git a/tsconfig.build.json b/tsconfig.build.json index d783ab3..3db8e88 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": [] }, - "exclude": ["src/**/*.spec.ts"] + "exclude": ["src/**/*.spec.ts", "src/**/*.bench.ts"] }