ventojs 2.0.0-canary.1 → 2.0.0-canary.3

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,8 @@ 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.
17
+ - New `{{ slot }}` tag to pass extra variables to `{{ layout }}` [#140]
16
18
 
17
19
  ### Changed
18
20
  - Renamed `src` directory to `core`.
@@ -47,3 +49,4 @@ Vento 2.0 is now dependency-free and compatible with browsers without a build st
47
49
  [#131]: https://github.com/ventojs/vento/issues/131
48
50
  [#134]: https://github.com/ventojs/vento/issues/134
49
51
  [#137]: https://github.com/ventojs/vento/issues/137
52
+ [#140]: https://github.com/ventojs/vento/issues/140
@@ -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,265 @@
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
+ path &&
63
+ ![path + ".js", path + ".mjs"].some((p) => frame.file.endsWith(p))) {
64
+ continue;
65
+ }
66
+ return {
67
+ type: this.name || "JavaScriptError",
68
+ message: this.message,
69
+ source,
70
+ position: this.position,
71
+ code,
72
+ line: frame.line,
73
+ column: frame.column,
74
+ file: path,
75
+ };
41
76
  }
77
+ // As a fallback, return the error with the available context
78
+ return {
79
+ type: this.name || "JavaScriptError",
80
+ message: this.message,
81
+ source,
82
+ position: this.position,
83
+ code,
84
+ file: path,
85
+ };
42
86
  }
43
87
  }
44
- export function createError(error, context) {
88
+ /** Create or complete VentoError with extra info from the template */
89
+ export function createError(error, context, position) {
45
90
  if (error instanceof RuntimeError)
46
91
  return error;
47
- // If the error is a TokenError, we can enhance it with the context information
48
- if (error instanceof TokenError) {
92
+ // If the error is a SourceError, we can fill the missing context information
93
+ if (error instanceof SourceError) {
49
94
  error.file ??= context.path;
50
95
  error.source ??= context.source;
96
+ error.position ??= position;
51
97
  return error;
52
98
  }
53
99
  // JavaScript syntax errors can be parsed to get accurate position
54
- return new RuntimeError(error, context);
100
+ return new RuntimeError(error, context, position);
55
101
  }
56
- export async function printError(error) {
102
+ const colors = {
103
+ number: (n) => `\x1b[33m${n}\x1b[39m`,
104
+ dim: (line) => `\x1b[2m${line}\x1b[22m`,
105
+ error: (msg) => `\x1b[31m${msg}\x1b[39m`,
106
+ };
107
+ const plain = {
108
+ number: (n) => n,
109
+ dim: (line) => line,
110
+ error: (msg) => msg,
111
+ };
112
+ const formats = {
113
+ colors,
114
+ plain,
115
+ };
116
+ /** Prints an error to the console in a formatted way. */
117
+ export async function printError(error, format = plain) {
57
118
  if (error instanceof VentoError) {
58
119
  const context = await error.getContext();
120
+ const fmt = typeof format === "string" ? formats[format] || plain : format;
59
121
  if (context) {
60
- console.error(stringifyContext(context));
122
+ console.error(stringifyError(context, fmt));
61
123
  return;
62
124
  }
63
125
  }
64
126
  console.error(error);
65
127
  }
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}`);
128
+ /** Converts an error context into a formatted string representation. */
129
+ export function stringifyError(context, format = plain) {
130
+ const { type, message, source, position, code, line, column, file } = context;
157
131
  const output = [];
132
+ // Print error type and message
158
133
  output.push(`${format.error(type)}: ${message}`);
134
+ // If we don't know the position, we cannot print the source code
135
+ if (position === undefined || source === undefined) {
136
+ if (file) {
137
+ output.push(format.dim(file));
138
+ }
139
+ return output.join("\n");
140
+ }
141
+ const sourceLines = codeToLines(source);
142
+ const [sourceLine, sourceColumn] = getSourceLineColumn(sourceLines, position);
143
+ // Print file location if available
159
144
  if (file) {
160
- output.push(format.dim(getLocation(file, source, position)), "");
145
+ output.push(format.dim(`${file}:${sourceLine}:${sourceColumn}`));
161
146
  }
162
- output.push(displayedCode, tooltip);
147
+ const pad = sourceLine.toString().length;
148
+ // Print the latest lines of the source code before the error
149
+ for (let line = Math.max(sourceLine - 3, 1); line <= sourceLine; line++) {
150
+ const sidebar = ` ${format.number(`${line}`.padStart(pad))} ${format.dim("|")} `;
151
+ output.push(sidebar + sourceLines[line - 1].trimEnd());
152
+ }
153
+ // If we don't have the compiled code, return the tag position
154
+ const indent = ` ${" ".repeat(pad)} ${format.dim("|")}`;
155
+ // If we don't have the compiled code, print the tag position
156
+ if (!code || line === undefined || column === undefined) {
157
+ output.push(`${indent} ${" ".repeat(sourceColumn - 1)}${format.error(`^ ${message}`)}`);
158
+ return output.join("\n");
159
+ }
160
+ // Print the compiled code with the error position
161
+ const codeLines = codeToLines(code);
162
+ output.push(`${indent} ${" ".repeat(sourceColumn - 1)}${format.error("^")}`);
163
+ output.push(`${indent} ${format.dim(codeLines[line - 1]?.trimEnd() || "")}`);
164
+ output.push(`${indent} ${" ".repeat(column)} ${format.error(`^ ${message}`)}`);
163
165
  return output.join("\n");
164
166
  }
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
- }
167
+ /**
168
+ * Extracts the context from a SyntaxError
169
+ * It does not work on Node.js and Bun due to the lack of position information
170
+ * in the stack trace of a dynamic import error.
171
+ */
172
+ async function getSyntaxErrorContext(error, context) {
173
+ const { source, code } = context;
174
+ const url = URL.createObjectURL(new Blob([code], { type: "application/javascript" }));
175
+ const err = await import(url).catch((e) => e);
176
+ URL.revokeObjectURL(url);
177
+ for (const frame of getStackFrames(err)) {
178
+ if (!frame.file.startsWith("blob:")) {
179
+ continue;
180
+ }
181
+ return {
182
+ type: "SyntaxError",
183
+ message: error.message,
184
+ source,
185
+ position: searchPosition(frame, code) ?? 0,
186
+ code,
187
+ line: frame.line,
188
+ column: frame.column,
189
+ file: context.path,
190
+ };
191
+ }
192
+ }
193
+ const LINE_TERMINATOR = /(\r\n?|[\n\u2028\u2029])/;
194
+ /** Convert the source code into an array of lines */
195
+ function codeToLines(code) {
196
+ const doubleLines = code.split(LINE_TERMINATOR);
197
+ const lines = [];
198
+ for (let i = 0; i < doubleLines.length; i += 2) {
199
+ lines.push(`${doubleLines[i]}${doubleLines[i + 1] ?? ""}`);
200
+ }
201
+ return lines;
202
+ }
203
+ const POSITION_VARIABLE = /^__pos=(\d+);$/;
204
+ /** Search the closest token to an error */
205
+ function searchPosition(frame, code) {
206
+ const posLine = codeToLines(code)
207
+ .slice(0, frame.line - 1)
208
+ .findLast((line) => POSITION_VARIABLE.test(line.trim()));
209
+ if (posLine) {
210
+ return Number(posLine.trim().slice(6, -1));
211
+ }
212
+ }
213
+ /** Get the line and column number of a position in the code */
214
+ function getSourceLineColumn(lines, position) {
215
+ if (position < 0) {
216
+ return [1, 1]; // Position is before the start of the source
217
+ }
218
+ let index = 0;
219
+ for (const [line, content] of lines.entries()) {
220
+ const length = content.length;
221
+ if (position < index + length) {
222
+ return [line + 1, position - index + 1];
176
223
  }
177
- else {
178
- column++;
224
+ index += content.length;
225
+ }
226
+ throw new Error(`Position ${position} is out of bounds for the provided source lines.`);
227
+ }
228
+ /** Returns every combination of file, line and column of an error stack */
229
+ // deno-lint-ignore no-explicit-any
230
+ function* getStackFrames(error) {
231
+ // Firefox specific
232
+ const { columnNumber, lineNumber, fileName } = error;
233
+ if (columnNumber !== undefined && lineNumber !== undefined && fileName) {
234
+ yield {
235
+ file: normalizeFile(fileName),
236
+ line: lineNumber,
237
+ column: columnNumber,
238
+ };
239
+ }
240
+ const { stack } = error;
241
+ if (!stack) {
242
+ return;
243
+ }
244
+ const matches = stack.matchAll(/([^(\s,]+):(\d+):(\d+)/g);
245
+ for (const match of matches) {
246
+ const [_, file, line, column] = match;
247
+ // Skip Node, Bun & Deno internal stack frames
248
+ if (file.startsWith("node:") || file.startsWith("ext:") || file === "native") {
249
+ continue;
179
250
  }
251
+ yield {
252
+ file: normalizeFile(file),
253
+ line: Number(line),
254
+ column: Number(column),
255
+ };
180
256
  }
181
- return `${file}:${line}:${column}`;
257
+ }
258
+ function normalizeFile(file) {
259
+ if (!file)
260
+ return "<anonymous>";
261
+ // Firefox may return "Function" for anonymous functions
262
+ if (file === "Function")
263
+ return "<anonymous>";
264
+ return file;
182
265
  }
package/core/js.js CHANGED
@@ -30,8 +30,10 @@ export default function* iterateTopLevel(source, start = 0) {
30
30
  const [stop, variable] = match;
31
31
  if (variable) {
32
32
  cursor += variable.length;
33
- if (!reserved.has(variable))
33
+ // Words used internally by Vento start with two underscores
34
+ if (!reserved.has(variable) && !variable.startsWith("__")) {
34
35
  variables.add(variable);
36
+ }
35
37
  continue;
36
38
  }
37
39
  // Check the type of the stopping point.
package/core/reserved.js CHANGED
@@ -1,14 +1,4 @@
1
1
  const variables = new Set([
2
- // Words reserved by Vento, used internally. In general, don't use variable
3
- // names starting with two underscores to be future-proof and avoid clashes.
4
- "__file",
5
- "__template",
6
- "__env",
7
- "__defaults",
8
- "__err",
9
- "__exports",
10
- "__pos",
11
- "__tmp",
12
2
  // JS reserved words, and some "dangerous" words like `let`, `async`, `of` or
13
3
  // `undefined`, which aren't technically reserved but don't name your
14
4
  // variables that.
package/loaders/module.js CHANGED
@@ -4,11 +4,13 @@
4
4
  */
5
5
  export class ModuleLoader {
6
6
  #root;
7
- constructor(root) {
7
+ #extension;
8
+ constructor(root, extension = ".js") {
8
9
  this.#root = root;
10
+ this.#extension = extension;
9
11
  }
10
12
  async load(file) {
11
- const url = new URL(join(this.#root.pathname, file + ".js"), this.#root);
13
+ const url = new URL(join(this.#root.pathname, file + this.#extension), this.#root);
12
14
  const module = await import(url.toString());
13
15
  return module.default;
14
16
  }
@@ -18,33 +20,38 @@ export class ModuleLoader {
18
20
  }
19
21
  return join("/", file);
20
22
  }
21
- }
22
- function join(...parts) {
23
- return parts.join("/").replace(/\/+/g, "/");
24
- }
25
- function dirname(path) {
26
- const lastSlash = path.lastIndexOf("/");
27
- return lastSlash === -1 ? "." : path.slice(0, lastSlash);
28
- }
29
- /**
30
- * Exports a template as a string that can be used in an ES module.
31
- * This is useful for precompiled templates.
32
- */
33
- export function exportTemplate(template, options) {
34
- if (!template.source) {
35
- throw new Error("Template source is not defined");
36
- }
37
- const exportCode = `export default function (__env) {
23
+ /**
24
+ * Outputs a template as a string that can be used in an ES module.
25
+ * This is useful for precompiled templates.
26
+ * @returns A tuple with the path and the content of the module.
27
+ */
28
+ output(template, source = false) {
29
+ if (!template.source) {
30
+ throw new Error("Template source is not defined");
31
+ }
32
+ if (!template.path) {
33
+ throw new Error("Template path is not defined");
34
+ }
35
+ const content = `export default function (__env
36
+ ) {
38
37
  ${template.toString()};
39
38
 
40
- ${options?.source
41
- ? `__template.path = ${JSON.stringify(template.path)};
39
+ ${source
40
+ ? `__template.path = ${JSON.stringify(template.path)};
42
41
  __template.code = ${JSON.stringify(template.code)};
43
42
  __template.source = ${JSON.stringify(template.source)};`
44
- : ""}
43
+ : ""}
45
44
  __template.defaults = ${JSON.stringify(template.defaults || {})};
46
45
 
47
46
  return __template;
48
47
  }`;
49
- return exportCode;
48
+ return [`${template.path}${this.#extension}`, content];
49
+ }
50
+ }
51
+ function join(...parts) {
52
+ return parts.join("/").replace(/\/+/g, "/");
53
+ }
54
+ function dirname(path) {
55
+ const lastSlash = path.lastIndexOf("/");
56
+ return lastSlash === -1 ? "." : path.slice(0, lastSlash);
50
57
  }
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.3",
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,34 +1,50 @@
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
+ env.tags.push(slotTag);
5
6
  };
6
7
  }
8
+ const LAYOUT_TAG = /^layout\s+([^{]+|`[^`]+`)+(?:\{([^]*)\})?$/;
9
+ const SLOT_NAME = /^[a-z_]\w*$/i;
7
10
  function layoutTag(env, token, output, tokens) {
8
- const [, code] = token;
11
+ const [, code, position] = token;
9
12
  if (!code.startsWith("layout ")) {
10
13
  return;
11
14
  }
12
- const match = code?.match(/^layout\s+([^{]+|`[^`]+`)+(?:\{([\s|\S]*)\})?$/);
15
+ const match = code?.match(LAYOUT_TAG);
13
16
  if (!match) {
14
- throw new TokenError("Invalid layout tag", token);
17
+ throw new SourceError("Invalid layout tag", position);
15
18
  }
16
19
  const [_, file, data] = match;
17
- const varname = output.startsWith("__layout")
18
- ? output + "_layout"
19
- : "__layout";
20
- const compiled = [];
21
- const compiledFilters = env.compileFilters(tokens, varname);
22
- compiled.push("{");
23
- compiled.push(`let ${varname} = "";`);
24
- compiled.push(...env.compileTokens(tokens, varname, "/layout"));
25
- compiled.push(`${varname} = ${compiledFilters};`);
20
+ const compiledFilters = env.compileFilters(tokens, "__slots.content");
26
21
  const { dataVarname } = env.options;
27
- compiled.push(`const __tmp = await __env.run(${file},
28
- {...${dataVarname}${data ? `, ${data}` : ""}, content: ${env.compileFilters(tokens, varname)}},
29
- __template.path
30
- );
31
- ${output} += __tmp.content;`);
32
- compiled.push("}");
33
- return compiled.join("\n");
22
+ return `${output} += (await (async () => {
23
+ const __slots = { content: "" };
24
+ ${env.compileTokens(tokens, "__slots.content", "/layout").join("\n")}
25
+ __slots.content = __env.utils.safeString(${compiledFilters});
26
+ return __env.run(${file}, {
27
+ ...${dataVarname},
28
+ ...__slots,
29
+ ${data ?? ""}
30
+ }, __template.path, ${position});
31
+ })()).content;`;
32
+ }
33
+ function slotTag(env, token, _output, tokens) {
34
+ const [, code, position] = token;
35
+ if (!code.startsWith("slot ")) {
36
+ return;
37
+ }
38
+ const name = code.slice(4).trim();
39
+ if (!SLOT_NAME.test(name)) {
40
+ throw new SourceError(`Invalid slot name "${name}"`, position);
41
+ }
42
+ const compiledFilters = env.compileFilters(tokens, "__tmp");
43
+ return `{
44
+ let __tmp = '';
45
+ ${env.compileTokens(tokens, "__tmp", "/slot").join("\n")}
46
+ __slots.${name} ??= '';
47
+ __slots.${name} += ${compiledFilters};
48
+ __slots.${name} = __env.utils.safeString(__slots.${name});
49
+ }`;
34
50
  }
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 {};
@@ -5,15 +5,13 @@ import type { Loader, PrecompiledTemplate, Template } from "../core/environment.
5
5
  */
6
6
  export declare class ModuleLoader implements Loader {
7
7
  #private;
8
- constructor(root: URL);
8
+ constructor(root: URL, extension?: string);
9
9
  load(file: string): Promise<PrecompiledTemplate>;
10
10
  resolve(from: string, file: string): string;
11
+ /**
12
+ * Outputs a template as a string that can be used in an ES module.
13
+ * This is useful for precompiled templates.
14
+ * @returns A tuple with the path and the content of the module.
15
+ */
16
+ output(template: Template, source?: boolean): [string, string];
11
17
  }
12
- export interface ExportOptions {
13
- source?: boolean;
14
- }
15
- /**
16
- * Exports a template as a string that can be used in an ES module.
17
- * This is useful for precompiled templates.
18
- */
19
- export declare function exportTemplate(template: Template, options?: ExportOptions): string;