ventojs 2.0.0-canary.1 → 2.0.0-canary.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,7 @@ Vento 2.0 is now dependency-free and compatible with browsers without a build st
13
13
  - Support for precompiled templates.
14
14
  - New filesystem loader to use File System API.
15
15
  - Better errors reporting [#131], [#137]
16
+ - `core/errors.ts` module to format errors.
16
17
 
17
18
  ### Changed
18
19
  - Renamed `src` directory to `core`.
@@ -1,6 +1,6 @@
1
1
  import iterateTopLevel from "./js.js";
2
2
  import tokenize from "./tokenizer.js";
3
- import { createError, TokenError } from "./errors.js";
3
+ import { createError, SourceError } from "./errors.js";
4
4
  export class Environment {
5
5
  cache = new Map();
6
6
  options;
@@ -20,8 +20,8 @@ export class Environment {
20
20
  use(plugin) {
21
21
  plugin(this);
22
22
  }
23
- async run(file, data, from) {
24
- const template = await this.load(file, from);
23
+ async run(file, data, from, position) {
24
+ const template = await this.load(file, from, position);
25
25
  return await template(data);
26
26
  }
27
27
  async runString(source, data, file) {
@@ -41,25 +41,21 @@ export class Environment {
41
41
  if (typeof source !== "string") {
42
42
  throw new TypeError(`The source code of "${path}" must be a string. Got ${typeof source}`);
43
43
  }
44
- const allTokens = this.tokenize(source, path);
45
- const tokens = [...allTokens];
44
+ const tokens = this.tokenize(source, path);
46
45
  const lastToken = tokens.at(-1);
47
46
  if (lastToken[0] != "string") {
48
- throw new TokenError("Unclosed tag", lastToken, source, path);
47
+ throw new SourceError("Unclosed tag", lastToken[2], path, source);
49
48
  }
50
49
  let code = "";
51
50
  try {
52
51
  code = this.compileTokens(tokens).join("\n");
53
52
  }
54
53
  catch (error) {
55
- if (!(error instanceof Error))
56
- throw error;
57
- throw createError(error, {
58
- source,
59
- code,
60
- tokens: allTokens,
61
- path,
62
- });
54
+ if (error instanceof SourceError) {
55
+ error.file ??= path;
56
+ error.source ??= source;
57
+ }
58
+ throw error;
63
59
  }
64
60
  const { dataVarname, autoDataVarname } = this.options;
65
61
  if (autoDataVarname) {
@@ -77,32 +73,32 @@ export class Environment {
77
73
  }
78
74
  try {
79
75
  const constructor = new Function("__env", `return async function __template(${dataVarname}) {
76
+ let __pos=0;
80
77
  try {
81
78
  ${dataVarname} = Object.assign({}, __template.defaults, ${dataVarname});
82
79
  const __exports = { content: "" };
83
80
  ${code}
84
81
  return __exports;
85
82
  } catch (error) {
86
- throw __env.utils.createError(error, __template);
83
+ throw __env.utils.createError(error, __template, __pos);
87
84
  }
88
85
  }`);
89
86
  const template = constructor(this);
90
87
  template.path = path;
91
88
  template.code = constructor.toString();
92
89
  template.source = source;
93
- template.tokens = allTokens;
94
90
  template.defaults = defaults || {};
95
91
  return template;
96
92
  }
97
93
  catch (error) {
98
- if (!(error instanceof Error))
99
- throw error;
100
- throw createError(error, {
101
- source,
102
- code,
103
- tokens: allTokens,
104
- path,
105
- });
94
+ if (error instanceof SyntaxError) {
95
+ throw createError(error, { source, code, path });
96
+ }
97
+ if (error instanceof SourceError) {
98
+ error.file ??= path;
99
+ error.source ??= source;
100
+ }
101
+ throw error;
106
102
  }
107
103
  }
108
104
  tokenize(source, path) {
@@ -115,7 +111,15 @@ export class Environment {
115
111
  }
116
112
  return tokens;
117
113
  }
118
- async load(file, from) {
114
+ async load(file, from, position) {
115
+ if (!file) {
116
+ throw position !== undefined
117
+ ? new SourceError(`Invalid template (${typeof file})`, position, from)
118
+ : new Error(`Invalid template (${typeof file})`);
119
+ }
120
+ if (typeof file !== "string") {
121
+ throw new TypeError(`The template filename must be a string. Got ${typeof file}`);
122
+ }
119
123
  const path = this.options.loader.resolve(from || "", file);
120
124
  let cached = this.cache.get(path);
121
125
  if (cached) {
@@ -126,6 +130,11 @@ export class Environment {
126
130
  .split("?")[0]
127
131
  .split("#")[0];
128
132
  cached = this.options.loader.load(cleanPath)
133
+ .catch((error) => {
134
+ throw position !== undefined
135
+ ? new SourceError(`Error loading template: ${error.message}`, position, from)
136
+ : error;
137
+ })
129
138
  .then((result) => {
130
139
  if (typeof result === "function") {
131
140
  return result(this);
@@ -141,7 +150,7 @@ export class Environment {
141
150
  let openToken;
142
151
  tokens: while (tokens.length > 0) {
143
152
  const token = tokens.shift();
144
- const [type, code, pos] = token;
153
+ const [type, code, position] = token;
145
154
  openToken ??= token;
146
155
  // We found the closing tag, so we stop compiling
147
156
  if (closeToken && type === "tag" && closeToken === code) {
@@ -157,7 +166,7 @@ export class Environment {
157
166
  continue;
158
167
  }
159
168
  if (type === "tag") {
160
- compiled.push(`/*__pos:${pos}*/`);
169
+ compiled.push(`__pos=${position};`);
161
170
  for (const tag of this.tags) {
162
171
  const compiledTag = tag(this, token, outputVar, tokens);
163
172
  if (typeof compiledTag === "string") {
@@ -170,11 +179,11 @@ export class Environment {
170
179
  compiled.push(`${outputVar} += (${expression}) ?? "";`);
171
180
  continue;
172
181
  }
173
- throw new TokenError(`Unknown token type "${type}"`, token);
182
+ throw new SourceError(`Unknown token type "${type}"`, position);
174
183
  }
175
184
  // If we reach here, it means we have an open token that wasn't closed
176
185
  if (closeToken) {
177
- throw new TokenError(`Missing closing tag ("${closeToken}" tag is expected)`, openToken);
186
+ throw new SourceError(`Missing closing tag ("${closeToken}" tag is expected)`, openToken[2]);
178
187
  }
179
188
  return compiled;
180
189
  }
@@ -185,7 +194,7 @@ export class Environment {
185
194
  const [, code, position] = token;
186
195
  const match = code.match(/^(await\s+)?([\w.]+)(?:\((.*)\))?$/);
187
196
  if (!match) {
188
- throw new TokenError(`Invalid filter: ${code}`, token);
197
+ throw new SourceError(`Invalid filter: ${code}`, position);
189
198
  }
190
199
  const [_, isAsync, name, args] = match;
191
200
  if (!Object.hasOwn(this.filters, name)) {
@@ -235,7 +244,7 @@ thisObject, method, ...args) {
235
244
  if (typeof thisObject[method] === "function") {
236
245
  return thisObject[method](...args);
237
246
  }
238
- throw new TokenError(`Method "${method}" is not a function of ${typeof thisObject} variable`, position);
247
+ throw new SourceError(`Method "${method}" is not a function of ${typeof thisObject} variable`, position);
239
248
  }
240
249
  function checkAsync(fn) {
241
250
  return fn.constructor?.name === "AsyncFunction";
package/core/errors.js CHANGED
@@ -1,182 +1,263 @@
1
1
  export class VentoError extends Error {
2
2
  }
3
- export class TokenError extends VentoError {
4
- token;
5
- source;
3
+ export class SourceError extends VentoError {
4
+ position;
6
5
  file;
7
- constructor(message, token, source, file) {
6
+ source;
7
+ constructor(message, position, file, source) {
8
8
  super(message);
9
- this.name = "TokenError";
10
- this.token = token;
11
- this.source = source;
9
+ this.name = "SourceError";
10
+ this.position = position;
12
11
  this.file = file;
12
+ this.source = source;
13
13
  }
14
14
  getContext() {
15
- if (!this.source || this.token === undefined) {
16
- return;
17
- }
18
15
  return {
19
16
  type: this.name,
20
17
  message: this.message,
21
- source: this.source,
22
- position: typeof this.token === "number" ? this.token : this.token[2],
18
+ position: this.position,
23
19
  file: this.file,
20
+ source: this.source,
24
21
  };
25
22
  }
26
23
  }
27
24
  export class RuntimeError extends VentoError {
28
- context;
29
- constructor(error, context) {
25
+ #context;
26
+ position;
27
+ constructor(error, context, position) {
30
28
  super(error.message);
31
29
  this.name = error.name || "JavaScriptError";
32
- this.context = context;
30
+ this.#context = context;
33
31
  this.cause = error;
32
+ this.position = position;
34
33
  }
35
- getContext() {
36
- if (this.cause instanceof SyntaxError) {
37
- return parseSyntaxError(this.cause, this.context);
34
+ async getContext() {
35
+ const { code, source, path } = this.#context;
36
+ // If we don't have the position, we cannot provide a context
37
+ // Try to get the context from a SyntaxError
38
+ if (this.position === undefined) {
39
+ try {
40
+ return (await getSyntaxErrorContext(this.cause, this.#context)) ??
41
+ {
42
+ type: this.name || "JavaScriptError",
43
+ message: this.message,
44
+ source,
45
+ code,
46
+ file: path,
47
+ };
48
+ }
49
+ catch {
50
+ return {
51
+ type: this.name || "JavaScriptError",
52
+ message: this.message,
53
+ source,
54
+ code,
55
+ file: path,
56
+ };
57
+ }
38
58
  }
39
- if (this.cause instanceof Error) {
40
- return parseError(this.cause, this.context);
59
+ // Capture the exact position of the error in the compiled code
60
+ for (const frame of getStackFrames(this.cause)) {
61
+ if (frame.file !== "<anonymous>") {
62
+ continue;
63
+ }
64
+ return {
65
+ type: this.name || "JavaScriptError",
66
+ message: this.message,
67
+ source,
68
+ position: this.position,
69
+ code,
70
+ line: frame.line,
71
+ column: frame.column,
72
+ file: path,
73
+ };
41
74
  }
75
+ // As a fallback, return the error with the available context
76
+ return {
77
+ type: this.name || "JavaScriptError",
78
+ message: this.message,
79
+ source,
80
+ position: this.position,
81
+ code,
82
+ file: path,
83
+ };
42
84
  }
43
85
  }
44
- export function createError(error, context) {
86
+ /** Create or complete VentoError with extra info from the template */
87
+ export function createError(error, context, position) {
45
88
  if (error instanceof RuntimeError)
46
89
  return error;
47
- // If the error is a TokenError, we can enhance it with the context information
48
- if (error instanceof TokenError) {
90
+ // If the error is a SourceError, we can fill the missing context information
91
+ if (error instanceof SourceError) {
49
92
  error.file ??= context.path;
50
93
  error.source ??= context.source;
94
+ error.position ??= position;
51
95
  return error;
52
96
  }
53
97
  // JavaScript syntax errors can be parsed to get accurate position
54
- return new RuntimeError(error, context);
98
+ return new RuntimeError(error, context, position);
55
99
  }
56
- export async function printError(error) {
100
+ const colors = {
101
+ number: (n) => `\x1b[33m${n}\x1b[39m`,
102
+ dim: (line) => `\x1b[2m${line}\x1b[22m`,
103
+ error: (msg) => `\x1b[31m${msg}\x1b[39m`,
104
+ };
105
+ const plain = {
106
+ number: (n) => n,
107
+ dim: (line) => line,
108
+ error: (msg) => msg,
109
+ };
110
+ const formats = {
111
+ colors,
112
+ plain,
113
+ };
114
+ /** Prints an error to the console in a formatted way. */
115
+ export async function printError(error, format = plain) {
57
116
  if (error instanceof VentoError) {
58
117
  const context = await error.getContext();
118
+ const fmt = typeof format === "string" ? formats[format] || plain : format;
59
119
  if (context) {
60
- console.error(stringifyContext(context));
120
+ console.error(stringifyError(context, fmt));
61
121
  return;
62
122
  }
63
123
  }
64
124
  console.error(error);
65
125
  }
66
- function parseError(error, context) {
67
- const stackMatch = error.stack?.match(/<anonymous>:(\d+):(\d+)/);
68
- if (!stackMatch)
69
- return;
70
- const row = Number(stackMatch[1]) - 1;
71
- const col = Number(stackMatch[2]);
72
- const position = getAccurateErrorPosition(row, col, context);
73
- if (position == -1)
74
- return;
75
- return {
76
- type: error.name || "JavaScriptError",
77
- message: error.message,
78
- source: context.source,
79
- position,
80
- file: context.path,
81
- };
82
- }
83
- async function parseSyntaxError(error, context) {
84
- const code = `()=>{${context.code}}`;
85
- const dataUrl = "data:application/javascript;base64," + btoa(code);
86
- const stack = await import(dataUrl).catch(({ stack }) => stack);
87
- if (!stack)
88
- return;
89
- const stackMatch = stack?.match(/:(\d+):(\d+)$/m);
90
- if (!stackMatch)
91
- return;
92
- const row = Number(stackMatch[1]) - 1;
93
- const col = Number(stackMatch[2]);
94
- const position = getAccurateErrorPosition(row, col, context);
95
- if (position == -1)
96
- return;
97
- return {
98
- type: "SyntaxError",
99
- message: error.message,
100
- source: context.source,
101
- position,
102
- file: context.path,
103
- };
104
- }
105
- function getAccurateErrorPosition(row, col, context) {
106
- const { code, tokens, source } = context;
107
- if (!tokens)
108
- return -1;
109
- const linesAndDelims = code.split(/(\r\n?|[\n\u2028\u2029])/);
110
- const linesAndDelimsUntilIssue = linesAndDelims.slice(0, row * 2);
111
- const issueIndex = linesAndDelimsUntilIssue.join("").length + col;
112
- const posLine = linesAndDelimsUntilIssue.findLast((line) => {
113
- return /^\/\*__pos:(\d+)\*\/$/.test(line);
114
- });
115
- if (!posLine)
116
- return -1;
117
- const position = Number(posLine.slice(8, -2));
118
- const token = tokens.findLast((token) => {
119
- if (token[2] == undefined)
120
- return false;
121
- return token[2] <= position;
122
- });
123
- if (!token)
124
- return -1;
125
- const isJS = token[1].startsWith(">");
126
- const tag = isJS ? token[1].slice(1).trimStart() : token[1];
127
- const issueStartIndex = code.lastIndexOf(tag, issueIndex);
128
- if (issueStartIndex == -1)
129
- return -1;
130
- const sourceIssueStartIndex = source.indexOf(tag, position);
131
- return sourceIssueStartIndex + issueIndex - issueStartIndex - 1;
132
- }
133
- const terminal = {
134
- number: (n) => `\x1b[33m${n}\x1b[39m`,
135
- dim: (line) => `\x1b[2m${line}\x1b[22m`,
136
- error: (msg) => `\x1b[31m${msg}\x1b[39m`,
137
- };
138
- const LINE_TERMINATOR = /\r\n?|[\n\u2028\u2029]/;
139
- export function stringifyContext(context, format = terminal) {
140
- const { type, message, source, position, file } = context;
141
- const sourceAfterIssue = source.slice(position);
142
- const newlineMatch = sourceAfterIssue.match(LINE_TERMINATOR);
143
- const endIndex = position + (newlineMatch?.index ?? sourceAfterIssue.length);
144
- const lines = source.slice(0, endIndex).split(LINE_TERMINATOR);
145
- const displayedLineEntries = [...lines.entries()].slice(-3);
146
- const endLineIndex = lines.at(-1).length + position - endIndex;
147
- const numberLength = (displayedLineEntries.at(-1)[0] + 1).toString().length;
148
- const displayedCode = displayedLineEntries.map(([index, line]) => {
149
- const number = `${index + 1}`.padStart(numberLength);
150
- const sidebar = ` ${format.number(number)} ${format.dim("|")} `;
151
- return sidebar + line;
152
- }).join("\n");
153
- const sidebarWidth = numberLength + 4;
154
- const tooltipIndex = sidebarWidth + endLineIndex;
155
- const tooltipIndent = " ".repeat(tooltipIndex);
156
- const tooltip = tooltipIndent + format.error(`^ ${message}`);
126
+ /** Converts an error context into a formatted string representation. */
127
+ export function stringifyError(context, format = plain) {
128
+ const { type, message, source, position, code, line, column, file } = context;
157
129
  const output = [];
130
+ // Print error type and message
158
131
  output.push(`${format.error(type)}: ${message}`);
132
+ // If we don't know the position, we cannot print the source code
133
+ if (position === undefined || source === undefined) {
134
+ if (file) {
135
+ output.push(format.dim(file));
136
+ }
137
+ return output.join("\n");
138
+ }
139
+ const sourceLines = codeToLines(source);
140
+ const [sourceLine, sourceColumn] = getSourceLineColumn(sourceLines, position);
141
+ // Print file location if available
159
142
  if (file) {
160
- output.push(format.dim(getLocation(file, source, position)), "");
143
+ output.push(format.dim(`${file}:${sourceLine}:${sourceColumn}`));
161
144
  }
162
- output.push(displayedCode, tooltip);
145
+ const pad = sourceLine.toString().length;
146
+ // Print the latest lines of the source code before the error
147
+ for (let line = Math.max(sourceLine - 3, 1); line <= sourceLine; line++) {
148
+ const sidebar = ` ${format.number(`${line}`.padStart(pad))} ${format.dim("|")} `;
149
+ output.push(sidebar + sourceLines[line - 1].trimEnd());
150
+ }
151
+ // If we don't have the compiled code, return the tag position
152
+ const indent = ` ${" ".repeat(pad)} ${format.dim("|")}`;
153
+ // If we don't have the compiled code, print the tag position
154
+ if (!code || line === undefined || column === undefined) {
155
+ output.push(`${indent} ${" ".repeat(sourceColumn - 1)}${format.error(`^ ${message}`)}`);
156
+ return output.join("\n");
157
+ }
158
+ // Print the compiled code with the error position
159
+ const codeLines = codeToLines(code);
160
+ output.push(`${indent} ${" ".repeat(sourceColumn - 1)}${format.error("^")}`);
161
+ output.push(`${indent} ${format.dim(codeLines[line - 1]?.trimEnd() || "")}`);
162
+ output.push(`${indent} ${" ".repeat(column)} ${format.error(`^ ${message}`)}`);
163
163
  return output.join("\n");
164
164
  }
165
- function getLocation(file, source, position) {
166
- let line = 1;
167
- let column = 1;
168
- for (let index = 0; index < position; index++) {
169
- if (source[index] === "\n" ||
170
- (source[index] === "\r" && source[index + 1] === "\n")) {
171
- line++;
172
- column = 1;
173
- if (source[index] === "\r") {
174
- index++;
175
- }
165
+ /**
166
+ * Extracts the context from a SyntaxError
167
+ * It does not work on Node.js and Bun due to the lack of position information
168
+ * in the stack trace of a dynamic import error.
169
+ */
170
+ async function getSyntaxErrorContext(error, context) {
171
+ const { source, code } = context;
172
+ const url = URL.createObjectURL(new Blob([code], { type: "application/javascript" }));
173
+ const err = await import(url).catch((e) => e);
174
+ URL.revokeObjectURL(url);
175
+ for (const frame of getStackFrames(err)) {
176
+ if (!frame.file.startsWith("blob:")) {
177
+ continue;
178
+ }
179
+ return {
180
+ type: "SyntaxError",
181
+ message: error.message,
182
+ source,
183
+ position: searchPosition(frame, code) ?? 0,
184
+ code,
185
+ line: frame.line,
186
+ column: frame.column,
187
+ file: context.path,
188
+ };
189
+ }
190
+ }
191
+ const LINE_TERMINATOR = /(\r\n?|[\n\u2028\u2029])/;
192
+ /** Convert the source code into an array of lines */
193
+ function codeToLines(code) {
194
+ const doubleLines = code.split(LINE_TERMINATOR);
195
+ const lines = [];
196
+ for (let i = 0; i < doubleLines.length; i += 2) {
197
+ lines.push(`${doubleLines[i]}${doubleLines[i + 1] ?? ""}`);
198
+ }
199
+ return lines;
200
+ }
201
+ const POSITION_VARIABLE = /^__pos=(\d+);$/;
202
+ /** Search the closest token to an error */
203
+ function searchPosition(frame, code) {
204
+ const posLine = codeToLines(code)
205
+ .slice(0, frame.line - 1)
206
+ .findLast((line) => POSITION_VARIABLE.test(line.trim()));
207
+ if (posLine) {
208
+ return Number(posLine.trim().slice(6, -1));
209
+ }
210
+ }
211
+ /** Get the line and column number of a position in the code */
212
+ function getSourceLineColumn(lines, position) {
213
+ if (position < 0) {
214
+ return [1, 1]; // Position is before the start of the source
215
+ }
216
+ let index = 0;
217
+ for (const [line, content] of lines.entries()) {
218
+ const length = content.length;
219
+ if (position < index + length) {
220
+ return [line + 1, position - index + 1];
176
221
  }
177
- else {
178
- column++;
222
+ index += content.length;
223
+ }
224
+ throw new Error(`Position ${position} is out of bounds for the provided source lines.`);
225
+ }
226
+ /** Returns every combination of file, line and column of an error stack */
227
+ // deno-lint-ignore no-explicit-any
228
+ function* getStackFrames(error) {
229
+ // Firefox specific
230
+ const { columnNumber, lineNumber, fileName } = error;
231
+ if (columnNumber !== undefined && lineNumber !== undefined && fileName) {
232
+ yield {
233
+ file: normalizeFile(fileName),
234
+ line: lineNumber,
235
+ column: columnNumber,
236
+ };
237
+ }
238
+ const { stack } = error;
239
+ if (!stack) {
240
+ return;
241
+ }
242
+ const matches = stack.matchAll(/([^(\s,]+):(\d+):(\d+)/g);
243
+ for (const match of matches) {
244
+ const [_, file, line, column] = match;
245
+ // Skip Node, Bun & Deno internal stack frames
246
+ if (file.startsWith("node:") || file.startsWith("ext:") || file === "native") {
247
+ continue;
179
248
  }
249
+ yield {
250
+ file: normalizeFile(file),
251
+ line: Number(line),
252
+ column: Number(column),
253
+ };
180
254
  }
181
- return `${file}:${line}:${column}`;
255
+ }
256
+ function normalizeFile(file) {
257
+ if (!file)
258
+ return "<anonymous>";
259
+ // Firefox may return "Function" for anonymous functions
260
+ if (file === "Function")
261
+ return "<anonymous>";
262
+ return file;
182
263
  }
package/loaders/module.js CHANGED
@@ -38,9 +38,10 @@ export function exportTemplate(template, options) {
38
38
  ${template.toString()};
39
39
 
40
40
  ${options?.source
41
- ? `__template.path = ${JSON.stringify(template.path)};
41
+ ? `__template.path = ${JSON.stringify(template.path ? template.path + ".js" : undefined)};
42
42
  __template.code = ${JSON.stringify(template.code)};
43
- __template.source = ${JSON.stringify(template.source)};`
43
+ __template.source = ${JSON.stringify(template.source)};
44
+ __template.tokens = ${JSON.stringify(template.tokens)};`
44
45
  : ""}
45
46
  __template.defaults = ${JSON.stringify(template.defaults || {})};
46
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ventojs",
3
- "version": "2.0.0-canary.1",
3
+ "version": "2.0.0-canary.2",
4
4
  "description": "🌬 A minimal but powerful template engine",
5
5
  "type": "module",
6
6
  "repository": {
package/plugins/export.js CHANGED
@@ -1,4 +1,4 @@
1
- import { TokenError } from "../core/errors.js";
1
+ import { SourceError } from "../core/errors.js";
2
2
  export default function () {
3
3
  return (env) => {
4
4
  env.tags.push(exportTag);
@@ -10,7 +10,7 @@ const INLINE_NAMED_EXPORT = /^([a-zA-Z_]\w*)\s*=([^]*)$/;
10
10
  const NAMED_EXPORTS = /^{[^]*?}$/;
11
11
  const AS = /\s+\bas\b\s+/;
12
12
  function exportTag(env, token, _output, tokens) {
13
- const [, code] = token;
13
+ const [, code, position] = token;
14
14
  const exportStart = code.match(EXPORT_START);
15
15
  if (!exportStart) {
16
16
  return;
@@ -58,7 +58,7 @@ function exportTag(env, token, _output, tokens) {
58
58
  compiled.push(`__exports["${rename}"] = ${value};`);
59
59
  }
60
60
  else {
61
- throw new TokenError("Invalid export", token);
61
+ throw new SourceError("Invalid export", position);
62
62
  }
63
63
  }
64
64
  return compiled.join("\n");
package/plugins/for.js CHANGED
@@ -1,4 +1,4 @@
1
- import { TokenError } from "../core/errors.js";
1
+ import { SourceError } from "../core/errors.js";
2
2
  import iterateTopLevel from "../core/js.js";
3
3
  export default function () {
4
4
  return (env) => {
@@ -7,7 +7,7 @@ export default function () {
7
7
  };
8
8
  }
9
9
  function forTag(env, token, output, tokens) {
10
- const [, code] = token;
10
+ const [, code, position] = token;
11
11
  if (code === "break" || code === "continue") {
12
12
  return `${code};`;
13
13
  }
@@ -17,7 +17,7 @@ function forTag(env, token, output, tokens) {
17
17
  const compiled = [];
18
18
  const match = code.match(/^for\s+(await\s+)?([\s\S]*)$/);
19
19
  if (!match) {
20
- throw new TokenError("Invalid for loop", token);
20
+ throw new SourceError("Invalid for loop", position);
21
21
  }
22
22
  let [, aw, tagCode] = match;
23
23
  let var1;
@@ -29,7 +29,7 @@ function forTag(env, token, output, tokens) {
29
29
  else {
30
30
  const parts = tagCode.match(/(^[^\s,]+)([\s|\S]+)$/);
31
31
  if (!parts) {
32
- throw new TokenError(`Invalid for loop variable: ${tagCode}`, token);
32
+ throw new SourceError("Invalid for loop", position);
33
33
  }
34
34
  var1 = parts[1].trim();
35
35
  tagCode = parts[2].trim();
@@ -43,7 +43,7 @@ function forTag(env, token, output, tokens) {
43
43
  else {
44
44
  const parts = tagCode.match(/^([\w]+)\s+of\s+([\s|\S]+)$/);
45
45
  if (!parts) {
46
- throw new TokenError(`Invalid for loop variable: ${tagCode}`, token);
46
+ throw new SourceError("Invalid for loop", position);
47
47
  }
48
48
  var2 = parts[1].trim();
49
49
  collection = parts[2].trim();
@@ -52,6 +52,9 @@ function forTag(env, token, output, tokens) {
52
52
  else if (tagCode.startsWith("of ")) {
53
53
  collection = tagCode.slice(3).trim();
54
54
  }
55
+ else {
56
+ throw new SourceError("Invalid for loop", position);
57
+ }
55
58
  if (var2) {
56
59
  compiled.push(`for ${aw || ""}(let [${var1}, ${var2}] of __env.utils.toIterator(${env.compileFilters(tokens, collection)}, true)) {`);
57
60
  }
@@ -1,17 +1,17 @@
1
- import { TokenError } from "../core/errors.js";
1
+ import { SourceError } from "../core/errors.js";
2
2
  export default function () {
3
3
  return (env) => {
4
4
  env.tags.push(functionTag);
5
5
  };
6
6
  }
7
7
  function functionTag(env, token, _output, tokens) {
8
- const [, code] = token;
8
+ const [, code, position] = token;
9
9
  if (!code.match(/^(export\s+)?(async\s+)?function\s/)) {
10
10
  return;
11
11
  }
12
- const match = code.match(/^(export\s+)?(async\s+)?function\s+(\w+)\s*(\([^)]+\))?$/);
12
+ const match = code.match(/^(export\s+)?(async\s+)?function\s+(\w+)\s*(\([^]*\))?$/);
13
13
  if (!match) {
14
- throw new TokenError("Invalid function tag", token);
14
+ throw new SourceError("Invalid function tag", position);
15
15
  }
16
16
  const [_, exp, as, name, args] = match;
17
17
  const compiled = [];
package/plugins/if.js CHANGED
@@ -1,4 +1,4 @@
1
- import { TokenError } from "../core/errors.js";
1
+ import { SourceError } from "../core/errors.js";
2
2
  export default function () {
3
3
  return (env) => {
4
4
  env.tags.push(ifTag);
@@ -18,13 +18,13 @@ function ifTag(env, [, code], output, tokens) {
18
18
  return compiled.join("\n");
19
19
  }
20
20
  function elseTag(_env, token) {
21
- const [, code] = token;
21
+ const [, code, position] = token;
22
22
  if (!code.startsWith("else ") && code !== "else") {
23
23
  return;
24
24
  }
25
25
  const match = code.match(/^else(\s+if\s+(.*))?$/);
26
26
  if (!match) {
27
- throw new TokenError("Invalid else tag", token);
27
+ throw new SourceError("Invalid else tag", position);
28
28
  }
29
29
  const [_, ifTag, condition] = match;
30
30
  if (ifTag) {
package/plugins/import.js CHANGED
@@ -1,4 +1,4 @@
1
- import { TokenError } from "../core/errors.js";
1
+ import { SourceError } from "../core/errors.js";
2
2
  export default function () {
3
3
  return (env) => {
4
4
  env.tags.push(importTag);
@@ -9,13 +9,13 @@ const DEFAULT_IMPORT = /^\b[a-zA-Z_]\w*\b$/i;
9
9
  const NAMED_IMPORTS = /^{[^]*?}$/;
10
10
  const AS = /\s+\bas\b\s+/;
11
11
  function importTag(env, token) {
12
- const [, code] = token;
12
+ const [, code, position] = token;
13
13
  if (!code.startsWith("import ")) {
14
14
  return;
15
15
  }
16
16
  const match = code.match(IMPORT_STATEMENT);
17
17
  if (!match) {
18
- throw new TokenError("Invalid import tag", token);
18
+ throw new SourceError("Invalid import tag", position);
19
19
  }
20
20
  const compiled = [];
21
21
  const variables = [];
@@ -43,18 +43,18 @@ function importTag(env, token) {
43
43
  return `${name}: ${rename}`;
44
44
  }
45
45
  else {
46
- throw new TokenError("Invalid named import", token);
46
+ throw new SourceError("Invalid named import", position);
47
47
  }
48
48
  });
49
49
  compiled.push(`({${chunks.join(",")}} = __tmp);`);
50
50
  }
51
51
  else {
52
- throw new TokenError("Invalid import tag", token);
52
+ throw new SourceError("Invalid import tag", position);
53
53
  }
54
54
  }
55
55
  const { dataVarname } = env.options;
56
56
  return `let ${variables.join(",")}; {
57
- let __tmp = await __env.run(${specifier}, {...${dataVarname}}, __template.path);
57
+ let __tmp = await __env.run(${specifier}, {...${dataVarname}}, __template.path, ${position});
58
58
  ${compiled.join("\n")}
59
59
  }`;
60
60
  }
@@ -1,4 +1,4 @@
1
- import { TokenError } from "../core/errors.js";
1
+ import { SourceError } from "../core/errors.js";
2
2
  import iterateTopLevel from "../core/js.js";
3
3
  export default function () {
4
4
  return (env) => {
@@ -6,7 +6,7 @@ export default function () {
6
6
  };
7
7
  }
8
8
  function includeTag(env, token, output, tokens) {
9
- const [, code] = token;
9
+ const [, code, position] = token;
10
10
  if (!code.startsWith("include ")) {
11
11
  return;
12
12
  }
@@ -21,7 +21,7 @@ function includeTag(env, token, output, tokens) {
21
21
  bracketIndex = index;
22
22
  }
23
23
  if (bracketIndex == -1) {
24
- throw new TokenError("Invalid include tag", token);
24
+ throw new SourceError("Invalid include tag", position);
25
25
  }
26
26
  file = tagCode.slice(0, bracketIndex).trim();
27
27
  data = tagCode.slice(bracketIndex).trim();
@@ -30,7 +30,8 @@ function includeTag(env, token, output, tokens) {
30
30
  return `{
31
31
  const __tmp = await __env.run(${file},
32
32
  {...${dataVarname}${data ? `, ...${data}` : ""}},
33
- __template.path
33
+ __template.path,
34
+ ${position}
34
35
  );
35
36
  ${output} += ${env.compileFilters(tokens, "__tmp.content")};
36
37
  }`;
package/plugins/layout.js CHANGED
@@ -1,17 +1,17 @@
1
- import { TokenError } from "../core/errors.js";
1
+ import { SourceError } from "../core/errors.js";
2
2
  export default function () {
3
3
  return (env) => {
4
4
  env.tags.push(layoutTag);
5
5
  };
6
6
  }
7
7
  function layoutTag(env, token, output, tokens) {
8
- const [, code] = token;
8
+ const [, code, position] = token;
9
9
  if (!code.startsWith("layout ")) {
10
10
  return;
11
11
  }
12
12
  const match = code?.match(/^layout\s+([^{]+|`[^`]+`)+(?:\{([\s|\S]*)\})?$/);
13
13
  if (!match) {
14
- throw new TokenError("Invalid layout tag", token);
14
+ throw new SourceError("Invalid layout tag", position);
15
15
  }
16
16
  const [_, file, data] = match;
17
17
  const varname = output.startsWith("__layout")
@@ -22,11 +22,12 @@ function layoutTag(env, token, output, tokens) {
22
22
  compiled.push("{");
23
23
  compiled.push(`let ${varname} = "";`);
24
24
  compiled.push(...env.compileTokens(tokens, varname, "/layout"));
25
- compiled.push(`${varname} = ${compiledFilters};`);
25
+ compiled.push(`${varname} = __env.utils.safeString(${compiledFilters});`);
26
26
  const { dataVarname } = env.options;
27
27
  compiled.push(`const __tmp = await __env.run(${file},
28
28
  {...${dataVarname}${data ? `, ${data}` : ""}, content: ${env.compileFilters(tokens, varname)}},
29
- __template.path
29
+ __template.path,
30
+ ${position}
30
31
  );
31
32
  ${output} += __tmp.content;`);
32
33
  compiled.push("}");
package/plugins/set.js CHANGED
@@ -1,11 +1,11 @@
1
- import { TokenError } from "../core/errors.js";
1
+ import { SourceError } from "../core/errors.js";
2
2
  export default function () {
3
3
  return (env) => {
4
4
  env.tags.push(setTag);
5
5
  };
6
6
  }
7
7
  function setTag(env, token, _output, tokens) {
8
- const [, code] = token;
8
+ const [, code, position] = token;
9
9
  if (!code.startsWith("set ")) {
10
10
  return;
11
11
  }
@@ -15,7 +15,7 @@ function setTag(env, token, _output, tokens) {
15
15
  if (expression.includes("=")) {
16
16
  const match = code.match(/^set\s+([\w]+)\s*=\s*([\s\S]+)$/);
17
17
  if (!match) {
18
- throw new TokenError("Invalid set tag", token);
18
+ throw new SourceError("Invalid set tag", position);
19
19
  }
20
20
  const [, variable, value] = match;
21
21
  const val = env.compileFilters(tokens, value);
@@ -8,7 +8,6 @@ export interface TemplateContext {
8
8
  code: string;
9
9
  path?: string;
10
10
  defaults?: Record<string, unknown>;
11
- tokens?: Token[];
12
11
  }
13
12
  export interface Template extends TemplateContext {
14
13
  (data?: Record<string, unknown>): Promise<TemplateResult>;
@@ -45,11 +44,11 @@ export declare class Environment {
45
44
  utils: Record<string, unknown>;
46
45
  constructor(options: Options);
47
46
  use(plugin: Plugin): void;
48
- run(file: string, data?: Record<string, unknown>, from?: string): Promise<TemplateResult>;
47
+ run(file: string, data?: Record<string, unknown>, from?: string, position?: number): Promise<TemplateResult>;
49
48
  runString(source: string, data?: Record<string, unknown>, file?: string): Promise<TemplateResult>;
50
49
  compile(source: string, path?: string, defaults?: Record<string, unknown>): Template;
51
50
  tokenize(source: string, path?: string): Token[];
52
- load(file: string, from?: string): Promise<Template>;
51
+ load(file: string, from?: string, position?: number): Promise<Template>;
53
52
  compileTokens(tokens: Token[], outputVar?: string, closeToken?: string): string[];
54
53
  compileFilters(tokens: Token[], output: string, autoescape?: boolean): string;
55
54
  }
@@ -1,38 +1,46 @@
1
- import type { Token } from "./tokenizer.d.ts";
2
1
  import type { TemplateContext } from "./environment.d.ts";
3
2
  export interface ErrorContext {
4
3
  type: string;
5
4
  message: string;
6
- source: string;
7
- position: number;
5
+ source?: string;
6
+ position?: number;
7
+ code?: string;
8
+ line?: number;
9
+ column?: number;
8
10
  file?: string;
9
11
  }
10
12
  export declare abstract class VentoError extends Error {
11
- abstract getContext(): ErrorContext | undefined | Promise<ErrorContext | undefined>;
13
+ abstract getContext(): ErrorContext | Promise<ErrorContext>;
12
14
  }
13
- export declare class TokenError extends VentoError {
14
- token: Token | number;
15
- source?: string;
15
+ export declare class SourceError extends VentoError {
16
+ position?: number;
16
17
  file?: string;
17
- constructor(message: string, token: Token | number, source?: string, file?: string);
18
+ source?: string;
19
+ constructor(message: string, position?: number, file?: string, source?: string);
18
20
  getContext(): {
19
21
  type: string;
20
22
  message: string;
21
- source: string;
22
23
  position: number;
23
24
  file: string;
25
+ source: string;
24
26
  };
25
27
  }
26
28
  export declare class RuntimeError extends VentoError {
27
- context: TemplateContext;
28
- constructor(error: Error, context: TemplateContext);
29
- getContext(): ErrorContext | Promise<ErrorContext>;
29
+ #private;
30
+ position?: number;
31
+ constructor(error: Error, context: TemplateContext, position?: number);
32
+ getContext(): Promise<ErrorContext>;
30
33
  }
31
- export declare function createError(error: Error, context: TemplateContext): Error;
32
- export declare function printError(error: unknown): Promise<void>;
34
+ /** Create or complete VentoError with extra info from the template */
35
+ export declare function createError(error: Error, context: TemplateContext, position?: number): VentoError;
33
36
  export interface ErrorFormat {
34
37
  number: (n: string) => string;
35
38
  dim: (line: string) => string;
36
39
  error: (msg: string) => string;
37
40
  }
38
- export declare function stringifyContext(context: ErrorContext, format?: ErrorFormat): string;
41
+ declare const formats: Record<string, ErrorFormat>;
42
+ /** Prints an error to the console in a formatted way. */
43
+ export declare function printError(error: unknown, format?: ErrorFormat | keyof typeof formats): Promise<void>;
44
+ /** Converts an error context into a formatted string representation. */
45
+ export declare function stringifyError(context: ErrorContext, format?: ErrorFormat): string;
46
+ export {};