Skip to content

Commit 9b7922c

Browse files
committed
Rewrite to parser
1 parent 460d3ec commit 9b7922c

File tree

5 files changed

+161
-55
lines changed

5 files changed

+161
-55
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"dist/"
2929
],
3030
"scripts": {
31+
"bench": "vitest bench",
3132
"build": "ts-scripts build",
3233
"format": "ts-scripts format",
3334
"lint": "ts-scripts lint",

src/index.bench.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { describe, bench } from "vitest";
2+
import { template } from "./index";
3+
4+
describe("template", () => {
5+
const fn = template("Hello {{name}}!");
6+
7+
bench("exec", () => {
8+
fn({ name: "Blake" });
9+
});
10+
});

src/index.spec.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,27 @@ describe("string-template", () => {
2020
expect(fn({ test: "are" })).toEqual("\"Some things\" are 'quoted'");
2121
});
2222

23-
it("should escape backslashes", () => {
23+
it("should handle backslashes", () => {
2424
const fn = template("test\\");
2525

26-
expect(fn({})).toEqual("test\\");
26+
expect(fn({})).toEqual("test");
2727
});
2828

29-
it("should allow functions", () => {
30-
const fn = template("{{test()}}");
29+
it("should handle escaped characters", () => {
30+
const fn = template("foo\\bar");
3131

32-
expect(fn({ test: () => "help" })).toEqual("help");
32+
expect(fn({})).toEqual("foobar");
3333
});
3434

35-
it("should allow bracket syntax reference", () => {
36-
const fn = template("{{['test']}}");
35+
it("should allow nested reference", () => {
36+
const fn = template("{{foo.bar}}");
3737

38-
expect(fn({ test: "hello" })).toEqual("hello");
38+
expect(fn({ foo: { bar: "hello" } })).toEqual("hello");
39+
});
40+
41+
it("should not access prototype properties", () => {
42+
const fn = template("{{toString}}");
43+
44+
expect(() => fn({})).toThrow(TypeError);
3945
});
4046
});

src/index.ts

Lines changed: 135 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,151 @@
1-
const INPUT_VAR_NAME = "it";
2-
const QUOTE_CHAR = '"';
3-
const ESCAPE_CHAR = "\\";
4-
51
export type Template<T extends object> = (data: T) => string;
62

7-
/**
8-
* Stringify a template into a function.
9-
*/
10-
export function compile(value: string) {
11-
let result = QUOTE_CHAR;
12-
for (let i = 0; i < value.length; i++) {
13-
const char = value[i];
14-
15-
// Escape special characters due to quoting.
16-
if (char === QUOTE_CHAR || char === ESCAPE_CHAR) {
17-
result += ESCAPE_CHAR;
18-
}
19-
20-
// Process template param.
21-
if (char === "{" && value[i + 1] === "{") {
22-
const start = i + 2;
23-
let end = 0;
24-
let withinString = "";
25-
26-
for (let j = start; j < value.length; j++) {
27-
const char = value[j];
28-
if (withinString) {
29-
if (char === ESCAPE_CHAR) j++;
30-
else if (char === withinString) withinString = "";
31-
continue;
32-
} else if (char === "}" && value[j + 1] === "}") {
33-
i = j + 1;
34-
end = j;
35-
break;
36-
} else if (char === '"' || char === "'" || char === "`") {
37-
withinString = char;
38-
}
39-
}
3+
function* parse(value: string): Generator<Token, Token> {
4+
let index = 0;
405

41-
if (!end) throw new TypeError(`Template parameter not closed at ${i}`);
6+
while (index < value.length) {
7+
if (value[index] === "\\") {
8+
yield { type: "ESCAPED", index, value: value[index + 1] || "" };
9+
index += 2;
10+
continue;
11+
}
12+
13+
if (value[index] === "{" && value[index + 1] === "{") {
14+
yield { type: "{{", index, value: "{{" };
15+
index += 2;
16+
continue;
17+
}
4218

43-
const param = value.slice(start, end).trim();
44-
const sep = param[0] === "[" ? "" : ".";
45-
result += `${QUOTE_CHAR} + (${INPUT_VAR_NAME}${sep}${param}) + ${QUOTE_CHAR}`;
19+
if (value[index] === "}" && value[index + 1] === "}") {
20+
yield { type: "}}", index, value: "{{" };
21+
index += 2;
4622
continue;
4723
}
4824

49-
result += char;
25+
yield { type: "CHAR", index, value: value[index++] };
26+
}
27+
28+
return { type: "END", index, value: "" };
29+
}
30+
31+
interface Token {
32+
type: "{{" | "}}" | "CHAR" | "ESCAPED" | "END";
33+
index: number;
34+
value: string;
35+
}
36+
37+
class It {
38+
#peek?: Token;
39+
40+
constructor(private tokens: Generator<Token, Token>) {}
41+
42+
peek(): Token {
43+
if (!this.#peek) {
44+
const next = this.tokens.next();
45+
this.#peek = next.value;
46+
}
47+
return this.#peek;
48+
}
49+
50+
tryConsume(type: Token["type"]): Token | undefined {
51+
const token = this.peek();
52+
if (token.type !== type) return undefined;
53+
this.#peek = undefined;
54+
return token;
5055
}
51-
result += QUOTE_CHAR;
5256

53-
return `function (${INPUT_VAR_NAME}) { return ${result}; }`;
57+
consume(type: Token["type"]): Token {
58+
const token = this.peek();
59+
if (token.type !== type) {
60+
throw new TypeError(
61+
`Unexpected ${token.type} at index ${token.index}, expected ${type}`,
62+
);
63+
}
64+
this.#peek = undefined;
65+
return token;
66+
}
5467
}
5568

5669
/**
5770
* Fast and simple string templates.
5871
*/
5972
export function template<T extends object = object>(value: string) {
60-
const body = compile(value);
61-
return new Function(`return (${body});`)() as Template<T>;
73+
const it = new It(parse(value));
74+
const values: Array<string | Template<T>> = [];
75+
let text = "";
76+
77+
while (true) {
78+
const value = it.tryConsume("CHAR") || it.tryConsume("ESCAPED");
79+
if (value) {
80+
text += value.value;
81+
continue;
82+
}
83+
84+
if (text) {
85+
values.push(text);
86+
text = "";
87+
}
88+
89+
if (it.tryConsume("{{")) {
90+
const path: string[] = [];
91+
let key = "";
92+
93+
while (true) {
94+
const escaped = it.tryConsume("ESCAPED");
95+
if (escaped) {
96+
key += escaped.value;
97+
continue;
98+
}
99+
100+
const char = it.tryConsume("CHAR");
101+
if (char) {
102+
if (char.value === ".") {
103+
path.push(key);
104+
key = "";
105+
continue;
106+
}
107+
key += char.value;
108+
continue;
109+
}
110+
111+
path.push(key);
112+
it.consume("}}");
113+
break;
114+
}
115+
116+
values.push(getter(path));
117+
continue;
118+
}
119+
120+
it.consume("END");
121+
break;
122+
}
123+
124+
return (data: T) => {
125+
let result = "";
126+
for (const value of values) {
127+
result += typeof value === "string" ? value : value(data);
128+
}
129+
return result;
130+
};
131+
}
132+
133+
const hasOwnProperty = Object.prototype.hasOwnProperty;
134+
135+
function getter(path: string[]) {
136+
return (data: any) => {
137+
let value = data;
138+
for (const key of path) {
139+
if (hasOwnProperty.call(value, key)) {
140+
value = value[key];
141+
} else {
142+
throw new TypeError(`Missing ${path.map(escape).join(".")} in data`);
143+
}
144+
}
145+
return value;
146+
};
147+
}
148+
149+
function escape(key: string) {
150+
return key.replace(/\./g, "\\.");
62151
}

tsconfig.build.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
"compilerOptions": {
44
"types": []
55
},
6-
"exclude": ["src/**/*.spec.ts"]
6+
"exclude": ["src/**/*.spec.ts", "src/**/*.bench.ts"]
77
}

0 commit comments

Comments
 (0)