Skip to content

Commit

Permalink
Rewrite to parser
Browse files Browse the repository at this point in the history
  • Loading branch information
blakeembrey committed Sep 3, 2024
1 parent 460d3ec commit 9b7922c
Show file tree
Hide file tree
Showing 5 changed files with 161 additions and 55 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"dist/"
],
"scripts": {
"bench": "vitest bench",
"build": "ts-scripts build",
"format": "ts-scripts format",
"lint": "ts-scripts lint",
Expand Down
10 changes: 10 additions & 0 deletions src/index.bench.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
22 changes: 14 additions & 8 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
181 changes: 135 additions & 46 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,151 @@
const INPUT_VAR_NAME = "it";
const QUOTE_CHAR = '"';
const ESCAPE_CHAR = "\\";

export type Template<T extends object> = (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<Token, Token> {
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<Token, Token>) {}

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<T extends object = object>(value: string) {
const body = compile(value);
return new Function(`return (${body});`)() as Template<T>;
const it = new It(parse(value));
const values: Array<string | Template<T>> = [];
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, "\\.");
}
2 changes: 1 addition & 1 deletion tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
"compilerOptions": {
"types": []
},
"exclude": ["src/**/*.spec.ts"]
"exclude": ["src/**/*.spec.ts", "src/**/*.bench.ts"]
}

0 comments on commit 9b7922c

Please sign in to comment.