UNPKG

edgerender-yatl

Version:

Yet Another Template Language

399 lines 15 kB
"use strict"; 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