ventojs 2.0.0-canary.0 → 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
@@ -5,20 +5,46 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
7
  ## 2.0.0 - Unreleased
8
- Vento 2.0 is now dependency-free and compatible with browsers without requiring a build process.
8
+ Vento 2.0 is now dependency-free and compatible with browsers without a build step.
9
9
 
10
10
  ### Added
11
11
  - Build-less browser support.
12
12
  - `plugins/mod.ts` module to register all default plugins easily.
13
+ - Support for precompiled templates.
14
+ - New filesystem loader to use File System API.
15
+ - Better errors reporting [#131], [#137]
16
+ - `core/errors.ts` module to format errors.
13
17
 
14
18
  ### Changed
15
19
  - Renamed `src` directory to `core`.
16
20
  - Moved all loaders to the `loaders` root directory.
17
- - Implemented a different approach to resolve the variables without using `meriyah` to analyze the code. [#128]
21
+ - Implemented a different approach to resolve the variables without using `meriyah` to analyze the code [#128].
22
+ - The signature of `tag` plugins has changed:
23
+ ```diff
24
+ -- (env: Environment, code: string, output: string, tokens: Tokens[])
25
+ ++ (env: Environment, token: Token, output: string, tokens: Tokens[])
26
+ ```
27
+ - The `compileTokens` function has changed. The third argument is a string with the closing tag and now it throws an error if its not found:
28
+ ```diff
29
+ -- env.compileTokens(tokens, tmpOutput, ["/code"]);
30
+ -- if (tokens.length && (tokens[0][0] !== "tag" || tokens[0][1] !== "/code")) {
31
+ -- throw new Error("missing closing tag");
32
+ -- }
33
+ ++ env.compileTokens(tokens, tmpOutput, "/code");
34
+ ```
18
35
 
19
36
  ### Removed
37
+ - `runStringSync` function.
20
38
  - Deprecated option `useWith`.
21
39
  - All extenal dependencies (`meriyah`, `estree`, etc).
22
40
  - `bare.ts` file since now it's useless.
23
41
 
42
+ ### Fixed
43
+ - Functions output when the autoescape is enabled [#95]
44
+ - Improved escape filter performance [#134]
45
+
46
+ [#95]: https://github.com/ventojs/vento/issues/95
24
47
  [#128]: https://github.com/ventojs/vento/issues/128
48
+ [#131]: https://github.com/ventojs/vento/issues/131
49
+ [#134]: https://github.com/ventojs/vento/issues/134
50
+ [#137]: https://github.com/ventojs/vento/issues/137
@@ -1,6 +1,6 @@
1
1
  import iterateTopLevel from "./js.js";
2
2
  import tokenize from "./tokenizer.js";
3
- import { TemplateError } from "./errors.js";
3
+ import { createError, SourceError } from "./errors.js";
4
4
  export class Environment {
5
5
  cache = new Map();
6
6
  options;
@@ -9,6 +9,10 @@ export class Environment {
9
9
  filters = {};
10
10
  utils = {
11
11
  callMethod,
12
+ createError,
13
+ safeString(str) {
14
+ return new SafeString(str);
15
+ },
12
16
  };
13
17
  constructor(options) {
14
18
  this.options = options;
@@ -16,8 +20,8 @@ export class Environment {
16
20
  use(plugin) {
17
21
  plugin(this);
18
22
  }
19
- async run(file, data, from) {
20
- const template = await this.load(file, from);
23
+ async run(file, data, from, position) {
24
+ const template = await this.load(file, from, position);
21
25
  return await template(data);
22
26
  }
23
27
  async runString(source, data, file) {
@@ -33,16 +37,26 @@ export class Environment {
33
37
  const template = this.compile(source, file);
34
38
  return await template(data);
35
39
  }
36
- runStringSync(source, data) {
37
- const template = this.compile(source, "", {}, true);
38
- return template(data);
39
- }
40
- compile(source, path, defaults, sync = false) {
40
+ compile(source, path, defaults) {
41
41
  if (typeof source !== "string") {
42
- throw new Error(`The source code of "${path}" must be a string. Got ${typeof source}`);
42
+ throw new TypeError(`The source code of "${path}" must be a string. Got ${typeof source}`);
43
43
  }
44
44
  const tokens = this.tokenize(source, path);
45
- let code = this.compileTokens(tokens).join("\n");
45
+ const lastToken = tokens.at(-1);
46
+ if (lastToken[0] != "string") {
47
+ throw new SourceError("Unclosed tag", lastToken[2], path, source);
48
+ }
49
+ let code = "";
50
+ try {
51
+ code = this.compileTokens(tokens).join("\n");
52
+ }
53
+ catch (error) {
54
+ if (error instanceof SourceError) {
55
+ error.file ??= path;
56
+ error.source ??= source;
57
+ }
58
+ throw error;
59
+ }
46
60
  const { dataVarname, autoDataVarname } = this.options;
47
61
  if (autoDataVarname) {
48
62
  const generator = iterateTopLevel(code);
@@ -57,24 +71,35 @@ export class Environment {
57
71
  `;
58
72
  }
59
73
  }
60
- const constructor = new Function("__file", "__env", "__defaults", "__err", `return${sync ? "" : " async"} function (${dataVarname}) {
61
- let __pos = 0;
62
74
  try {
63
- ${dataVarname} = Object.assign({}, __defaults, ${dataVarname});
64
- const __exports = { content: "" };
65
- ${code}
66
- return __exports;
67
- } catch (cause) {
68
- const template = ${sync ? "" : "await"} __env.cache.get(__file);
69
- throw new __err(__file, template?.source, __pos, cause);
70
- }
71
- }
72
- `);
73
- const template = constructor(path, this, defaults, TemplateError);
74
- template.file = path;
75
- template.code = code;
76
- template.source = source;
77
- return template;
75
+ const constructor = new Function("__env", `return async function __template(${dataVarname}) {
76
+ let __pos=0;
77
+ try {
78
+ ${dataVarname} = Object.assign({}, __template.defaults, ${dataVarname});
79
+ const __exports = { content: "" };
80
+ ${code}
81
+ return __exports;
82
+ } catch (error) {
83
+ throw __env.utils.createError(error, __template, __pos);
84
+ }
85
+ }`);
86
+ const template = constructor(this);
87
+ template.path = path;
88
+ template.code = constructor.toString();
89
+ template.source = source;
90
+ template.defaults = defaults || {};
91
+ return template;
92
+ }
93
+ catch (error) {
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;
102
+ }
78
103
  }
79
104
  tokenize(source, path) {
80
105
  let tokens = tokenize(source);
@@ -86,7 +111,15 @@ export class Environment {
86
111
  }
87
112
  return tokens;
88
113
  }
89
- 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
+ }
90
123
  const path = this.options.loader.resolve(from || "", file);
91
124
  let cached = this.cache.get(path);
92
125
  if (cached) {
@@ -97,17 +130,32 @@ export class Environment {
97
130
  .split("?")[0]
98
131
  .split("#")[0];
99
132
  cached = this.options.loader.load(cleanPath)
100
- .then(({ source, data }) => this.compile(source, path, data));
133
+ .catch((error) => {
134
+ throw position !== undefined
135
+ ? new SourceError(`Error loading template: ${error.message}`, position, from)
136
+ : error;
137
+ })
138
+ .then((result) => {
139
+ if (typeof result === "function") {
140
+ return result(this);
141
+ }
142
+ const { source, data } = result;
143
+ return this.compile(source, path, data);
144
+ });
101
145
  this.cache.set(path, cached);
102
146
  return await cached;
103
147
  }
104
- compileTokens(tokens, outputVar = "__exports.content", stopAt) {
148
+ compileTokens(tokens, outputVar = "__exports.content", closeToken) {
105
149
  const compiled = [];
150
+ let openToken;
106
151
  tokens: while (tokens.length > 0) {
107
- if (stopAt && tokens[0][0] === "tag" && stopAt.includes(tokens[0][1])) {
108
- break;
152
+ const token = tokens.shift();
153
+ const [type, code, position] = token;
154
+ openToken ??= token;
155
+ // We found the closing tag, so we stop compiling
156
+ if (closeToken && type === "tag" && closeToken === code) {
157
+ return compiled;
109
158
  }
110
- const [type, code, pos] = tokens.shift();
111
159
  if (type === "comment") {
112
160
  continue;
113
161
  }
@@ -118,9 +166,9 @@ export class Environment {
118
166
  continue;
119
167
  }
120
168
  if (type === "tag") {
121
- compiled.push(`__pos = ${pos};`);
169
+ compiled.push(`__pos=${position};`);
122
170
  for (const tag of this.tags) {
123
- const compiledTag = tag(this, code, outputVar, tokens);
171
+ const compiledTag = tag(this, token, outputVar, tokens);
124
172
  if (typeof compiledTag === "string") {
125
173
  compiled.push(compiledTag);
126
174
  continue tokens;
@@ -131,17 +179,22 @@ export class Environment {
131
179
  compiled.push(`${outputVar} += (${expression}) ?? "";`);
132
180
  continue;
133
181
  }
134
- throw new Error(`Unknown token type "${type}"`);
182
+ throw new SourceError(`Unknown token type "${type}"`, position);
183
+ }
184
+ // If we reach here, it means we have an open token that wasn't closed
185
+ if (closeToken) {
186
+ throw new SourceError(`Missing closing tag ("${closeToken}" tag is expected)`, openToken[2]);
135
187
  }
136
188
  return compiled;
137
189
  }
138
190
  compileFilters(tokens, output, autoescape = false) {
139
191
  let unescaped = false;
140
192
  while (tokens.length > 0 && tokens[0][0] === "filter") {
141
- const [, code] = tokens.shift();
193
+ const token = tokens.shift();
194
+ const [, code, position] = token;
142
195
  const match = code.match(/^(await\s+)?([\w.]+)(?:\((.*)\))?$/);
143
196
  if (!match) {
144
- throw new Error(`Invalid filter: ${code}`);
197
+ throw new SourceError(`Invalid filter: ${code}`, position);
145
198
  }
146
199
  const [_, isAsync, name, args] = match;
147
200
  if (!Object.hasOwn(this.filters, name)) {
@@ -154,7 +207,7 @@ export class Environment {
154
207
  }
155
208
  else {
156
209
  // It's a prototype's method (e.g. `String.toUpperCase()`)
157
- output = `${isAsync ? "await " : ""}__env.utils.callMethod(${output}, "${name}", ${args ? args : ""})`;
210
+ output = `${isAsync ? "await " : ""}__env.utils.callMethod(${position}, ${output}, "${name}", ${args ? args : ""})`;
158
211
  }
159
212
  }
160
213
  else {
@@ -182,16 +235,19 @@ function isGlobal(name) {
182
235
  return Object.hasOwn(globalThis[obj], prop);
183
236
  }
184
237
  }
238
+ function callMethod(position,
185
239
  // deno-lint-ignore no-explicit-any
186
- function callMethod(thisObject, method, ...args) {
240
+ thisObject, method, ...args) {
187
241
  if (thisObject === null || thisObject === undefined) {
188
242
  return thisObject;
189
243
  }
190
244
  if (typeof thisObject[method] === "function") {
191
245
  return thisObject[method](...args);
192
246
  }
193
- throw new Error(`"${method}" is not a valid filter, global object or a method of a ${typeof thisObject} variable`);
247
+ throw new SourceError(`Method "${method}" is not a function of ${typeof thisObject} variable`, position);
194
248
  }
195
249
  function checkAsync(fn) {
196
250
  return fn.constructor?.name === "AsyncFunction";
197
251
  }
252
+ export class SafeString extends String {
253
+ }
package/core/errors.js CHANGED
@@ -1,34 +1,263 @@
1
- class VentoBaseError extends Error {
2
- name = this.constructor.name;
1
+ export class VentoError extends Error {
3
2
  }
4
- export class TemplateError extends VentoBaseError {
5
- path;
6
- source;
3
+ export class SourceError extends VentoError {
7
4
  position;
8
- constructor(path = "<unknown>", source = "<empty file>", position = 0, cause) {
9
- const { line, column, code } = errorLine(source, position);
10
- super(`Error in template ${path}:${line}:${column}\n\n${code.trim()}\n\n`, { cause });
11
- this.path = path;
12
- this.source = source;
5
+ file;
6
+ source;
7
+ constructor(message, position, file, source) {
8
+ super(message);
9
+ this.name = "SourceError";
13
10
  this.position = position;
11
+ this.file = file;
12
+ this.source = source;
13
+ }
14
+ getContext() {
15
+ return {
16
+ type: this.name,
17
+ message: this.message,
18
+ position: this.position,
19
+ file: this.file,
20
+ source: this.source,
21
+ };
14
22
  }
15
23
  }
16
- /** Returns the number and code of the errored line */
17
- export function errorLine(source, position) {
18
- let line = 1;
19
- let column = 1;
20
- for (let index = 0; index < position; index++) {
21
- if (source[index] === "\n" ||
22
- (source[index] === "\r" && source[index + 1] === "\n")) {
23
- line++;
24
- column = 1;
25
- if (source[index] === "\r") {
26
- index++;
24
+ export class RuntimeError extends VentoError {
25
+ #context;
26
+ position;
27
+ constructor(error, context, position) {
28
+ super(error.message);
29
+ this.name = error.name || "JavaScriptError";
30
+ this.#context = context;
31
+ this.cause = error;
32
+ this.position = position;
33
+ }
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
+ };
27
48
  }
49
+ catch {
50
+ return {
51
+ type: this.name || "JavaScriptError",
52
+ message: this.message,
53
+ source,
54
+ code,
55
+ file: path,
56
+ };
57
+ }
58
+ }
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
+ };
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
+ };
84
+ }
85
+ }
86
+ /** Create or complete VentoError with extra info from the template */
87
+ export function createError(error, context, position) {
88
+ if (error instanceof RuntimeError)
89
+ return error;
90
+ // If the error is a SourceError, we can fill the missing context information
91
+ if (error instanceof SourceError) {
92
+ error.file ??= context.path;
93
+ error.source ??= context.source;
94
+ error.position ??= position;
95
+ return error;
96
+ }
97
+ // JavaScript syntax errors can be parsed to get accurate position
98
+ return new RuntimeError(error, context, position);
99
+ }
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) {
116
+ if (error instanceof VentoError) {
117
+ const context = await error.getContext();
118
+ const fmt = typeof format === "string" ? formats[format] || plain : format;
119
+ if (context) {
120
+ console.error(stringifyError(context, fmt));
121
+ return;
122
+ }
123
+ }
124
+ console.error(error);
125
+ }
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;
129
+ const output = [];
130
+ // Print error type and message
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));
28
136
  }
29
- else {
30
- column++;
137
+ return output.join("\n");
138
+ }
139
+ const sourceLines = codeToLines(source);
140
+ const [sourceLine, sourceColumn] = getSourceLineColumn(sourceLines, position);
141
+ // Print file location if available
142
+ if (file) {
143
+ output.push(format.dim(`${file}:${sourceLine}:${sourceColumn}`));
144
+ }
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
+ return output.join("\n");
164
+ }
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;
31
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
+ };
32
189
  }
33
- return { line, column, code: source.split("\n")[line - 1] };
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];
221
+ }
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;
248
+ }
249
+ yield {
250
+ file: normalizeFile(file),
251
+ line: Number(line),
252
+ column: Number(column),
253
+ };
254
+ }
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;
34
263
  }
package/core/reserved.js CHANGED
@@ -2,6 +2,7 @@ const variables = new Set([
2
2
  // Words reserved by Vento, used internally. In general, don't use variable
3
3
  // names starting with two underscores to be future-proof and avoid clashes.
4
4
  "__file",
5
+ "__template",
5
6
  "__env",
6
7
  "__defaults",
7
8
  "__err",
package/core/tokenizer.js CHANGED
@@ -8,12 +8,12 @@ export default function tokenize(source) {
8
8
  const index = source.indexOf("{{");
9
9
  const code = index === -1 ? source : source.slice(0, index);
10
10
  tokens.push([type, code, position]);
11
- if (index === -1) {
12
- break;
13
- }
14
11
  position += index;
15
12
  source = source.slice(index);
16
13
  type = source.startsWith("{{#") ? "comment" : "tag";
14
+ if (index === -1) {
15
+ break;
16
+ }
17
17
  continue;
18
18
  }
19
19
  if (type === "comment") {
@@ -42,9 +42,11 @@ export default function tokenize(source) {
42
42
  return curr;
43
43
  }
44
44
  // Filters
45
- tokens.push(["filter", code]);
45
+ tokens.push(["filter", code, position + prev]);
46
46
  return curr;
47
47
  });
48
+ if (indexes[lastIndex] == Infinity)
49
+ return tokens;
48
50
  position += indexes[lastIndex];
49
51
  source = source.slice(indexes[lastIndex]);
50
52
  type = "string";
@@ -64,6 +66,9 @@ export default function tokenize(source) {
64
66
  continue;
65
67
  }
66
68
  }
69
+ if (type == "string") {
70
+ tokens.push([type, "", position]);
71
+ }
67
72
  return tokens;
68
73
  }
69
74
  /**
@@ -82,5 +87,6 @@ export function parseTag(source) {
82
87
  indexes.push(index + 2);
83
88
  return indexes;
84
89
  }
85
- throw new Error("Unclosed tag");
90
+ indexes.push(Infinity);
91
+ return indexes;
86
92
  }
package/loaders/file.js CHANGED
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
3
3
  import process from "node:process";
4
4
  /**
5
5
  * Vento file loader for loading templates from the file system.
6
- * Used by JS runtimes like Node, Deno, and Bun.
6
+ * Used by Node-like runtimes (Node, Deno, Bun, ...)
7
7
  */
8
8
  export class FileLoader {
9
9
  #root;
@@ -0,0 +1,36 @@
1
+ import { join } from "./utils.js";
2
+ /**
3
+ * Vento FileSystem API loader for loading templates.
4
+ * Used by browser environments.
5
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/File_System_API
6
+ */
7
+ export class FileSystemLoader {
8
+ #handle;
9
+ constructor(handle) {
10
+ this.#handle = handle;
11
+ }
12
+ async load(file) {
13
+ const parts = file.split("/");
14
+ let currentHandle = this.#handle;
15
+ while (parts.length > 1) {
16
+ const part = parts.shift();
17
+ if (part) {
18
+ currentHandle = await currentHandle.getDirectoryHandle(part, {
19
+ create: false,
20
+ });
21
+ }
22
+ }
23
+ const entry = await currentHandle.getFileHandle(parts[0], {
24
+ create: false,
25
+ });
26
+ const fileHandle = await entry.getFile();
27
+ const source = await fileHandle.text();
28
+ return { source };
29
+ }
30
+ resolve(from, file) {
31
+ if (file.startsWith(".")) {
32
+ return join(from, "..", file);
33
+ }
34
+ return join(file);
35
+ }
36
+ }