edgerender-yatl
Version:
Yet Another Template Language
399 lines • 15 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.load_template = void 0;
const sax_wasm_1 = require("sax-wasm");
const utils_1 = require("./utils");
const expressions_1 = require("./expressions");
async function load_template(file_path, file_loader, prepare_parser_wasm) {
const loader = new TemplateLoader(file_path, file_loader);
const chunks = await loader.load(prepare_parser_wasm);
return chunks.map(convert_chunk);
}
exports.load_template = load_template;
class TemplateLoader {
constructor(file_path, file_loader) {
this.base_file_path = file_path;
this.file_loader = file_loader;
const options = { highWaterMark: 32 * 1024 }; // 32k chunks
const events = sax_wasm_1.SaxEventType.Attribute | sax_wasm_1.SaxEventType.CloseTag | sax_wasm_1.SaxEventType.OpenTag | sax_wasm_1.SaxEventType.Text | sax_wasm_1.SaxEventType.Doctype;
this.sax_parser = new sax_wasm_1.SAXParser(events, options);
}
async load(prepare_parser_wasm) {
await prepare_parser_wasm(this.sax_parser);
const parser = await this.parse_file(this.base_file_path);
return parser.current.body;
}
async parse_file(file_path) {
const file_parser = new FileParser(file_path);
this.sax_parser.eventHandler = file_parser.handler;
const stream = await this.file_loader(file_path);
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) {
this.sax_parser.end();
await this.load_external_components(file_parser.components);
return file_parser;
}
else {
this.sax_parser.write(value);
}
}
}
async load_external_components(components) {
const req_components = Object.entries(components).filter(([, c]) => 'used' in c && c.used);
const req_files = {};
for (const [name, component] of req_components) {
const path = component.path || `${name}.html`;
if (path in req_files) {
req_files[path].add(name);
}
else {
req_files[path] = new Set([name]);
}
}
const imported_components = await Promise.all(Object.entries(req_files).map(pc => this.load_file_components(...pc)));
for (const new_file_components of imported_components) {
for (const [name, component] of Object.entries(new_file_components)) {
// modify the component in place to convert it from a ComponentReference to as ComponentDefinition
const new_comp = Object.assign(components[name], component);
delete new_comp.path;
delete new_comp.used;
}
}
}
async load_file_components(file_path, components) {
const parser = await this.parse_file(file_path);
// TODO check all components are defined here and raise an error if not
return Object.fromEntries(Object.entries(parser.components).filter(([k]) => components.has(k)));
}
}
const illegal_names = new Set(['set', 'for', 'if']);
class FileParser {
constructor(file_name) {
this.parents = [];
this.components = {};
this.file_name = file_name;
this.current = {
type: 'temp_element',
name: 'root',
attributes: [],
loc: { line: 1, col: 1 },
body: [],
};
this.handler = this.handler.bind(this);
}
handler(event, data) {
switch (event) {
case sax_wasm_1.SaxEventType.OpenTag:
this.on_open_tag(data);
return;
case sax_wasm_1.SaxEventType.CloseTag:
this.on_close_tag();
return;
case sax_wasm_1.SaxEventType.Text:
this.on_text(data);
return;
case sax_wasm_1.SaxEventType.Doctype:
this.on_doctype(data);
return;
case sax_wasm_1.SaxEventType.Attribute:
// processed by on_open_tag
return;
default:
throw Error(`Internal Error: got unexpected type ${sax_type_name(event)}, ${JSON.stringify(data.toJSON())}`);
}
}
on_open_tag(tag) {
let { name } = tag;
let new_tag;
if (name.toLowerCase() == 'template') {
new_tag = this.on_component(tag);
}
else {
let component = undefined;
if (name == '') {
name = 'fragment';
}
else if (utils_1.is_upper_case(name[0])) {
// assume this is a component
component = this.components[name];
if (!component) {
throw Error(`"${name}" appears to be component and is not defined or imported in this file. ` +
`Either define the component or, if you meant to refer to a standard HTML tag, use the lower case name.`);
}
if (!('props' in component)) {
component.used = true;
}
}
const attributes = tag.attributes.map(a => this.prepare_attr(a));
new_tag = { type: 'temp_element', name, loc: pos2loc(tag.openStart), body: [], attributes };
if (component) {
new_tag.component = component;
}
this.current.body.push(new_tag);
}
this.parents.push(this.current);
if (new_tag) {
this.current = new_tag;
}
}
on_close_tag() {
const parent = this.parents.pop();
if (parent) {
this.current = parent;
}
else {
throw Error('no parent found, mis-formed XML');
}
}
on_text(text) {
this.current.body.push(...this.parse_string(text.value));
}
on_doctype(doctype) {
this.current.body.push({ type: 'doctype', doctype: doctype.value });
}
on_component(tag) {
let component_name = null;
let path = null;
const props = [];
for (const attr of tag.attributes) {
const name = attr.name.value;
const raw_value = attr.value.value;
if (name == 'name' || name == 'id') {
component_name = raw_value;
}
else if (name == 'path') {
path = raw_value;
}
else {
// TODO allow optional empty string via `foobar:optional=""`, prevent names ending with :
if (illegal_names.has(name)) {
throw new Error(`"${name}" is not allowed as a component property name`);
}
if (raw_value == '') {
props.push({ name });
}
else {
props.push({ name, default: raw_value });
}
}
}
if (!component_name) {
throw Error('"name" or "id" is required for "<template>" elements when creating components');
}
else if (!/^[A-Z][a-zA-Z0-9]+$/.test(component_name)) {
throw Error('component names must be CamelCase: start with a capital, contain only letters and numbers');
}
if (component_name in this.components) {
throw Error(`Component ${component_name} already defined`);
}
if (tag.selfClosing) {
// a ComponentReference
this.components[component_name] = { path, used: false };
return null;
}
else {
// a ComponentDefinition
return (this.components[component_name] = { props, body: [], file: this.file_name, loc: pos2loc(tag.openStart) });
}
}
prepare_attr(attr) {
const name = attr.name.value;
const raw_value = attr.value.value;
if (!name.includes(':')) {
if (illegal_names.has(name)) {
throw new Error(`"${name}" is an illegal name, you might have missed a colon at the end of the name`);
}
return { name, value: this.parse_string(raw_value) };
}
const value = [expressions_1.build_clause(raw_value)];
if (name.startsWith('set:')) {
const set_name = name.substr(4).replace(/:+$/, '');
return { name: 'set', set_name, value };
}
else if (name.startsWith('if:')) {
return { name: 'if', value };
}
else if (name.startsWith('for:')) {
let for_names;
if (name == 'for:') {
for_names = ['item'];
}
else {
for_names = name.substr(4).replace(/:+$/, '').split(':');
if (!for_names.every(n => n.length > 0)) {
throw new Error(`Empty names are not allowed in "for" expressions, got ${JSON.stringify(for_names)}`);
}
}
return { name: 'set', for_names, value };
}
else {
if (/^[^:]:$/.test(name)) {
throw new Error(`Invalid name "${name}"`);
}
return { name: name.slice(0, -1), value };
}
}
parse_string(text) {
let chunk_end = text.indexOf('{{');
// if "{{" is not found in the rest of the string, indexOf returns -1
// this check is specifically to speed up the case where no '{{' is found
if (chunk_end == -1) {
if (text) {
return [{ type: 'text', text }];
}
else {
return [];
}
}
let chunk_start = 0;
const parts = [];
while (true) {
parts.push({ type: 'text', text: text.substr(chunk_start, chunk_end - chunk_start) });
let string_start = null;
for (let index = chunk_end + 2; index < text.length; index++) {
const letter = text[index];
if (!string_start) {
// not (yet) inside a string inside the clause
if (letter == '}' && text[index - 1] == '}') {
// found the end of the expression!
const clause = text.substr(chunk_end + 2, index - chunk_end - 3);
parts.push(expressions_1.build_clause(clause));
chunk_start = index + 1;
break;
}
else if (letter == '"' || letter == "'") {
string_start = letter;
}
}
else if (letter == string_start && text[index - 1] != '\\') {
// end of this substring
string_start = null;
}
}
chunk_end = text.indexOf('{{', chunk_start);
if (chunk_end == -1) {
break;
}
}
parts.push({ type: 'text', text: text.slice(chunk_start) });
return parts.filter(p => !(p.type == 'text' && !p.text));
}
}
const pos2loc = (p) => ({ line: p.line + 1, col: p.character + 1 });
const sax_type_name = (event) => {
for (const [name, value] of Object.entries(sax_wasm_1.SaxEventType)) {
if (event == value) {
return name;
}
}
return 'Unknown';
};
function convert_element(el) {
const { name, loc, component } = el;
const fragment = name == 'text' || name == 'fragment';
const set_attributes = [];
const attributes = [];
let _if;
let _for;
let for_names;
let for_join;
for (const attr of el.attributes) {
const { value } = attr;
const attr_name = attr.name;
if ('set_name' in attr) {
set_attributes.push({ name: attr.set_name, value });
}
else if ('for_names' in attr) {
_for = one_clause(value, 'for');
for_names = attr.for_names;
}
else if (attr_name == 'for_join') {
for_join = value;
}
else if (attr_name == 'if') {
_if = one_clause(value, 'if');
}
else {
if (fragment) {
throw new Error(`Standard attributes (like "${attr_name}") make no sense with ${name} elements`);
}
attributes.push({ name: attr_name, value });
}
}
const el_body = el.body.length ? el.body.map(convert_chunk) : undefined;
if (component === undefined) {
return {
type: 'tag',
name,
fragment: fragment || undefined,
loc,
set_attributes: set_attributes.length ? set_attributes : undefined,
body: el_body,
attributes: attributes.length ? attributes : undefined,
if: _if,
for: _for,
for_names,
for_join,
};
}
else {
if ('path' in component) {
throw Error(`Internal Error: Component reference "${el.name}" found after loading template`);
}
else if (set_attributes.length) {
throw new Error('"set:" style attributes not permitted on components');
}
const attr_lookup = Object.fromEntries(attributes.map(attr => [attr.name, attr.value]));
return {
type: 'component',
name: el.name,
loc: el.loc,
props: component.props.map((p) => {
const { name } = p;
const attr = attr_lookup[p.name];
if (attr) {
return { name, value: attr };
}
else {
if (p.default === undefined) {
throw Error(`The required property "${name}" was not providing when calling ${el.name}`);
}
else {
return { name, value: [{ type: 'text', text: p.default }] };
}
}
}),
if: _if,
for: _for,
for_names,
for_join,
body: component.body.map(convert_chunk),
children: el_body,
comp_file: component.file,
comp_loc: component.loc,
};
}
}
function one_clause(value, attr) {
if (value.length != 1) {
throw new Error(`One Clause is required as the value for ${attr} attributes`);
}
const first = value[0];
if (first.type == 'text') {
throw new Error(`Text values are not valid as ${attr} values`);
}
else {
return first;
}
}
function convert_chunk(chunk) {
if (chunk.type == 'temp_element') {
return utils_1.remove_undefined(convert_element(chunk));
}
else {
return chunk;
}
}
//# sourceMappingURL=parse.js.map