├── plugins ├── js.ts ├── empty.ts ├── unescape.ts ├── trim.ts ├── mod.ts ├── echo.ts ├── escape.ts ├── if.ts ├── function.ts ├── include.ts ├── default.ts ├── auto_trim.ts ├── import.ts ├── set.ts ├── export.ts ├── layout.ts └── for.ts ├── loaders ├── utils.ts ├── url.ts ├── memory.ts ├── file.ts ├── filesystem.ts └── module.ts ├── highlightjs-vento.js ├── prism-vento.js ├── web.ts ├── mod.ts ├── LICENSE ├── core ├── reserved.ts ├── tokenizer.ts ├── js.ts ├── errors.ts └── environment.ts ├── README.md └── CHANGELOG.md /plugins/js.ts: -------------------------------------------------------------------------------- 1 | import type { Token } from "../core/tokenizer.ts"; 2 | import type { Environment, Plugin } from "../core/environment.ts"; 3 | 4 | export default function (): Plugin { 5 | return (env: Environment) => { 6 | env.tags.push(jsTag); 7 | }; 8 | } 9 | 10 | function jsTag( 11 | _env: Environment, 12 | [, code]: Token, 13 | ): string | undefined { 14 | if (!code.startsWith(">")) { 15 | return; 16 | } 17 | 18 | return code.replace(/^>\s+/, ""); 19 | } 20 | -------------------------------------------------------------------------------- /loaders/utils.ts: -------------------------------------------------------------------------------- 1 | // Adapted from https://gist.github.com/creationix/7435851 2 | export function join(...paths: string[]): string { 3 | const parts = ([] as string[]) 4 | .concat(...paths.map((path) => path.split("/"))) 5 | .filter((part) => part && part !== "."); 6 | 7 | const newParts: string[] = []; 8 | 9 | for (const part of parts) { 10 | if (part === "..") { 11 | newParts.pop(); 12 | } else { 13 | newParts.push(part); 14 | } 15 | } 16 | 17 | newParts.unshift(""); // Ensure always a leading slash 18 | return newParts.join("/"); 19 | } 20 | -------------------------------------------------------------------------------- /highlightjs-vento.js: -------------------------------------------------------------------------------- 1 | /** @type LanguageFn */ 2 | export default function (hljs) { 3 | return { 4 | name: "vto", 5 | subLanguage: "xml", 6 | contains: [ 7 | hljs.COMMENT("{{#", "#}}"), 8 | { 9 | begin: "{{[-]?", 10 | end: "[-]?}}", 11 | subLanguage: "javascript", 12 | excludeBegin: true, 13 | excludeEnd: true, 14 | }, 15 | { 16 | begin: "^---\n", 17 | end: "\n---\n", 18 | subLanguage: "yaml", 19 | excludeBegin: true, 20 | excludeEnd: true, 21 | }, 22 | ], 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /plugins/empty.ts: -------------------------------------------------------------------------------- 1 | import { SafeString } from "../core/environment.ts"; 2 | import type { Environment, Plugin } from "../core/environment.ts"; 3 | 4 | export default function (): Plugin { 5 | return (env: Environment) => { 6 | env.filters.empty = (value: unknown): boolean => { 7 | if (!value) return true; 8 | if (typeof value == "string" || value instanceof SafeString) { 9 | return value.toString().trim() == ""; 10 | } 11 | if (typeof value != "object") return false; 12 | if (Array.isArray(value)) return value.length == 0; 13 | return false; 14 | }; 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /prism-vento.js: -------------------------------------------------------------------------------- 1 | Prism.languages.vto = { 2 | "delimiter": { 3 | pattern: /\{{2}[->]?|-?\}{2}/, 4 | alias: "punctuation", 5 | lookbehind: true, 6 | }, 7 | "comment": /^#[\s\S]*#$/, 8 | "javascript": { 9 | pattern: /\s*\S[\s\S]*/, 10 | alias: "language-javascript", 11 | inside: Prism.languages.javascript, 12 | }, 13 | }; 14 | 15 | Prism.hooks.add("before-tokenize", function (env) { 16 | const ventoPattern = /\{{2}[\s\S]+\}{2}/g; 17 | Prism.languages["markup-templating"].buildPlaceholders( 18 | env, 19 | "vto", 20 | ventoPattern, 21 | ); 22 | }); 23 | 24 | Prism.hooks.add("after-tokenize", function (env) { 25 | Prism.languages["markup-templating"].tokenizePlaceholders(env, "vto"); 26 | }); 27 | -------------------------------------------------------------------------------- /loaders/url.ts: -------------------------------------------------------------------------------- 1 | import { join } from "./utils.ts"; 2 | import type { Loader, TemplateSource } from "../core/environment.ts"; 3 | 4 | /** 5 | * Vento URL loader for loading templates from a URL. 6 | * Used by browser environments. 7 | */ 8 | export class UrlLoader implements Loader { 9 | #root: URL; 10 | 11 | constructor(root: URL) { 12 | this.#root = root; 13 | } 14 | 15 | async load(file: string): Promise { 16 | const url = new URL(join(this.#root.pathname, file), this.#root); 17 | const source = await (await fetch(url)).text(); 18 | 19 | return { source }; 20 | } 21 | 22 | resolve(from: string, file: string): string { 23 | if (file.startsWith(".")) { 24 | return join(from, "..", file); 25 | } 26 | 27 | return join(file); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /plugins/unescape.ts: -------------------------------------------------------------------------------- 1 | import type { Environment, Plugin } from "../core/environment.ts"; 2 | 3 | const NAMED_ENTITIES = /&(apos|quot|amp|lt|gt);/g; 4 | const CHAR_REF = /&#(x[0-9A-F]{1,6}|[0-9]{1,7});/gi; 5 | 6 | const entities: Record = { 7 | apos: "'", 8 | quot: '"', 9 | amp: "&", 10 | lt: "<", 11 | gt: ">", 12 | }; 13 | 14 | export default function (): Plugin { 15 | return (env: Environment) => { 16 | env.filters.unescape = (value: unknown) => { 17 | if (!value) return ""; 18 | return value.toString().replace(NAMED_ENTITIES, (_, name) => { 19 | return entities[name]; 20 | }).replace(CHAR_REF, (full, number) => { 21 | const parsed = Number(`0${number}`); 22 | if (parsed > 0x10FFFF) return full; 23 | return String.fromCodePoint(parsed); 24 | }); 25 | }; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /loaders/memory.ts: -------------------------------------------------------------------------------- 1 | import { join } from "./utils.ts"; 2 | import type { Loader, TemplateSource } from "../core/environment.ts"; 3 | 4 | /** 5 | * Vento loader for loading templates from an in-memory object. 6 | * Used for testing or in-memory operations. 7 | */ 8 | export class MemoryLoader implements Loader { 9 | files: Map; 10 | 11 | constructor(files: Record) { 12 | this.files = new Map(Object.entries(files)); 13 | } 14 | 15 | load(file: string): Promise { 16 | const source = this.files.get(file); 17 | if (source === undefined) { 18 | throw new Error(`File not found: ${file}`); 19 | } 20 | 21 | return Promise.resolve({ source }); 22 | } 23 | 24 | resolve(from: string, file: string): string { 25 | if (file.startsWith(".")) { 26 | return join(from, "..", file); 27 | } 28 | 29 | return join(file); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /loaders/file.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import fs from "node:fs/promises"; 3 | import process from "node:process"; 4 | import type { Loader, TemplateSource } from "../core/environment.ts"; 5 | 6 | /** 7 | * Vento file loader for loading templates from the file system. 8 | * Used by Node-like runtimes (Node, Deno, Bun, ...) 9 | */ 10 | export class FileLoader implements Loader { 11 | #root: string; 12 | 13 | constructor(root: string = process.cwd()) { 14 | this.#root = root; 15 | } 16 | 17 | async load(file: string): Promise { 18 | return { 19 | source: await fs.readFile(file, "utf-8"), 20 | }; 21 | } 22 | 23 | resolve(from: string, file: string): string { 24 | if (file.startsWith(".")) { 25 | return path.join(path.dirname(from), file); 26 | } 27 | 28 | if (file.startsWith(this.#root)) { 29 | return file; 30 | } 31 | 32 | return path.join(this.#root, file); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /web.ts: -------------------------------------------------------------------------------- 1 | import { Environment, type Loader } from "./core/environment.ts"; 2 | import { UrlLoader } from "./loaders/url.ts"; 3 | import defaultPlugins from "./plugins/mod.ts"; 4 | 5 | export interface Options { 6 | includes: URL | Loader; 7 | autoDataVarname?: boolean; 8 | dataVarname?: string; 9 | autoescape?: boolean; 10 | } 11 | 12 | export default function (options: Options): Environment { 13 | // Determine the loader based on the includes option 14 | const loader = options.includes instanceof URL 15 | ? new UrlLoader(options.includes) 16 | : options.includes; 17 | 18 | // Create a new Environment instance with the provided options 19 | const env = new Environment({ 20 | loader, 21 | dataVarname: options.dataVarname || "it", 22 | autoescape: options.autoescape ?? false, 23 | autoDataVarname: options.autoDataVarname ?? true, 24 | }); 25 | 26 | // Register the default plugins 27 | env.use(defaultPlugins()); 28 | 29 | return env; 30 | } 31 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import { Environment, type Loader } from "./core/environment.ts"; 2 | import { FileLoader } from "./loaders/file.ts"; 3 | import defaultPlugins from "./plugins/mod.ts"; 4 | 5 | export interface Options { 6 | includes?: string | Loader; 7 | autoDataVarname?: boolean; 8 | dataVarname?: string; 9 | autoescape?: boolean; 10 | strict?: boolean; 11 | } 12 | 13 | export default function (options: Options = {}): Environment { 14 | // Determine the loader based on the includes option 15 | const loader = typeof options.includes === "object" 16 | ? options.includes 17 | : new FileLoader(options.includes); 18 | 19 | // Create a new Environment instance with the provided options 20 | const env = new Environment({ 21 | loader, 22 | dataVarname: options.dataVarname || "it", 23 | autoescape: options.autoescape ?? false, 24 | autoDataVarname: options.autoDataVarname ?? true, 25 | strict: options.strict ?? false, 26 | }); 27 | 28 | // Register the default plugins 29 | env.use(defaultPlugins()); 30 | 31 | return env; 32 | } 33 | -------------------------------------------------------------------------------- /plugins/trim.ts: -------------------------------------------------------------------------------- 1 | import type { Token } from "../core/tokenizer.ts"; 2 | import type { Environment, Plugin } from "../core/environment.ts"; 3 | 4 | export default function (): Plugin { 5 | return (env: Environment) => { 6 | env.tokenPreprocessors.push(trim); 7 | }; 8 | } 9 | 10 | export function trim(_: Environment, tokens: Token[]) { 11 | for (let i = 0; i < tokens.length; i++) { 12 | const previous = tokens[i - 1]; 13 | const token = tokens[i]; 14 | const next = tokens[i + 1]; 15 | 16 | let [type, code] = token; 17 | 18 | if (["tag", "comment"].includes(type) && code.startsWith("-")) { 19 | previous[1] = previous[1].trimEnd(); 20 | code = code.slice(1); 21 | } 22 | 23 | if (["tag", "filter", "comment"].includes(type) && code.endsWith("-")) { 24 | next[1] = next[1].trimStart(); 25 | code = code.slice(0, -1); 26 | } 27 | 28 | // Trim tag and filter code 29 | switch (type) { 30 | case "tag": 31 | case "filter": 32 | token[1] = code.trim(); 33 | break; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Óscar Otero 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /plugins/mod.ts: -------------------------------------------------------------------------------- 1 | import type { Environment, Plugin } from "../core/environment.ts"; 2 | 3 | import ifTag from "./if.ts"; 4 | import forTag from "./for.ts"; 5 | import includeTag from "./include.ts"; 6 | import setTag from "./set.ts"; 7 | import defaultTag from "./default.ts"; 8 | import jsTag from "./js.ts"; 9 | import layoutTag from "./layout.ts"; 10 | import functionTag from "./function.ts"; 11 | import importTag from "./import.ts"; 12 | import exportTag from "./export.ts"; 13 | import echoTag from "./echo.ts"; 14 | import escape from "./escape.ts"; 15 | import unescape from "./unescape.ts"; 16 | import trim from "./trim.ts"; 17 | import empty from "./empty.ts"; 18 | 19 | export default function (): Plugin { 20 | return (env: Environment) => { 21 | env.use(ifTag()); 22 | env.use(forTag()); 23 | env.use(jsTag()); 24 | env.use(includeTag()); 25 | env.use(setTag()); 26 | env.use(defaultTag()); 27 | env.use(layoutTag()); 28 | env.use(functionTag()); 29 | env.use(importTag()); 30 | env.use(exportTag()); 31 | env.use(echoTag()); 32 | env.use(escape()); 33 | env.use(unescape()); 34 | env.use(trim()); 35 | env.use(empty()); 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /core/reserved.ts: -------------------------------------------------------------------------------- 1 | const variables = new Set([ 2 | // JS reserved words, and some "dangerous" words like `let`, `async`, `of` or 3 | // `undefined`, which aren't technically reserved but don't name your 4 | // variables that. 5 | "async", 6 | "await", 7 | "break", 8 | "case", 9 | "catch", 10 | "class", 11 | "const", 12 | "continue", 13 | "debugger", 14 | "default", 15 | "delete", 16 | "do", 17 | "else", 18 | "enum", 19 | "export", 20 | "extends", 21 | "false", 22 | "finally", 23 | "for", 24 | "function", 25 | "if", 26 | "import", 27 | "in", 28 | "instanceof", 29 | "let", 30 | "new", 31 | "null", 32 | "of", 33 | "return", 34 | "super", 35 | "switch", 36 | "this", 37 | "throw", 38 | "true", 39 | "try", 40 | "typeof", 41 | "undefined", 42 | "var", 43 | "void", 44 | "while", 45 | "with", 46 | "yield", 47 | 48 | // Variables that are already defined globally 49 | ...Object.getOwnPropertyNames(globalThis), 50 | ]); 51 | 52 | // Remove `name` from the reserved variables 53 | // because it's widely used in templates 54 | // and it can cause issues if it's reserved. 55 | variables.delete("name"); 56 | 57 | export default variables; 58 | -------------------------------------------------------------------------------- /plugins/echo.ts: -------------------------------------------------------------------------------- 1 | import type { Token } from "../core/tokenizer.ts"; 2 | import type { Environment, Plugin } from "../core/environment.ts"; 3 | 4 | export default function (): Plugin { 5 | return (env: Environment) => { 6 | env.tags.push(echoTag); 7 | }; 8 | } 9 | 10 | function echoTag( 11 | env: Environment, 12 | [, code]: Token, 13 | output: string, 14 | tokens: Token[], 15 | ): string | undefined { 16 | if (!/^echo\b/.test(code)) { 17 | return; 18 | } 19 | 20 | const inline = code.slice(4).trim(); 21 | // Inline value, e.g. {{ echo "foo" |> toUpperCase() }} 22 | if (inline) { 23 | const compiled = env.compileFilters(tokens, inline, env.options.autoescape); 24 | return `${output} += ${compiled};`; 25 | } 26 | 27 | // Captured echo, e.g. {{ echo |> toUpperCase }} foo {{ /echo }} 28 | const tmp = env.getTempVariable(); 29 | const compiled = [`let ${tmp} = "";`]; 30 | const filters = env.compileFilters(tokens, tmp); 31 | compiled.push(...env.compileTokens(tokens, tmp, "/echo")); 32 | 33 | if (filters != tmp) { 34 | compiled.push(`${tmp} = ${filters}`); 35 | } 36 | 37 | return `{ 38 | ${compiled.join("\n")} 39 | ${output} += ${tmp}; 40 | }`; 41 | } 42 | -------------------------------------------------------------------------------- /plugins/escape.ts: -------------------------------------------------------------------------------- 1 | import { SafeString } from "../core/environment.ts"; 2 | import type { Environment, Plugin } from "../core/environment.ts"; 3 | 4 | const UNSAFE = /[<>"&']/g; 5 | 6 | export default function (): Plugin { 7 | return (env: Environment) => { 8 | env.filters.escape = (value: unknown): string => { 9 | if (!value) return ""; 10 | if (value instanceof SafeString) return value.toString(); 11 | 12 | const str = value.toString(); 13 | 14 | let html = ""; 15 | let previous = 0; 16 | for (let match = UNSAFE.exec(str); match; match = UNSAFE.exec(str)) { 17 | html += str.slice(previous, match.index); 18 | previous = match.index + 1; 19 | switch (str.charCodeAt(match.index)) { 20 | case 34: // " 21 | html += """; 22 | break; 23 | case 39: // ' 24 | html += "'"; 25 | break; 26 | case 38: // & 27 | html += "&"; 28 | break; 29 | case 60: // < 30 | html += "<"; 31 | break; 32 | case 62: // > 33 | html += ">"; 34 | break; 35 | } 36 | } 37 | 38 | html += str.slice(previous); 39 | return html; 40 | }; 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /loaders/filesystem.ts: -------------------------------------------------------------------------------- 1 | import { join } from "./utils.ts"; 2 | import type { Loader, TemplateSource } from "../core/environment.ts"; 3 | 4 | /** 5 | * Vento FileSystem API loader for loading templates. 6 | * Used by browser environments. 7 | * @see https://developer.mozilla.org/en-US/docs/Web/API/File_System_API 8 | */ 9 | export class FileSystemLoader implements Loader { 10 | #handle: FileSystemDirectoryHandle; 11 | 12 | constructor(handle: FileSystemDirectoryHandle) { 13 | this.#handle = handle; 14 | } 15 | 16 | async load(file: string): Promise { 17 | const parts = file.split("/"); 18 | let currentHandle: FileSystemDirectoryHandle = this.#handle; 19 | 20 | while (parts.length > 1) { 21 | const part = parts.shift(); 22 | if (part) { 23 | currentHandle = await currentHandle.getDirectoryHandle(part, { 24 | create: false, 25 | }); 26 | } 27 | } 28 | 29 | const entry = await currentHandle.getFileHandle(parts[0], { 30 | create: false, 31 | }); 32 | const fileHandle = await entry.getFile(); 33 | const source = await fileHandle.text(); 34 | 35 | return { source }; 36 | } 37 | 38 | resolve(from: string, file: string): string { 39 | if (file.startsWith(".")) { 40 | return join(from, "..", file); 41 | } 42 | 43 | return join(file); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /plugins/if.ts: -------------------------------------------------------------------------------- 1 | import { SourceError } from "../core/errors.ts"; 2 | import type { Token } from "../core/tokenizer.ts"; 3 | import type { Environment, Plugin } from "../core/environment.ts"; 4 | 5 | export default function (): Plugin { 6 | return (env: Environment) => { 7 | env.tags.push(ifTag); 8 | env.tags.push(elseTag); 9 | }; 10 | } 11 | 12 | function ifTag( 13 | env: Environment, 14 | [, code]: Token, 15 | output: string, 16 | tokens: Token[], 17 | ): string | undefined { 18 | if (!code.startsWith("if ")) { 19 | return; 20 | } 21 | const condition = code.replace(/^if\s+/, "").trim(); 22 | const compiled: string[] = []; 23 | 24 | const val = env.compileFilters(tokens, condition); 25 | compiled.push(`if (${val}) {`); 26 | compiled.push(...env.compileTokens(tokens, output, "/if")); 27 | compiled.push("}"); 28 | 29 | return compiled.join("\n"); 30 | } 31 | 32 | function elseTag( 33 | _env: Environment, 34 | token: Token, 35 | ): string | undefined { 36 | const [, code, position] = token; 37 | 38 | if (!code.startsWith("else ") && code !== "else") { 39 | return; 40 | } 41 | const match = code.match(/^else(\s+if\s+(.*))?$/); 42 | 43 | if (!match) { 44 | throw new SourceError("Invalid else tag", position); 45 | } 46 | 47 | const [_, ifTag, condition] = match; 48 | 49 | if (ifTag) { 50 | return `} else if (${condition}) {`; 51 | } 52 | 53 | return "} else {"; 54 | } 55 | -------------------------------------------------------------------------------- /plugins/function.ts: -------------------------------------------------------------------------------- 1 | import { SourceError } from "../core/errors.ts"; 2 | import type { Token } from "../core/tokenizer.ts"; 3 | import type { Environment, Plugin } from "../core/environment.ts"; 4 | 5 | export default function (): Plugin { 6 | return (env: Environment) => { 7 | env.tags.push(functionTag); 8 | }; 9 | } 10 | 11 | function functionTag( 12 | env: Environment, 13 | token: Token, 14 | _output: string, 15 | tokens: Token[], 16 | ): string | undefined { 17 | const [, code, position] = token; 18 | 19 | if (!code.match(/^(export\s+)?(async\s+)?function\s/)) { 20 | return; 21 | } 22 | 23 | const match = code.match( 24 | /^(export\s+)?(async\s+)?function\s+(\w+)\s*(\([^]*\))?$/, 25 | ); 26 | 27 | if (!match) { 28 | throw new SourceError("Invalid function tag", position); 29 | } 30 | 31 | const [_, exp, as, name, args] = match; 32 | 33 | const compiled: string[] = []; 34 | compiled.push(`${as || ""} function ${name} ${args || "()"} {`); 35 | const tmp = env.getTempVariable(); 36 | compiled.push(`let ${tmp} = "";`); 37 | const result = env.compileFilters(tokens, tmp); 38 | 39 | if (exp) { 40 | compiled.push(...env.compileTokens(tokens, tmp, "/export")); 41 | } else { 42 | compiled.push(...env.compileTokens(tokens, tmp, "/function")); 43 | } 44 | 45 | compiled.push(`return __env.utils.safeString(${result});`); 46 | compiled.push(`}`); 47 | 48 | if (exp) { 49 | compiled.push(`__exports["${name}"] = ${name}`); 50 | } 51 | 52 | return compiled.join("\n"); 53 | } 54 | -------------------------------------------------------------------------------- /plugins/include.ts: -------------------------------------------------------------------------------- 1 | import { SourceError } from "../core/errors.ts"; 2 | import iterateTopLevel from "../core/js.ts"; 3 | import type { Token } from "../core/tokenizer.ts"; 4 | import type { Environment, Plugin } from "../core/environment.ts"; 5 | 6 | export default function (): Plugin { 7 | return (env: Environment) => { 8 | env.tags.push(includeTag); 9 | }; 10 | } 11 | 12 | const DIRECT_DATA = /["'`\w]\s+([a-z_$][^\s'"`]*)$/i; 13 | 14 | function includeTag( 15 | env: Environment, 16 | token: Token, 17 | output: string, 18 | tokens: Token[], 19 | ): string | undefined { 20 | const [, code, position] = token; 21 | if (!code.startsWith("include ")) { 22 | return; 23 | } 24 | 25 | const tagCode = code.substring(7).trim(); 26 | let file = tagCode; 27 | let data = ""; 28 | 29 | // includes { data } 30 | if (tagCode.endsWith("}")) { 31 | let bracketIndex = -1; 32 | for (const [index, reason] of iterateTopLevel(tagCode)) { 33 | if (reason == "{") bracketIndex = index; 34 | } 35 | if (bracketIndex == -1) { 36 | throw new SourceError("Invalid include tag", position); 37 | } 38 | file = tagCode.slice(0, bracketIndex).trim(); 39 | data = tagCode.slice(bracketIndex).trim(); 40 | } 41 | 42 | // Includes data directly (e.g. {{ include "template.vto" data }}) 43 | const directDataMatch = tagCode.match(DIRECT_DATA); 44 | if (directDataMatch) { 45 | data = directDataMatch[1]; 46 | file = tagCode.slice(0, -data.length).trim(); 47 | } 48 | 49 | const { dataVarname } = env.options; 50 | const tmp = env.getTempVariable(); 51 | return `{ 52 | const ${tmp} = await __env.run(${file}, 53 | {...${dataVarname}${data ? `, ...${data}` : ""}}, 54 | __template.path, 55 | ${position} 56 | ); 57 | ${output} += ${env.compileFilters(tokens, `${tmp}.content`)}; 58 | }`; 59 | } 60 | -------------------------------------------------------------------------------- /plugins/default.ts: -------------------------------------------------------------------------------- 1 | import { SourceError } from "../core/errors.ts"; 2 | import type { Token } from "../core/tokenizer.ts"; 3 | import type { Environment, Plugin } from "../core/environment.ts"; 4 | 5 | export default function (): Plugin { 6 | return (env: Environment) => { 7 | env.tags.push(defaultTag); 8 | }; 9 | } 10 | 11 | const VARNAME = /^[a-zA-Z_$][\w$]*$/; 12 | const VALID_TAG = /^([a-zA-Z_$][\w$]*)\s*=\s*([^]+)$/; 13 | 14 | function defaultTag( 15 | env: Environment, 16 | token: Token, 17 | _output: string, 18 | tokens: Token[], 19 | ): string | undefined { 20 | const [, code, position] = token; 21 | const { dataVarname } = env.options; 22 | 23 | if (!code.startsWith("default ")) { 24 | return; 25 | } 26 | 27 | const expression = code.replace("default", "").trim(); 28 | // Setting a value (e.g. {{ default foo = "bar" }} 29 | if (expression.includes("=")) { 30 | const match = expression.match(VALID_TAG); 31 | if (!match) { 32 | throw new SourceError("Invalid default tag", position); 33 | } 34 | const variable = match[1]; 35 | const value = env.compileFilters(tokens, match[2]); 36 | return ` 37 | if (typeof ${variable} == "undefined" || ${variable} === null) { 38 | var ${variable} = ${dataVarname}["${variable}"] = ${value}; 39 | } 40 | `; 41 | } 42 | 43 | // Capture a value (e.g. {{ default foo }}bar{{ /default }} 44 | if (!VARNAME.test(expression)) { 45 | throw new SourceError("Invalid default tag", position); 46 | } 47 | const subvarName = `${dataVarname}["${expression}"]`; 48 | const compiledFilters = env.compileFilters(tokens, subvarName); 49 | return ` 50 | if (typeof ${expression} == "undefined" || ${expression} === null) { 51 | ${subvarName} = ""; 52 | ${env.compileTokens(tokens, subvarName, "/default").join("")} 53 | var ${expression} = ${subvarName} = ${compiledFilters}; 54 | } 55 | `; 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Vento 4 |

5 | 6 | [![Deno](https://data.jsdelivr.com/v1/package/gh/ventojs/vento/badge)](https://www.jsdelivr.com/package/gh/ventojs/vento) 7 | [![NPM](https://img.shields.io/npm/v/ventojs)](https://www.npmjs.com/package/ventojs) 8 | [![Tests](https://github.com/ventojs/vento/workflows/Tests/badge.svg?branch=main)](https://github.com/ventojs/vento/actions/workflows/deno.yml) 9 | [![Discord](https://img.shields.io/badge/join-chat-blue?logo=discord&logoColor=white)](https://discord.gg/YbTmpACHWB) 10 | 11 | A minimal, ergonomic template engine inspired by other great engines like 12 | Nunjucks, Liquid, Mustache, and EJS. 13 | 14 |
15 | 16 |

17 | 18 |

19 | 20 | ## Features 21 | 22 | - Minimal, fast runtime. 🔥 23 | - No dependencies. 24 | - Compatible with browsers and JS runtimes (Deno, Node, Bun, etc). 25 | - Ergonomic by design. All tags and outputs are written with `{{` and `}}`. 26 | - Write JavaScript anywhere. `{{ await user.getName() }}` is real JS executed at 27 | runtime. 28 | - Built-in tags like `if`, `for`, `include`, `layout` and 29 | [more](https://vento.js.org). 30 | - Filters, using the `|>` pipeline operator. Inspired by the 31 | [F# pipeline operator proposal](https://github.com/valtech-nyc/proposal-fsharp-pipelines) 32 | - Async friendly. No need to use special tags. 33 | - Flexible plugin system. Nearly all of Vento's features and tags are 34 | implemented as plugins. 35 | 36 | ## Getting started 37 | 38 | See [Getting started](https://vento.js.org/getting-started/) in the docs for 39 | examples and guidance. 40 | 41 | ## Editor support 42 | 43 | See [Editor integrations](https://vento.js.org/editor-integrations/) in the 44 | docs. 45 | -------------------------------------------------------------------------------- /plugins/auto_trim.ts: -------------------------------------------------------------------------------- 1 | import type { Token } from "../core/tokenizer.ts"; 2 | import type { Environment, Plugin } from "../core/environment.ts"; 3 | 4 | export const defaultTags = [ 5 | ">", 6 | "set", 7 | "continue", 8 | "break", 9 | "/set", 10 | "default", 11 | "/default", 12 | "if", 13 | "/if", 14 | "else", 15 | "for", 16 | "/for", 17 | "function", 18 | "async", 19 | "/function", 20 | "export", 21 | "/export", 22 | "import", 23 | ]; 24 | 25 | const LEADING_WHITESPACE = /(^|\n)[ \t]+$/; 26 | const TRAILING_WHITESPACE = /^[ \t]*\r?\n/; 27 | 28 | export type AutoTrimOptions = { tags: string[] }; 29 | 30 | export default function ( 31 | options: AutoTrimOptions = { tags: defaultTags }, 32 | ): Plugin { 33 | return (env: Environment) => { 34 | env.tokenPreprocessors.push((_, tokens) => autoTrim(tokens, options)); 35 | }; 36 | } 37 | 38 | export function autoTrim(tokens: Token[], options: AutoTrimOptions) { 39 | for (let i = 0; i < tokens.length; i++) { 40 | const token = tokens[i]; 41 | const [type, code] = token; 42 | 43 | let needsTrim = false; 44 | if (type === "comment") { 45 | needsTrim = true; 46 | } else if (type === "tag") { 47 | needsTrim = options.tags.some((tag) => { 48 | if (!code.startsWith(tag)) return false; 49 | return /\s/.test(code[tag.length] ?? " "); 50 | }); 51 | } 52 | 53 | if (!needsTrim) continue; 54 | 55 | // Remove leading horizontal space 56 | const previous = tokens[i - 1]; 57 | previous[1] = previous[1].replace(LEADING_WHITESPACE, "$1"); 58 | 59 | // Skip "filter" tokens to find the next "string" token 60 | for (let j = i + 1; j < tokens.length; j++) { 61 | if (tokens[j][0] === "filter") continue; 62 | if (tokens[j][0] !== "string") break; 63 | // Remove trailing horizontal space + newline 64 | const next = tokens[j]; 65 | next[1] = next[1].replace(TRAILING_WHITESPACE, ""); 66 | break; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /loaders/module.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Loader, 3 | PrecompiledTemplate, 4 | Template, 5 | } from "../core/environment.ts"; 6 | 7 | /** 8 | * Vento loader for loading templates from a ES modules. 9 | * Used to load precompiled templates. 10 | */ 11 | export class ModuleLoader implements Loader { 12 | #root: URL; 13 | #extension: string; 14 | 15 | constructor(root: URL, extension = ".js") { 16 | this.#root = root; 17 | this.#extension = extension; 18 | } 19 | 20 | async load(file: string): Promise { 21 | const url = new URL( 22 | join(this.#root.pathname, file + this.#extension), 23 | this.#root, 24 | ); 25 | const module = await import(url.toString()); 26 | 27 | return module.default as PrecompiledTemplate; 28 | } 29 | 30 | resolve(from: string, file: string): string { 31 | if (file.startsWith(".")) { 32 | return join("/", dirname(from), file); 33 | } 34 | 35 | return join("/", file); 36 | } 37 | 38 | /** 39 | * Outputs a template as a string that can be used in an ES module. 40 | * This is useful for precompiled templates. 41 | * @returns A tuple with the path and the content of the module. 42 | */ 43 | output(template: Template, source = false): [string, string] { 44 | if (!template.source) { 45 | throw new Error("Template source is not defined"); 46 | } 47 | if (!template.path) { 48 | throw new Error("Template path is not defined"); 49 | } 50 | const content = `export default function (__env 51 | ) { 52 | ${template.toString()}; 53 | 54 | ${ 55 | source 56 | ? `__template.path = ${JSON.stringify(template.path)}; 57 | __template.code = ${JSON.stringify(template.code)}; 58 | __template.source = ${JSON.stringify(template.source)};` 59 | : "" 60 | } 61 | __template.defaults = ${JSON.stringify(template.defaults || {})}; 62 | 63 | return __template; 64 | }`; 65 | 66 | return [`${template.path}${this.#extension}`, content]; 67 | } 68 | } 69 | 70 | function join(...parts: string[]): string { 71 | return parts.join("/").replace(/\/+/g, "/"); 72 | } 73 | 74 | function dirname(path: string): string { 75 | const lastSlash = path.lastIndexOf("/"); 76 | return lastSlash === -1 ? "." : path.slice(0, lastSlash); 77 | } 78 | -------------------------------------------------------------------------------- /plugins/import.ts: -------------------------------------------------------------------------------- 1 | import { SourceError } from "../core/errors.ts"; 2 | import type { Token } from "../core/tokenizer.ts"; 3 | import type { Environment, Plugin } from "../core/environment.ts"; 4 | 5 | export default function (): Plugin { 6 | return (env: Environment) => { 7 | env.tags.push(importTag); 8 | }; 9 | } 10 | 11 | const IMPORT_STATEMENT = /^import\b\s*([^]*?)\s*\bfrom\b([^]*)$/; 12 | const DEFAULT_IMPORT = /^\b[a-zA-Z_]\w*\b$/i; 13 | const NAMED_IMPORTS = /^{[^]*?}$/; 14 | const AS = /\s+\bas\b\s+/; 15 | 16 | function importTag( 17 | env: Environment, 18 | token: Token, 19 | ): string | undefined { 20 | const [, code, position] = token; 21 | if (!code.startsWith("import ")) { 22 | return; 23 | } 24 | 25 | const match = code.match(IMPORT_STATEMENT); 26 | if (!match) { 27 | throw new SourceError("Invalid import tag", position); 28 | } 29 | 30 | const compiled: string[] = []; 31 | const variables: string[] = []; 32 | const [, identifiers, specifier] = match; 33 | 34 | const defaultImport = identifiers.match(DEFAULT_IMPORT); 35 | const tmp = env.getTempVariable(); 36 | if (defaultImport) { 37 | const [name] = defaultImport; 38 | variables.push(name); 39 | compiled.push(`${name} = ${tmp};`); 40 | } else { 41 | const namedImports = identifiers.match(NAMED_IMPORTS); 42 | if (namedImports) { 43 | const [full] = namedImports; 44 | const chunks = full.slice(1, -1).split(",").map((chunk) => { 45 | const names = chunk.trim().split(AS); 46 | if (names.length == 1) { 47 | const [name] = names; 48 | variables.push(name); 49 | return name; 50 | } else if (names.length == 2) { 51 | const [name, rename] = names; 52 | variables.push(rename); 53 | return `${name}: ${rename}`; 54 | } else { 55 | throw new SourceError("Invalid named import", position); 56 | } 57 | }); 58 | compiled.push(`({${chunks.join(",")}} = ${tmp});`); 59 | } else { 60 | throw new SourceError("Invalid import tag", position); 61 | } 62 | } 63 | 64 | const { dataVarname } = env.options; 65 | return `let ${variables.join(",")}; { 66 | let ${tmp} = await __env.run(${specifier}, {...${dataVarname}}, __template.path, ${position}); 67 | ${compiled.join("\n")} 68 | }`; 69 | } 70 | -------------------------------------------------------------------------------- /plugins/set.ts: -------------------------------------------------------------------------------- 1 | import { SourceError } from "../core/errors.ts"; 2 | import type { Token } from "../core/tokenizer.ts"; 3 | import type { Environment, Plugin } from "../core/environment.ts"; 4 | 5 | export default function (): Plugin { 6 | return (env: Environment) => { 7 | env.tags.push(setTag); 8 | }; 9 | } 10 | 11 | const VARNAME = /^[a-zA-Z_$][\w$]*$/; 12 | const DETECTED_VARS = /([a-zA-Z_$][\w$]*)\b(?!\s*\:)/g; 13 | const VALID_TAG = /^set\s+([\w{}[\]\s,:.$]+)\s*=\s*([\s\S]+)$/; 14 | 15 | function setTag( 16 | env: Environment, 17 | token: Token, 18 | _output: string, 19 | tokens: Token[], 20 | ): string | undefined { 21 | const [, code, position] = token; 22 | 23 | if (!code.startsWith("set ")) { 24 | return; 25 | } 26 | 27 | const expression = code.replace(/^set\s+/, ""); 28 | const { dataVarname } = env.options; 29 | 30 | // Value is set (e.g. {{ set foo = "bar" }}) 31 | if (expression.includes("=")) { 32 | const match = code.match(VALID_TAG); 33 | 34 | if (!match) { 35 | throw new SourceError("Invalid set tag", position); 36 | } 37 | 38 | const variable = match[1].trim(); 39 | const value = match[2].trim(); 40 | const val = env.compileFilters(tokens, value); 41 | 42 | if ( 43 | (variable.startsWith("{") && variable.endsWith("}")) || 44 | (variable.startsWith("[") && variable.endsWith("]")) 45 | ) { 46 | const names = Array.from(variable.matchAll(DETECTED_VARS)) 47 | .map((n) => n[1]); 48 | return ` 49 | var ${variable} = ${val}; 50 | Object.assign(${dataVarname}, { ${names.join(", ")} }); 51 | `; 52 | } 53 | if (!VARNAME.test(variable)) { 54 | throw new SourceError("Invalid variable name", position); 55 | } 56 | 57 | return `var ${variable} = ${dataVarname}["${variable}"] = ${val};`; 58 | } 59 | 60 | // Value is captured (eg: {{ set foo }}bar{{ /set }}) 61 | const compiled: string[] = []; 62 | const varName = expression.trim(); 63 | const subvarName = `${dataVarname}["${varName}"]`; 64 | const compiledFilters = env.compileFilters(tokens, subvarName); 65 | 66 | compiled.push(`${subvarName} = "";`); 67 | compiled.push(...env.compileTokens(tokens, subvarName, "/set")); 68 | compiled.push(`var ${varName} = ${subvarName} = ${compiledFilters};`); 69 | return compiled.join("\n"); 70 | } 71 | -------------------------------------------------------------------------------- /plugins/export.ts: -------------------------------------------------------------------------------- 1 | import { SourceError } from "../core/errors.ts"; 2 | 3 | import type { Token } from "../core/tokenizer.ts"; 4 | import type { Environment, Plugin } from "../core/environment.ts"; 5 | 6 | export default function (): Plugin { 7 | return (env: Environment) => { 8 | env.tags.push(exportTag); 9 | }; 10 | } 11 | 12 | const EXPORT_START = /^export\b\s*/; 13 | const BLOCK_EXPORT = /^([a-zA-Z_]\w*)\s*$/; 14 | const INLINE_NAMED_EXPORT = /^([a-zA-Z_]\w*)\s*=([^]*)$/; 15 | const NAMED_EXPORTS = /^{[^]*?}$/; 16 | const AS = /\s+\bas\b\s+/; 17 | 18 | function exportTag( 19 | env: Environment, 20 | token: Token, 21 | _output: string, 22 | tokens: Token[], 23 | ): string | undefined { 24 | const [, code, position] = token; 25 | const exportStart = code.match(EXPORT_START); 26 | if (!exportStart) { 27 | return; 28 | } 29 | 30 | const source = code.slice(exportStart[0].length); 31 | const compiled: string[] = []; 32 | const { dataVarname } = env.options; 33 | 34 | // {{ export foo }}content{{ /export }} 35 | const blockExport = source.match(BLOCK_EXPORT); 36 | if (blockExport) { 37 | const [, name] = blockExport; 38 | const compiledFilters = env.compileFilters(tokens, name); 39 | compiled.push(`var ${name} = "";`); 40 | compiled.push(...env.compileTokens(tokens, name, "/export")); 41 | 42 | compiled.push(`${name} = ${compiledFilters}`); 43 | compiled.push(`${dataVarname}["${name}"] = ${name};`); 44 | compiled.push(`__exports["${name}"] = ${name};`); 45 | return compiled.join("\n"); 46 | } 47 | 48 | // {{ export foo = "content" }} 49 | const inlineNamedExport = source.match(INLINE_NAMED_EXPORT); 50 | if (inlineNamedExport) { 51 | const [, name, content] = inlineNamedExport; 52 | compiled.push(`var ${name} = "";`); 53 | compiled.push(`${name} = ${env.compileFilters(tokens, content)};`); 54 | compiled.push(`${dataVarname}["${name}"] = ${name};`); 55 | compiled.push(`__exports["${name}"] = ${name};`); 56 | return compiled.join("\n"); 57 | } 58 | 59 | // {{ export { foo, bar as baz } }} 60 | const namedExports = source.match(NAMED_EXPORTS); 61 | if (namedExports) { 62 | const [full] = namedExports; 63 | const chunks = full.slice(1, -1).split(","); 64 | for (const chunk of chunks) { 65 | const names = chunk.trim().split(AS); 66 | if (names.length == 1) { 67 | const [name] = names; 68 | const value = `${dataVarname}["${name}"] ?? ${name}`; 69 | compiled.push(`__exports["${name}"] = ${value};`); 70 | } else if (names.length == 2) { 71 | const [name, rename] = names; 72 | const value = `${dataVarname}["${name}"] ?? ${name}`; 73 | compiled.push(`__exports["${rename}"] = ${value};`); 74 | } else { 75 | throw new SourceError("Invalid export", position); 76 | } 77 | } 78 | 79 | return compiled.join("\n"); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /plugins/layout.ts: -------------------------------------------------------------------------------- 1 | import { SourceError } from "../core/errors.ts"; 2 | import iterateTopLevel from "../core/js.ts"; 3 | import type { Token } from "../core/tokenizer.ts"; 4 | import type { Environment, Plugin } from "../core/environment.ts"; 5 | 6 | export default function (): Plugin { 7 | return (env: Environment) => { 8 | env.tags.push(layoutTag); 9 | env.tags.push(slotTag); 10 | }; 11 | } 12 | 13 | const SLOT_NAME = /^[a-z_]\w*$/i; 14 | const DIRECT_DATA = /["'`\w]\s+([a-z_$][^\s'"`]*)$/i; 15 | 16 | function layoutTag( 17 | env: Environment, 18 | token: Token, 19 | output: string, 20 | tokens: Token[], 21 | ): string | undefined { 22 | const [, code, position] = token; 23 | 24 | if (!code.startsWith("layout ")) { 25 | return; 26 | } 27 | 28 | const tagCode = code.slice(6).trim(); 29 | let file = tagCode; 30 | let data = ""; 31 | 32 | // Includes { data } 33 | if (tagCode.endsWith("}")) { 34 | let bracketIndex = -1; 35 | for (const [index, reason] of iterateTopLevel(tagCode)) { 36 | if (reason == "{") bracketIndex = index; 37 | } 38 | if (bracketIndex == -1) { 39 | throw new SourceError("Invalid layout tag", position); 40 | } 41 | file = tagCode.slice(0, bracketIndex).trim(); 42 | data = tagCode.slice(bracketIndex).trim(); 43 | } 44 | 45 | // Includes data directly (e.g. {{ layout "template.vto" data }}) 46 | const directDataMatch = tagCode.match(DIRECT_DATA); 47 | if (directDataMatch) { 48 | data = directDataMatch[1]; 49 | file = tagCode.slice(0, -data.length).trim(); 50 | } 51 | 52 | const compiledFilters = env.compileFilters(tokens, "__slots.content"); 53 | const { dataVarname } = env.options; 54 | return `${output} += (await (async () => { 55 | const __slots = { content: "" }; 56 | ${env.compileTokens(tokens, "__slots.content", "/layout", true).join("\n")} 57 | __slots.content = __env.utils.safeString(${compiledFilters}); 58 | return __env.run(${file}, { 59 | ...${dataVarname}, 60 | ...__slots, 61 | ${data ? "..." + data : ""} 62 | }, __template.path, ${position}); 63 | })()).content;`; 64 | } 65 | 66 | function slotTag( 67 | env: Environment, 68 | token: Token, 69 | _output: string, 70 | tokens: Token[], 71 | ): string | undefined { 72 | const [, code, position] = token; 73 | 74 | if (!code.startsWith("slot ")) { 75 | return; 76 | } 77 | 78 | const name = code.slice(4).trim(); 79 | if (!SLOT_NAME.test(name)) { 80 | throw new SourceError(`Invalid slot name "${name}"`, position); 81 | } 82 | 83 | const tmp = env.getTempVariable(); 84 | const compiledFilters = env.compileFilters(tokens, tmp); 85 | return `{ 86 | let ${tmp} = ''; 87 | ${env.compileTokens(tokens, tmp, "/slot").join("\n")} 88 | __slots.${name} ??= ''; 89 | __slots.${name} += ${compiledFilters}; 90 | __slots.${name} = __env.utils.safeString(__slots.${name}); 91 | }`; 92 | } 93 | -------------------------------------------------------------------------------- /core/tokenizer.ts: -------------------------------------------------------------------------------- 1 | import iterateTopLevel from "./js.ts"; 2 | 3 | export type TokenType = "string" | "tag" | "filter" | "comment"; 4 | export type Token = [TokenType, string, number]; 5 | 6 | export default function tokenize(source: string): Token[] { 7 | const tokens: Token[] = []; 8 | let type: TokenType = "string"; 9 | let position = 0; 10 | 11 | while (source.length > 0) { 12 | if (type === "string") { 13 | const index = source.indexOf("{{"); 14 | const code = index === -1 ? source : source.slice(0, index); 15 | 16 | tokens.push([type, code, position]); 17 | 18 | position += index; 19 | source = source.slice(index); 20 | type = source.startsWith("{{#") ? "comment" : "tag"; 21 | 22 | if (index === -1) { 23 | break; 24 | } 25 | 26 | continue; 27 | } 28 | 29 | if (type === "comment") { 30 | source = source.slice(3); 31 | const index = source.indexOf("#}}"); 32 | const comment = index === -1 ? source : source.slice(0, index); 33 | tokens.push([type, comment, position]); 34 | 35 | if (index === -1) { 36 | break; 37 | } 38 | 39 | position += index + 3; 40 | source = source.slice(index + 3); 41 | type = "string"; 42 | continue; 43 | } 44 | 45 | if (type === "tag") { 46 | const indexes = parseTag(source); 47 | const lastIndex = indexes.length - 1; 48 | let tag: Token | undefined; 49 | 50 | indexes.reduce((prev, curr, index) => { 51 | const code = source.slice(prev, curr - 2); 52 | 53 | // Tag 54 | if (index === 1) { 55 | tag = [type, code, position]; 56 | tokens.push(tag); 57 | return curr; 58 | } 59 | 60 | // Filters 61 | tokens.push(["filter", code, position + prev]); 62 | return curr; 63 | }); 64 | 65 | if (indexes[lastIndex] == Infinity) return tokens; 66 | 67 | position += indexes[lastIndex]; 68 | source = source.slice(indexes[lastIndex]); 69 | type = "string"; 70 | 71 | // Search the closing echo tag {{ /echo }} 72 | if (tag?.[1].match(/^\-?\s*echo\s*\-?$/)) { 73 | const end = /{{\-?\s*\/echo\s*\-?}}/.exec(source); 74 | 75 | if (!end) { 76 | tokens.push(["string", source, position]); 77 | return tokens; 78 | } 79 | 80 | tokens.push(["string", source.slice(0, end.index), position]); 81 | position += end.index; 82 | tokens.push(["tag", end[0].slice(2, -2), position]); 83 | position += end[0].length; 84 | source = source.slice(end.index + end[0].length); 85 | } 86 | 87 | continue; 88 | } 89 | } 90 | if (type == "string") { 91 | tokens.push([type, "", position]); 92 | } 93 | return tokens; 94 | } 95 | 96 | /** 97 | * Parse a tag and return the indexes of the start and end brackets, and the filters between. 98 | * For example: {{ tag |> filter1 |> filter2 }} => [2, 9, 20, 31] 99 | */ 100 | export function parseTag(source: string): number[] { 101 | const indexes = [2]; 102 | for (const [index, reason] of iterateTopLevel(source, 2)) { 103 | if (reason == "|>") { 104 | indexes.push(index + 2); 105 | continue; 106 | } else if (!source.startsWith("}}", index)) continue; 107 | indexes.push(index + 2); 108 | return indexes; 109 | } 110 | indexes.push(Infinity); 111 | return indexes; 112 | } 113 | -------------------------------------------------------------------------------- /plugins/for.ts: -------------------------------------------------------------------------------- 1 | import { SourceError } from "../core/errors.ts"; 2 | import iterateTopLevel from "../core/js.ts"; 3 | import type { Token } from "../core/tokenizer.ts"; 4 | import type { Environment, Plugin } from "../core/environment.ts"; 5 | 6 | export default function (): Plugin { 7 | return (env: Environment) => { 8 | env.tags.push(forTag); 9 | env.utils.toIterator = toIterator; 10 | }; 11 | } 12 | 13 | function forTag( 14 | env: Environment, 15 | token: Token, 16 | output: string, 17 | tokens: Token[], 18 | ): string | undefined { 19 | const [, code, position] = token; 20 | 21 | if (code === "break" || code === "continue") { 22 | return `${code};`; 23 | } 24 | 25 | if (!code.startsWith("for ")) { 26 | return; 27 | } 28 | 29 | const compiled: string[] = []; 30 | 31 | const match = code.match( 32 | /^for\s+(await\s+)?([\s\S]*)$/, 33 | ); 34 | 35 | if (!match) { 36 | throw new SourceError("Invalid for loop", position); 37 | } 38 | 39 | let [, aw, tagCode] = match; 40 | let var1: string; 41 | let var2: string | undefined = undefined; 42 | let collection = ""; 43 | 44 | if (tagCode.startsWith("[") || tagCode.startsWith("{")) { 45 | [var1, tagCode] = getDestructureContent(tagCode); 46 | } else { 47 | const parts = tagCode.match(/(^[^\s,]+)([\s|\S]+)$/); 48 | if (!parts) { 49 | throw new SourceError("Invalid for loop", position); 50 | } 51 | 52 | var1 = parts[1].trim(); 53 | tagCode = parts[2].trim(); 54 | } 55 | 56 | if (tagCode.startsWith(",")) { 57 | tagCode = tagCode.slice(1).trim(); 58 | 59 | if (tagCode.startsWith("[") || tagCode.startsWith("{")) { 60 | [var2, tagCode] = getDestructureContent(tagCode); 61 | collection = tagCode.slice(3).trim(); // Remove "of " from the start 62 | } else { 63 | const parts = tagCode.match(/^([\w]+)\s+of\s+([\s|\S]+)$/); 64 | if (!parts) { 65 | throw new SourceError("Invalid for loop", position); 66 | } 67 | 68 | var2 = parts[1].trim(); 69 | collection = parts[2].trim(); 70 | } 71 | } else if (tagCode.startsWith("of ")) { 72 | collection = tagCode.slice(3).trim(); 73 | } else { 74 | throw new SourceError("Invalid for loop", position); 75 | } 76 | 77 | if (var2) { 78 | compiled.push( 79 | `for ${aw || ""}(let [${var1}, ${var2}] of __env.utils.toIterator(${ 80 | env.compileFilters(tokens, collection) 81 | }, true)) {`, 82 | ); 83 | } else { 84 | compiled.push( 85 | `for ${aw || ""}(let ${var1} of __env.utils.toIterator(${ 86 | env.compileFilters(tokens, collection) 87 | })) {`, 88 | ); 89 | } 90 | 91 | compiled.push(...env.compileTokens(tokens, output, "/for")); 92 | compiled.push("}"); 93 | 94 | return compiled.join("\n"); 95 | } 96 | 97 | function toIterator( 98 | // deno-lint-ignore no-explicit-any 99 | item: any, 100 | withKeys = false, 101 | ): Iterable | AsyncIterable | Array { 102 | if (item === undefined || item === null) { 103 | return []; 104 | } 105 | 106 | if (Array.isArray(item)) { 107 | return withKeys ? item.map((value, i) => [i, value]) : item; 108 | } 109 | 110 | if (typeof item === "function") { 111 | return toIterator(item(), withKeys); 112 | } 113 | 114 | if (typeof item === "object" && item !== null) { 115 | if (typeof item[Symbol.iterator] === "function") { 116 | if (withKeys) { 117 | return iterableToEntries(item as Iterable); 118 | } 119 | return item as Iterable; 120 | } 121 | 122 | if (typeof item[Symbol.asyncIterator] === "function") { 123 | if (withKeys) { 124 | return asyncIterableToEntries(item as AsyncIterable); 125 | } 126 | 127 | return item as AsyncIterable; 128 | } 129 | 130 | return withKeys ? Object.entries(item) : Object.values(item); 131 | } 132 | 133 | if (typeof item === "string") { 134 | return toIterator(item.split(""), withKeys); 135 | } 136 | 137 | if (typeof item === "number") { 138 | return toIterator(new Array(item).fill(0).map((_, i) => i + 1), withKeys); 139 | } 140 | 141 | return toIterator([item], withKeys); 142 | } 143 | 144 | function* iterableToEntries( 145 | iterator: Iterable, 146 | ): Generator<[number, unknown]> { 147 | let i = 0; 148 | for (const value of iterator) { 149 | yield [i++, value]; 150 | } 151 | } 152 | 153 | async function* asyncIterableToEntries( 154 | iterator: AsyncIterable, 155 | ): AsyncGenerator<[number, unknown]> { 156 | let i = 0; 157 | for await (const value of iterator) { 158 | yield [i++, value]; 159 | } 160 | } 161 | 162 | function getDestructureContent(code: string): [string, string] { 163 | const generator = iterateTopLevel(code); 164 | generator.next(); 165 | const [position] = generator.next().value; 166 | return [ 167 | code.slice(0, position + 1).trim(), 168 | code.slice(position + 1).trim(), 169 | ]; 170 | } 171 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | ## [2.3.0] - Unreleased 8 | ### Added 9 | - New `default` tag to assign fallback content to a variable [#164], [#166]. 10 | - `include` and `layout` tags allows direct data [#163], [#167]. 11 | Example: `{{ include "file.vto" data }}` 12 | 13 | ### Fixed 14 | - Support `break` and `continue` tags by `auto_trim` plugin. 15 | 16 | ## [2.2.0] - 2025-10-15 17 | ### Added 18 | - Support for destructuring in set [#158] [#154]. 19 | 20 | ### Fixed 21 | - Possible variables naming collision [#157]. 22 | - `auto_trim` plugin edge cases [#159]. 23 | - `set`: allow `$` character in the variable name. 24 | 25 | ## [2.1.1] - 2025-09-18 26 | ### Fixed 27 | - The tag `include` fails when it's inside a `slot`. 28 | 29 | ## [2.1.0] - 2025-09-17 30 | ### Added 31 | - New `strict` mode to fail when using an undefined variable. This mode has a different performance profile than normal mode; it's mostly intended for testing and debug purposes. [#101], [#142] 32 | 33 | ### Fixed 34 | - Variable detection with spread operator [#156] 35 | 36 | ## [2.0.2] - 2025-09-13 37 | ### Added 38 | - The closing tag `{{ /layout }}` is optional. [#145], [#151]. 39 | 40 | ### Fixed 41 | - Static content after a function declaration is not printed [#147], [#150]. 42 | - Fix and simplify escaping of JSON (and other) [#146], [#148]. 43 | - Improved performance for `escape` filter and `compileFilters` internal function. 44 | - Use `SafeString` object only if `autoescape` is `true`. 45 | 46 | ## [2.0.1] - 2025-09-05 47 | ### Fixed 48 | - One-letter variable names are not captured. 49 | 50 | ## [2.0.0] - 2025-09-01 51 | Vento 2.0 is now dependency-free and compatible with browsers without a build step. 52 | 53 | ### Added 54 | - Build-less browser support. 55 | - `plugins/mod.ts` module to register all default plugins easily. 56 | - Support for precompiled templates. 57 | - New filesystem loader to use File System API. 58 | - Better errors reporting [#131], [#137] 59 | - `core/errors.ts` module to format errors. 60 | - New `{{ slot }}` tag to pass extra variables to `{{ layout }}` [#140] 61 | 62 | ### Changed 63 | - Renamed `src` directory to `core`. 64 | - Moved all loaders to the `loaders` root directory. 65 | - Implemented a different approach to resolve the variables without using `meriyah` to analyze the code [#128]. 66 | - The signature of `tag` plugins has changed: 67 | ```diff 68 | -- (env: Environment, code: string, output: string, tokens: Tokens[]) 69 | ++ (env: Environment, token: Token, output: string, tokens: Tokens[]) 70 | ``` 71 | - 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: 72 | ```diff 73 | -- env.compileTokens(tokens, tmpOutput, ["/code"]); 74 | -- if (tokens.length && (tokens[0][0] !== "tag" || tokens[0][1] !== "/code")) { 75 | -- throw new Error("missing closing tag"); 76 | -- } 77 | ++ env.compileTokens(tokens, tmpOutput, "/code"); 78 | ``` 79 | - Prism and Highlight.js adapters: Rename language name to `vto` (from `vento`). 80 | 81 | ### Removed 82 | - `runStringSync` function. 83 | - Deprecated option `useWith`. 84 | - All extenal dependencies (`meriyah`, `estree`, etc). 85 | - `bare.ts` file since now it's useless. 86 | 87 | ### Fixed 88 | - Functions output when the autoescape is enabled [#95] 89 | - Improved escape filter performance [#134] 90 | 91 | [#95]: https://github.com/ventojs/vento/issues/95 92 | [#101]: https://github.com/ventojs/vento/issues/101 93 | [#128]: https://github.com/ventojs/vento/issues/128 94 | [#131]: https://github.com/ventojs/vento/issues/131 95 | [#134]: https://github.com/ventojs/vento/issues/134 96 | [#137]: https://github.com/ventojs/vento/issues/137 97 | [#140]: https://github.com/ventojs/vento/issues/140 98 | [#142]: https://github.com/ventojs/vento/issues/142 99 | [#145]: https://github.com/ventojs/vento/issues/145 100 | [#146]: https://github.com/ventojs/vento/issues/146 101 | [#147]: https://github.com/ventojs/vento/issues/147 102 | [#148]: https://github.com/ventojs/vento/issues/148 103 | [#150]: https://github.com/ventojs/vento/issues/150 104 | [#151]: https://github.com/ventojs/vento/issues/151 105 | [#154]: https://github.com/ventojs/vento/issues/154 106 | [#156]: https://github.com/ventojs/vento/issues/156 107 | [#157]: https://github.com/ventojs/vento/issues/157 108 | [#158]: https://github.com/ventojs/vento/issues/158 109 | [#159]: https://github.com/ventojs/vento/issues/159 110 | [#163]: https://github.com/ventojs/vento/issues/163 111 | [#164]: https://github.com/ventojs/vento/issues/164 112 | [#166]: https://github.com/ventojs/vento/issues/166 113 | [#167]: https://github.com/ventojs/vento/issues/167 114 | 115 | [2.3.0]: https://github.com/ventojs/vento/compare/v2.2.0...HEAD 116 | [2.2.0]: https://github.com/ventojs/vento/compare/v2.1.1...v2.2.0 117 | [2.1.1]: https://github.com/ventojs/vento/compare/v2.1.0...v2.1.1 118 | [2.1.0]: https://github.com/ventojs/vento/compare/v2.0.2...v2.1.0 119 | [2.0.2]: https://github.com/ventojs/vento/compare/v2.0.1...v2.0.2 120 | [2.0.1]: https://github.com/ventojs/vento/compare/v2.0.0...v2.0.1 121 | [2.0.0]: https://github.com/ventojs/vento/releases/tag/v2.0.0 122 | -------------------------------------------------------------------------------- /core/js.ts: -------------------------------------------------------------------------------- 1 | import reserved from "./reserved.ts"; 2 | 3 | const TEMPLATE_PART = /[`}](?:\\?[^])*?(?:`|\${)/y; 4 | const REGEX_LITERAL_START = /(?<=[(=:,?&!]\s*)\//y; 5 | const STOPPING_POINT = /['"`{}[\]/|]|(((?<=\.\.\.)|(?]> { 21 | const variables = new Set(); 22 | let cursor = start; 23 | let depth = -1; 24 | const brackets = []; 25 | const max = source.length; 26 | 27 | parsing: while (cursor < max) { 28 | // Search for the next stopping point (e.g., a brace, quote, or regex). 29 | STOPPING_POINT.lastIndex = cursor; 30 | const match = STOPPING_POINT.exec(source); 31 | 32 | // No stopping point found, stop parsing. 33 | if (!match) { 34 | break parsing; 35 | } 36 | 37 | cursor = match.index; 38 | const [stop, variable] = match; 39 | if (variable) { 40 | cursor += variable.length; 41 | // Words used internally by Vento start with two underscores 42 | if (!reserved.has(variable) && !variable.startsWith("__")) { 43 | variables.add(variable); 44 | } 45 | continue; 46 | } 47 | 48 | // Check the type of the stopping point. 49 | switch (stop) { 50 | case "|": { 51 | cursor++; 52 | // It's a pipe `|>` in the top-level scope 53 | if (depth < 0 && source[cursor] === ">") { 54 | cursor++; 55 | yield [cursor - 2, "|>", variables]; 56 | } 57 | break; 58 | } 59 | 60 | case "'": 61 | case `"`: { 62 | // It's a quote or double-quote string: find the end. 63 | let escapes = 0; 64 | do { 65 | cursor = source.indexOf(stop, cursor + 1); 66 | if (cursor == -1) { // No closing quote found 67 | break parsing; 68 | } 69 | escapes = 0; 70 | // Handle escaped quotes 71 | while (source[cursor - 1 - escapes] == "\\") { 72 | escapes++; 73 | } 74 | } while (escapes % 2 != 0); 75 | cursor++; 76 | break; 77 | } 78 | 79 | case "{": { 80 | // It's an opening brace: yield if it's in the top-level scope. 81 | if (depth < 0) yield [cursor, "{", variables]; 82 | cursor++; 83 | // Handle `{}` 84 | if (source[cursor] == "}") cursor++; 85 | // Push the opening brace onto the stack. 86 | else brackets[++depth] = "{"; 87 | break; 88 | } 89 | 90 | case "[": { 91 | // It's an opening brace: yield if it's in the top-level scope. 92 | if (depth < 0) yield [cursor, "[", variables]; 93 | cursor++; 94 | 95 | // Handle `[]` 96 | if (source[cursor] == "]") cursor++; 97 | // Push the opening brace onto the stack. 98 | else brackets[++depth] = "["; 99 | break; 100 | } 101 | 102 | case "]": { 103 | // Close the last opened bracket if it matches. 104 | if (brackets[depth] == "[") depth--; 105 | 106 | // Yield if it's in the top-level scope. 107 | if (depth < 0) yield [cursor, "]", variables]; 108 | cursor++; 109 | break; 110 | } 111 | 112 | case "}": { 113 | // Close the last opened brace if it matches. 114 | if (brackets[depth] == "{") { 115 | depth--; 116 | // Yield if it's in the top-level scope. 117 | if (depth < 0) yield [cursor, "}", variables]; 118 | cursor++; 119 | break; 120 | } 121 | 122 | // If it doesn't match, but we're in the top-level scope, yield anyway. 123 | if (depth < 0) { 124 | yield [cursor, "}", variables]; 125 | cursor++; 126 | break; 127 | } 128 | 129 | // Break if we're not inside in a template literal. 130 | // otherwise, continue parsing. 131 | if (brackets[depth] != "`") { 132 | cursor++; 133 | break; 134 | } 135 | 136 | depth--; 137 | } /* falls through */ 138 | 139 | case "`": { 140 | // Search for template literal part or end. 141 | TEMPLATE_PART.lastIndex = cursor; 142 | const match = TEMPLATE_PART.exec(source); 143 | 144 | // If we don't find anything, return end of the string. 145 | if (!match) return [max, ""]; 146 | 147 | const [part] = match; 148 | cursor += part.length; 149 | 150 | // We found the end of the template literal 151 | if (source[cursor - 1] == "`") break; 152 | 153 | // Otherwise, we found a template literal part. 154 | // Store the opening backtick in the stack. 155 | brackets[++depth] = "`"; 156 | break; 157 | } 158 | 159 | case "/": { 160 | // It's a line comment 161 | if (source[cursor + 1] == "/") { 162 | cursor = source.indexOf("\n", cursor + 2); 163 | if (cursor == -1) break parsing; 164 | break; 165 | } 166 | 167 | // It's a block comment 168 | if (source[cursor + 1] == "*") { 169 | cursor = source.indexOf("*/", cursor + 2); 170 | if (cursor == -1) break parsing; 171 | break; 172 | } 173 | 174 | // Check if it's a regex literal. 175 | REGEX_LITERAL_START.lastIndex = cursor; 176 | if (!REGEX_LITERAL_START.test(source)) { 177 | cursor++; 178 | break; 179 | } 180 | 181 | // It's a regex literal: find the end. 182 | let inCharClass = false; 183 | cursor++; 184 | do { 185 | const character = source[cursor]; 186 | cursor++; 187 | switch (character) { 188 | case "\\": 189 | cursor++; 190 | break; 191 | case "[": 192 | inCharClass = true; 193 | break; 194 | case "]": 195 | inCharClass = false; 196 | break; 197 | case "/": 198 | if (!inCharClass) continue parsing; 199 | break; 200 | } 201 | } while (cursor < max); 202 | break parsing; 203 | } 204 | } 205 | } 206 | return [max, "", variables]; 207 | } 208 | -------------------------------------------------------------------------------- /core/errors.ts: -------------------------------------------------------------------------------- 1 | import type { TemplateContext } from "./environment.ts"; 2 | 3 | export interface ErrorContext { 4 | /* The type of error, e.g., "SourceError", "SyntaxError", etc. */ 5 | type: string; 6 | /* The error message */ 7 | message: string; 8 | /* The source code (.vto) where the error occurred */ 9 | source?: string; 10 | /* The token position in the source code */ 11 | position?: number; 12 | /* The compiled code where the error occurred */ 13 | code?: string; 14 | /* The line number in the compiled code where the error occurred */ 15 | line?: number; 16 | /* The column number in the compiled code where the error occurred */ 17 | column?: number; 18 | /* The file path where the error occurred */ 19 | file?: string; 20 | } 21 | 22 | export abstract class VentoError extends Error { 23 | abstract getContext(): ErrorContext | Promise; 24 | } 25 | 26 | export class SourceError extends VentoError { 27 | position?: number; 28 | file?: string; 29 | source?: string; 30 | 31 | constructor( 32 | message: string, 33 | position?: number, 34 | file?: string, 35 | source?: string, 36 | ) { 37 | super(message); 38 | this.name = "SourceError"; 39 | this.position = position; 40 | this.file = file; 41 | this.source = source; 42 | } 43 | 44 | getContext() { 45 | return { 46 | type: this.name, 47 | message: this.message, 48 | position: this.position, 49 | file: this.file, 50 | source: this.source, 51 | }; 52 | } 53 | } 54 | 55 | export class RuntimeError extends VentoError { 56 | #context: TemplateContext; 57 | position?: number; 58 | 59 | constructor(error: Error, context: TemplateContext, position?: number) { 60 | super(error.message); 61 | this.name = error.name || "JavaScriptError"; 62 | this.#context = context; 63 | this.cause = error; 64 | this.position = position; 65 | } 66 | 67 | async getContext() { 68 | const { code, source, path } = this.#context; 69 | 70 | // If we don't have the position, we cannot provide a context 71 | // Try to get the context from a SyntaxError 72 | if (this.position === undefined) { 73 | try { 74 | return (await getSyntaxErrorContext( 75 | this.cause as SyntaxError, 76 | this.#context, 77 | )) ?? 78 | { 79 | type: this.name || "JavaScriptError", 80 | message: this.message, 81 | source, 82 | code, 83 | file: path, 84 | }; 85 | } catch { 86 | return { 87 | type: this.name || "JavaScriptError", 88 | message: this.message, 89 | source, 90 | code, 91 | file: path, 92 | }; 93 | } 94 | } 95 | 96 | // Capture the exact position of the error in the compiled code 97 | for (const frame of getStackFrames(this.cause)) { 98 | if ( 99 | frame.file !== "" && 100 | path && 101 | ![path + ".js", path + ".mjs"].some((p) => frame.file.endsWith(p)) 102 | ) { 103 | continue; 104 | } 105 | 106 | return { 107 | type: this.name || "JavaScriptError", 108 | message: this.message, 109 | source, 110 | position: this.position, 111 | code, 112 | line: frame.line, 113 | column: frame.column, 114 | file: path, 115 | }; 116 | } 117 | 118 | // As a fallback, return the error with the available context 119 | return { 120 | type: this.name || "JavaScriptError", 121 | message: this.message, 122 | source, 123 | position: this.position, 124 | code, 125 | file: path, 126 | }; 127 | } 128 | } 129 | 130 | /** Create or complete VentoError with extra info from the template */ 131 | export function createError( 132 | error: Error, 133 | context: TemplateContext, 134 | position?: number, 135 | ): VentoError { 136 | if (error instanceof RuntimeError) return error; 137 | 138 | // If the error is a SourceError, we can fill the missing context information 139 | if (error instanceof SourceError) { 140 | error.file ??= context.path; 141 | error.source ??= context.source; 142 | error.position ??= position; 143 | return error; 144 | } 145 | 146 | // JavaScript syntax errors can be parsed to get accurate position 147 | return new RuntimeError(error, context, position); 148 | } 149 | 150 | export interface ErrorFormat { 151 | number: (n: string) => string; 152 | dim: (line: string) => string; 153 | error: (msg: string) => string; 154 | } 155 | 156 | const colors: ErrorFormat = { 157 | number: (n: string) => `\x1b[33m${n}\x1b[39m`, 158 | dim: (line: string) => `\x1b[2m${line}\x1b[22m`, 159 | error: (msg: string) => `\x1b[31m${msg}\x1b[39m`, 160 | }; 161 | 162 | const plain: ErrorFormat = { 163 | number: (n: string) => n, 164 | dim: (line: string) => line, 165 | error: (msg: string) => msg, 166 | }; 167 | 168 | const formats: Record = { 169 | colors, 170 | plain, 171 | }; 172 | 173 | /** Prints an error to the console in a formatted way. */ 174 | export async function printError( 175 | error: unknown, 176 | format: ErrorFormat | keyof typeof formats = plain, 177 | ): Promise { 178 | if (error instanceof VentoError) { 179 | const context = await error.getContext(); 180 | const fmt = typeof format === "string" ? formats[format] || plain : format; 181 | 182 | if (context) { 183 | console.error(stringifyError(context, fmt)); 184 | return; 185 | } 186 | } 187 | 188 | console.error(error); 189 | } 190 | 191 | /** Converts an error context into a formatted string representation. */ 192 | export function stringifyError( 193 | context: ErrorContext, 194 | format = plain, 195 | ): string { 196 | const { type, message, source, position, code, line, column, file } = context; 197 | const output: string[] = []; 198 | 199 | // Print error type and message 200 | output.push(`${format.error(type)}: ${message}`); 201 | 202 | // If we don't know the position, we cannot print the source code 203 | if (position === undefined || source === undefined) { 204 | if (file) { 205 | output.push(format.dim(file)); 206 | } 207 | return output.join("\n"); 208 | } 209 | 210 | const sourceLines = codeToLines(source); 211 | const [sourceLine, sourceColumn] = getSourceLineColumn(sourceLines, position); 212 | 213 | // Print file location if available 214 | if (file) { 215 | output.push(format.dim(`${file}:${sourceLine}:${sourceColumn}`)); 216 | } 217 | 218 | const pad = sourceLine.toString().length; 219 | 220 | // Print the latest lines of the source code before the error 221 | for (let line = Math.max(sourceLine - 3, 1); line <= sourceLine; line++) { 222 | const sidebar = ` ${format.number(`${line}`.padStart(pad))} ${ 223 | format.dim("|") 224 | } `; 225 | output.push(sidebar + sourceLines[line - 1].trimEnd()); 226 | } 227 | 228 | // If we don't have the compiled code, return the tag position 229 | const indent = ` ${" ".repeat(pad)} ${format.dim("|")}`; 230 | 231 | // If we don't have the compiled code, print the tag position 232 | if (!code || line === undefined || column === undefined) { 233 | output.push( 234 | `${indent} ${" ".repeat(sourceColumn - 1)}${ 235 | format.error(`^ ${message}`) 236 | }`, 237 | ); 238 | return output.join("\n"); 239 | } 240 | 241 | // Print the compiled code with the error position 242 | const codeLines = codeToLines(code); 243 | output.push(`${indent} ${" ".repeat(sourceColumn - 1)}${format.error("^")}`); 244 | output.push(`${indent} ${format.dim(codeLines[line - 1]?.trimEnd() || "")}`); 245 | output.push( 246 | `${indent} ${" ".repeat(column)} ${format.error(`^ ${message}`)}`, 247 | ); 248 | 249 | return output.join("\n"); 250 | } 251 | 252 | /** 253 | * Extracts the context from a SyntaxError 254 | * It does not work on Node.js and Bun due to the lack of position information 255 | * in the stack trace of a dynamic import error. 256 | */ 257 | async function getSyntaxErrorContext( 258 | error: SyntaxError, 259 | context: TemplateContext, 260 | ): Promise { 261 | const { source, code } = context; 262 | const url = URL.createObjectURL( 263 | new Blob([code], { type: "application/javascript" }), 264 | ); 265 | const err = await import(url).catch((e) => e); 266 | URL.revokeObjectURL(url); 267 | 268 | for (const frame of getStackFrames(err)) { 269 | if (!frame.file.startsWith("blob:")) { 270 | continue; 271 | } 272 | 273 | return { 274 | type: "SyntaxError", 275 | message: error.message, 276 | source, 277 | position: searchPosition(frame, code) ?? 0, 278 | code, 279 | line: frame.line, 280 | column: frame.column, 281 | file: context.path, 282 | }; 283 | } 284 | } 285 | 286 | const LINE_TERMINATOR = /(\r\n?|[\n\u2028\u2029])/; 287 | 288 | /** Convert the source code into an array of lines */ 289 | function codeToLines(code: string): string[] { 290 | const doubleLines = code.split(LINE_TERMINATOR); 291 | const lines: string[] = []; 292 | 293 | for (let i = 0; i < doubleLines.length; i += 2) { 294 | lines.push(`${doubleLines[i]}${doubleLines[i + 1] ?? ""}`); 295 | } 296 | 297 | return lines; 298 | } 299 | 300 | const POSITION_VARIABLE = /^__pos=(\d+);$/; 301 | 302 | /** Search the closest token to an error */ 303 | function searchPosition( 304 | frame: StackFrame, 305 | code: string, 306 | ): number | undefined { 307 | const posLine = codeToLines(code) 308 | .slice(0, frame.line - 1) 309 | .findLast((line) => POSITION_VARIABLE.test(line.trim())); 310 | if (posLine) { 311 | return Number(posLine.trim().slice(6, -1)); 312 | } 313 | } 314 | 315 | /** Get the line and column number of a position in the code */ 316 | function getSourceLineColumn( 317 | lines: string[], 318 | position: number, 319 | ): [number, number] { 320 | if (position < 0) { 321 | return [1, 1]; // Position is before the start of the source 322 | } 323 | let index = 0; 324 | 325 | for (const [line, content] of lines.entries()) { 326 | const length = content.length; 327 | 328 | if (position < index + length) { 329 | return [line + 1, position - index + 1]; 330 | } 331 | index += content.length; 332 | } 333 | 334 | throw new Error( 335 | `Position ${position} is out of bounds for the provided source lines.`, 336 | ); 337 | } 338 | 339 | interface StackFrame { 340 | file: string; 341 | line: number; 342 | column: number; 343 | } 344 | 345 | /** Returns every combination of file, line and column of an error stack */ 346 | // deno-lint-ignore no-explicit-any 347 | function* getStackFrames(error: any): Generator { 348 | // Firefox specific 349 | const { columnNumber, lineNumber, fileName } = error; 350 | if (columnNumber !== undefined && lineNumber !== undefined && fileName) { 351 | yield { 352 | file: normalizeFile(fileName), 353 | line: lineNumber, 354 | column: columnNumber, 355 | }; 356 | } 357 | 358 | const { stack } = error; 359 | 360 | if (!stack) { 361 | return; 362 | } 363 | 364 | const matches = stack.matchAll(/([^(\s,]+):(\d+):(\d+)/g); 365 | for (const match of matches) { 366 | const [_, file, line, column] = match; 367 | 368 | // Skip Node, Bun & Deno internal stack frames 369 | if ( 370 | file.startsWith("node:") || file.startsWith("ext:") || file === "native" 371 | ) { 372 | continue; 373 | } 374 | 375 | yield { 376 | file: normalizeFile(file), 377 | line: Number(line), 378 | column: Number(column), 379 | }; 380 | } 381 | } 382 | 383 | function normalizeFile(file?: string): string { 384 | if (!file) return ""; 385 | // Firefox may return "Function" for anonymous functions 386 | if (file === "Function") return ""; 387 | return file; 388 | } 389 | -------------------------------------------------------------------------------- /core/environment.ts: -------------------------------------------------------------------------------- 1 | import iterateTopLevel from "./js.ts"; 2 | import tokenize, { Token } from "./tokenizer.ts"; 3 | 4 | import { createError, SourceError } from "./errors.ts"; 5 | 6 | export interface TemplateResult { 7 | content: string; 8 | [key: string]: unknown; 9 | } 10 | 11 | export interface TemplateContext { 12 | source: string; 13 | code: string; 14 | path?: string; 15 | defaults?: Record; 16 | } 17 | 18 | export interface Template extends TemplateContext { 19 | (data?: Record): Promise; 20 | } 21 | 22 | export type TokenPreprocessor = ( 23 | env: Environment, 24 | tokens: Token[], 25 | path?: string, 26 | ) => Token[] | void; 27 | 28 | export type Tag = ( 29 | env: Environment, 30 | token: Token, 31 | output: string, 32 | tokens: Token[], 33 | ) => string | undefined; 34 | 35 | export type FilterThis = { 36 | data: Record; 37 | env: Environment; 38 | }; 39 | 40 | // deno-lint-ignore no-explicit-any 41 | export type Filter = (this: FilterThis, ...args: any[]) => any; 42 | 43 | export type Plugin = (env: Environment) => void; 44 | 45 | export interface TemplateSource { 46 | source: string; 47 | data?: Record; 48 | } 49 | export type PrecompiledTemplate = (env: Environment) => Template; 50 | 51 | export interface Loader { 52 | load(file: string): Promise; 53 | resolve(from: string, file: string): string; 54 | } 55 | 56 | export interface Options { 57 | loader: Loader; 58 | dataVarname: string; 59 | autoescape: boolean; 60 | autoDataVarname: boolean; 61 | strict: boolean; 62 | } 63 | 64 | export class Environment { 65 | cache: Map> = new Map(); 66 | options: Options; 67 | tags: Tag[] = []; 68 | tokenPreprocessors: TokenPreprocessor[] = []; 69 | filters: Record = {}; 70 | #tempVariablesCreated = 0; 71 | utils: Record = { 72 | callMethod, 73 | createError, 74 | }; 75 | 76 | constructor(options: Options) { 77 | this.options = options; 78 | this.utils.safeString = (str: string) => 79 | this.options.autoescape ? new SafeString(str) : str; 80 | } 81 | 82 | use(plugin: Plugin) { 83 | plugin(this); 84 | } 85 | 86 | async run( 87 | file: string, 88 | data?: Record, 89 | from?: string, 90 | position?: number, 91 | ): Promise { 92 | const template = await this.load(file, from, position); 93 | return await template(data); 94 | } 95 | 96 | async runString( 97 | source: string, 98 | data?: Record, 99 | file?: string, 100 | ): Promise { 101 | if (file) { 102 | const cached = this.cache.get(file); 103 | 104 | if (cached) { 105 | return (await cached)(data); 106 | } 107 | 108 | const template = this.compile(source, file); 109 | this.cache.set(file, template); 110 | 111 | return await template(data); 112 | } 113 | 114 | const template = this.compile(source, file); 115 | return await template(data); 116 | } 117 | 118 | compile( 119 | source: string, 120 | path?: string, 121 | defaults?: Record, 122 | ): Template { 123 | if (typeof source !== "string") { 124 | throw new TypeError( 125 | `The source code of "${path}" must be a string. Got ${typeof source}`, 126 | ); 127 | } 128 | const tokens = this.tokenize(source, path); 129 | const lastToken = tokens.at(-1)!; 130 | 131 | if (lastToken[0] != "string") { 132 | throw new SourceError("Unclosed tag", lastToken[2], path, source); 133 | } 134 | 135 | let code = ""; 136 | try { 137 | code = this.compileTokens(tokens).join("\n"); 138 | } catch (error) { 139 | if (error instanceof SourceError) { 140 | error.file ??= path; 141 | error.source ??= source; 142 | } 143 | throw error; 144 | } 145 | 146 | const { dataVarname, autoDataVarname, strict } = this.options; 147 | 148 | if (strict && autoDataVarname) { 149 | const innerCode = JSON.stringify(` 150 | const __exports = { content: "" }; 151 | ${code} 152 | return __exports; 153 | `); 154 | code = ` 155 | return new (async function(){}).constructor( 156 | "__env", 157 | "__template", 158 | "${dataVarname}", 159 | \`{\${Object.keys(${dataVarname}).join(",")}}\`, 160 | ${innerCode} 161 | )(__env, __template, ${dataVarname}, ${dataVarname}); 162 | `; 163 | } else if (autoDataVarname) { 164 | const generator = iterateTopLevel(code); 165 | const [, , variables] = generator.next().value; 166 | while (!generator.next().done); 167 | variables.delete(dataVarname); 168 | 169 | if (variables.size > 0) { 170 | code = ` 171 | var {${[...variables].join(",")}} = ${dataVarname}; 172 | {\n${code}\n} 173 | `; 174 | } 175 | } 176 | 177 | try { 178 | const constructor = new Function( 179 | "__env", 180 | `return async function __template(${dataVarname}) { 181 | let __pos=0; 182 | try { 183 | ${dataVarname} = Object.assign({}, __template.defaults, ${dataVarname}); 184 | const __exports = { content: "" }; 185 | ${code} 186 | return __exports; 187 | } catch (error) { 188 | throw __env.utils.createError(error, __template, __pos); 189 | } 190 | }`, 191 | ); 192 | const template = constructor(this); 193 | template.path = path; 194 | template.code = constructor.toString(); 195 | template.source = source; 196 | template.defaults = defaults || {}; 197 | return template; 198 | } catch (error) { 199 | if (error instanceof SyntaxError) { 200 | throw createError(error, { source, code, path }); 201 | } 202 | if (error instanceof SourceError) { 203 | error.file ??= path; 204 | error.source ??= source; 205 | } 206 | throw error; 207 | } 208 | } 209 | 210 | tokenize(source: string, path?: string): Token[] { 211 | let tokens = tokenize(source); 212 | 213 | for (const tokenPreprocessor of this.tokenPreprocessors) { 214 | const result = tokenPreprocessor(this, tokens, path); 215 | 216 | if (result !== undefined) { 217 | tokens = result; 218 | } 219 | } 220 | 221 | return tokens; 222 | } 223 | 224 | async load( 225 | file: string, 226 | from?: string, 227 | position?: number, 228 | ): Promise