commit f26d7e410285de30217fb17cd491874c11edb21a Author: yizhi <946185759@qq.com> Date: Wed Nov 27 11:03:51 2024 +0800 初次提交 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2316c07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/dist +/types +/package-lock.json +/.vscode +/.env diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..8d81853 --- /dev/null +++ b/.npmignore @@ -0,0 +1,4 @@ +/node_modules +/.vscode +/test +/package-lock.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d1df92 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# HTML模板引擎 + +使用此模板引擎,可以方便的将数据渲染到网页上。 + +## 安装 +``` +npm install @yizhi/render +``` + +## 使用 +使用render函数,传入模板字符串和数据对象,即可渲染出网页内容。 +```typescript +import { render } from '@yizhi/render'; +const html = render("/path/to/template.html", {name:"Join", age:20, love:["eat", "sleep", "code"] }); +console.log(html); +``` + +### 内容输出 +使用`{{ 表达式 }}`语法,可以将表达式的值输出到网页。 +```html +

{{ data.name }}

+``` + +经过渲染后,输出的HTML内容为: +```html +

Join

+``` + +### 条件判断 +在标签上使用`:if`和`:else`来进行条件判断,如果else有值,则表示elseif。 + + +```html +
你还是个宝宝
+
你还未成年
+
你已经长大了
+``` + +经过渲染后,输出的HTML内容为: +```html +
你已经长大了
+``` + +### 循环 +在标签上使用`:for`来进行循环,支持两种循环方式: + +- `:for="数据项 in 数据"` +- `:for="(索引, 数据项) in 数据"` + +其中,数据项可以是数组或对象。 + +示例: + +```html + + + + + +``` +经过渲染后,输出的HTML内容为: +```html + + + + + +``` + +### 模板定义 +可以使用`template`来定义模板,通过`name`属性来指定模板名称。 + +```html + +``` + +通过上面方式,我们就定义了一个名为`my-template`的模板。 + + +### 模板使用 +通过`use`标签,可以将模板内容渲染到网页上,`data`属性指定数据对象。 + +```html +
+

模板使用

+ +
+``` + +如果模板文件在其他位置,还可以通过`from`属性指定模板文件路径。 + +```html +
+

模板使用

+ +
+``` + +经过渲染后,输出的HTML内容为: +```html +
+

模板使用

+

Join

+ +
+``` + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..b32ca1e --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "@yizhi/render", + "version": "1.0.1", + "main": "dist/index.js", + "types": "types/index.d.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@types/node": "^22.9.3" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ab43ba4 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,15 @@ + +export { render } from "./render"; +export { config } from "./render/config"; + +if (require.main === module) (async function test() { + try { + const { render } = await import("./render"); + const res = render("test/dist/index", { id: 1, name: "yizhi", love: ["eat", "sleep", "play", "code"] }); + console.log(res); + } catch (e) { + console.log(e) + debugger + } +})(); + diff --git a/src/parser/elem.ts b/src/parser/elem.ts new file mode 100644 index 0000000..c04bd5e --- /dev/null +++ b/src/parser/elem.ts @@ -0,0 +1,204 @@ +/** + * 位置 + */ +export class Location { + #line: number; + #column: number; + #offset: number; + + constructor(line: number, column: number, offset: number) { + this.#line = line; + this.#column = column; + this.#offset = offset; + } + + /** 行号 */ + public get line(): number { return this.#line; } + + /** 列号 */ + public get column(): number { return this.#column; } + + /** 偏移 */ + public get offset(): number { return this.#offset; } +} + +/** 位置信息 */ +export class Position { + #start: Location; + #end: Location; + + constructor(start: Location, end: Location) { + this.#start = start; + this.#end = end; + } + + /** 起始位置 */ + public get start(): Location { return this.#start; } + + /** 结束位置 */ + public get end(): Location { return this.#end; } + + + /** 长度 */ + public get length(): number { return this.#end.offset - this.#start.offset; } +} + +/** 节点 */ +export abstract class Node { + #position: Position; + + constructor(position: Position) { + this.#position = position; + } + + /** 位置信息 */ + public get position(): Position { return this.#position; } +} + +/** 属性名 */ +export class ElementAttributeName extends Node { + #name: string; + + constructor(position: Position, name: string) { + super(position); + this.#name = name; + } + + /** 属性名 */ + public get name(): string { return this.#name; } +} + +/** 属性值 */ +export class ElementAttributeValue extends Node { + #value: string; + + constructor(position: Position, value: string) { + super(position); + this.#value = value; + } + + /** 属性值 */ + public get value(): string { return this.#value; } +} + +/** 元素标签 */ +export class ElementTag extends Node { + #tagName: string; + + constructor(position: Position, tagName: string) { + super(position); + this.#tagName = tagName; + } + + /** 标签名 */ + public get tagName(): string { return this.#tagName; } +} + +/** 元素属性 */ +export class ElementAttribute extends Node { + #name: ElementAttributeName; + #value: ElementAttributeValue | null; + #control: boolean; + + constructor(name: ElementAttributeName, value: ElementAttributeValue | null, control: boolean) { + super(new Position(name.position.start, (value ?? name).position.end)); + this.#name = name; + this.#value = value; + this.#control = control; + } + + /** 属性名 */ + public get name(): ElementAttributeName { return this.#name; } + + /** 属性值 */ + public get value(): ElementAttributeValue | null { return this.#value; } + + /** 是否控制属性 */ + public get control(): boolean { return this.#control; } +} + +/** 元素 */ +export class Element extends Node { + #tag: ElementTag; + #attributes: ElementAttribute[]; + #children: (Element | Text | Comment)[]; + #selfClosing: boolean; + + constructor(position: Position, tag: ElementTag, attributes: ElementAttribute[], selfClosing: boolean, children: (Element | Text | Comment)[]) { + super(position); + this.#tag = tag; + this.#attributes = attributes; + this.#selfClosing = selfClosing; + this.#children = children; + } + + /** 标签 */ + public get tag() { return this.#tag; } + + /** 属性列表 */ + public get attributes() { return this.#attributes; } + + /** 标签名 */ + public get tagName() { return this.#tag.tagName; } + + /** 是否自闭合 */ + public get selfClosing() { return this.#selfClosing; } + + /** 子节点列表 */ + public get children() { return this.#children; } +} + +/** 文本节点 */ +export class Text extends Node { + #value: string; + + constructor(position: Position, value: string) { + super(position); + this.#value = value; + } + + /** 文本内容 */ + public get value(): string { return this.#value; } +} + +/** 文档类型 */ +export class DocType extends Node { + #items: string[]; + + constructor(position: Position, items: string[]) { + super(position); + this.#items = items; + } + + /** 文档类型信息 */ + public get items(): string[] { return this.#items; } +} + +/** 注释 */ +export class Comment extends Node { + #value: string; + + constructor(position: Position, value: string) { + super(position); + this.#value = value; + } + + /** 注释内容 */ + public get value(): string { return this.#value; } +} + +/** 文档 */ +export class Document { + #doctype: DocType | null = null; + #children: (Element | Text | Comment)[] = []; + + /** 文档类型 */ + public set doctype(doctype: DocType | null) { this.#doctype = doctype; } + public get doctype() { return this.#doctype; } + + /** 子节点列表 */ + public get children() { return this.#children; } + + /** 添加子节点 */ + public addChild(child: Element | Text | Comment) { this.#children.push(child); } +} diff --git a/src/parser/error.ts b/src/parser/error.ts new file mode 100644 index 0000000..e8367c3 --- /dev/null +++ b/src/parser/error.ts @@ -0,0 +1,16 @@ +import { Location } from "./elem"; + +/** + * HTML解析错误 + */ +export class ParseError extends Error { + #location: Location; + constructor(location: Location, message: string) { + super(message); + this.#location = location; + } + + get location(): Location { + return this.#location; + } +} \ No newline at end of file diff --git a/src/parser/index.ts b/src/parser/index.ts new file mode 100644 index 0000000..2c78ca1 --- /dev/null +++ b/src/parser/index.ts @@ -0,0 +1,3 @@ +export * from "./elem"; +export * from "./error"; +export * from "./parser"; diff --git a/src/parser/parser.ts b/src/parser/parser.ts new file mode 100644 index 0000000..f5ddeba --- /dev/null +++ b/src/parser/parser.ts @@ -0,0 +1,243 @@ +import fs from 'fs'; +import { Comment, DocType, Document, Element, ElementAttribute, ElementAttributeName, ElementAttributeValue, ElementTag, Location, Position, Text } from './elem'; +import { ParseError } from './error'; + +export class Parser { + #filePath: string; + #content: string; + #line: number = 0; + #column: number = 0; + #offset: number = 0; + #document!: Document; + + private constructor(filePath: string, content: string) { + this.#filePath = filePath; + this.#content = content; + } + + public static fromFile(filePath: string) { return new Parser(filePath, fs.readFileSync(filePath, 'utf-8')); } + + public static fromString(content: string, filePath: string) { return new Parser(filePath, content); } + + public parse() { + this.#line = 0; + this.#column = 0; + this.#offset = 0; + this.#document = new Document(); + this.#parse().forEach(e => this.#document.addChild(e)); + return this.#document; + } + + private is(text: string, offset: number = 0, caseSensitive: boolean = false) { + const sub = this.#content.slice(this.#offset + offset, this.#offset + offset + text.length); + if (caseSensitive) return sub === text; + else return sub.toLowerCase() === text.toLowerCase(); + } + + private go(length: number) { + for (let i = 0; i < length; i++) { + if (this.#content[this.#offset + i] === '\n') { + this.#line++; + this.#column = 0; + } + else this.#column++; + } + this.#offset += length; + } + + private eof() { + return this.#offset >= this.#content.length; + } + + private eat(text: string, caseSensitive: boolean = false) { + if (this.is(text, 0, caseSensitive)) { + this.go(text.length); + return true; + } + return false; + } + + private eatSpace(required: boolean = false) { + const start = this.#offset; + while (true) { + const ch = this.#content[this.#offset]; + if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') this.go(1); + else break; + } + if (required && start === this.#offset) { + throw new ParseError(new Location(this.#line, this.#column, this.#offset), `此处应该有空白`); + } + } + + private eatString() { + const start = new Location(this.#line, this.#column, this.#offset); + const quote = this.#content[this.#offset]; + if (quote !== '"' && quote !== '\'') throw new ParseError(start, `字符串应该以双引号或单引号开始`); + let finished = false; + this.go(1); + while (!this.eof()) { + const ch = this.#content[this.#offset]; + if (ch === quote) { + this.go(1); + finished = true; + break; + } + else if (ch === '\\') this.go(2); + else this.go(1); + } + const end = new Location(this.#line, this.#column, this.#offset); + if (!finished) throw new ParseError(end, `字符串应该以双引号或单引号结束`); + return { start, end, text: this.#content.slice(start.offset, end.offset) }; + } + + private eatComment() { + let finished = false; + let start = this.#offset; + let end = start; + while (!this.eof()) { + if (this.is('-->')) { + end = this.#offset; + this.go(3); + finished = true; + break; + } + this.go(1); + } + if (!finished) throw new ParseError(new Location(this.#line, this.#column, this.#offset), `注释应该以 --> 结束`); + return { text: this.#content.slice(start, end) }; + } + + #isIDentifierChar(code: number) { + if ((code >= 97 && code <= 122) || // a-z + (code >= 65 && code <= 90) || // A-Z + (code >= 48 && code <= 57) || // 0-9 + code === 95 || // _ + code === 45) // - + return true; + return false; + } + + private eatIdentifier() { + const start = new Location(this.#line, this.#column, this.#offset); + while (true) { + const code = this.#content.codePointAt(this.#offset); + if (!code) break; + if (!this.#isIDentifierChar(code)) break; + this.go(1); + } + const end = new Location(this.#line, this.#column, this.#offset); + if (start.offset === end.offset) throw new ParseError(start, `标识符不能为空`); + return { start, end, text: this.#content.slice(start.offset, end.offset) }; + } + + private eatText() { + const start = this.#offset; + while (!this.eof() && !this.is('<')) this.go(1); + const end = this.#offset; + return { start, end, text: this.#content.slice(start, end) }; + } + + #parse(): Array { + const items: Array = []; + while (!this.eof()) { + const start = new Location(this.#line, this.#column, this.#offset); + // DOCTYPE + if (this.eat('")) throw new ParseError(new Location(this.#line, this.#column, this.#offset), `标签 ${tag.tagName} 应该有结束标签`); + items.push(new Element( + new Position(start, new Location(this.#line, this.#column, this.#offset)), + tag, attrs, false, children, + )); + } + } + //文本 + else { + const { text } = this.eatText(); + items.push(new Text(new Position(start, new Location(this.#line, this.#column, this.#offset)), text)); + } + } + return items; + } + + #parseDoctype() { + this.eatSpace(true); + const name = this.eatIdentifier(); + const items: string[] = []; + while (!this.eof() && !this.eat('>')) { + this.eatSpace(true); + if (this.is('"') || this.is("'")) { + const item = this.eatString(); + items.push(item.text); + } + else { + const item = this.eatIdentifier(); + items.push(item.text); + } + } + return [name.text, ...items]; + } + + #parseElement() { + const name = this.eatIdentifier(); + const attrs: Array = []; + let closed = false; + let gotBooleanAttribute = false; + while (!this.eat('>')) { + if (this.eat("/>")) { + closed = true; + break; + } + this.eatSpace(!gotBooleanAttribute); + let isControlAttribute = false; + if (this.eat(":")) isControlAttribute = true; + else if (this.eat("/>")) { + closed = true; + break; + } + const name = this.eatIdentifier(); + this.eatSpace(false); + if (this.eat("=")) { + gotBooleanAttribute = false; + this.eatSpace(false); + const value = this.eatString(); + attrs.push(new ElementAttribute( + new ElementAttributeName(new Position(name.start, name.end), name.text), + new ElementAttributeValue(new Position(value.start, value.end), value.text), + isControlAttribute, + )); + } + else { + gotBooleanAttribute = true; + attrs.push(new ElementAttribute(new ElementAttributeName(new Position(name.start, name.end), name.text), null, isControlAttribute)); + } + } + return { + tag: new ElementTag(new Position(name.start, new Location(this.#line, this.#column, this.#offset)), name.text), + attrs, + closed, + }; + } +} \ No newline at end of file diff --git a/src/render/config.ts b/src/render/config.ts new file mode 100644 index 0000000..6531cea --- /dev/null +++ b/src/render/config.ts @@ -0,0 +1,22 @@ +/** 渲染配置 */ +interface IRenderConfig { + /** 是否使用缓存 */ + cache: boolean + /** 扩展名 */ + extname: string +} + +/** 全局渲染配置 */ +export const renderConfig: IRenderConfig = { + cache: true, + extname: '.html' +} + +/** + * 配置渲染器 + * @param key 配置键 + * @param value 配置值 + */ +export function config(key: K, value: IRenderConfig[K]) { + renderConfig[key] = value; +} diff --git a/src/render/element.ts b/src/render/element.ts new file mode 100644 index 0000000..ad58e8d --- /dev/null +++ b/src/render/element.ts @@ -0,0 +1,218 @@ +import path from "path"; +import { __system__, check } from "./util"; + +export abstract class HTMLNode { + public abstract build(...args: string[]): string; +} + +export class HTMLDocument extends HTMLNode { + #doctype: string | null = null; + #children: Array = []; + #templates: Array = []; + + public setDoctype(doctype: string) { + this.#doctype = doctype; + return this; + } + + public addChild(child: HTMLElement | HTMLComment | HTMLText) { + this.#children.push(child); + return this; + } + + public addTemplate(template: HTMLTemplate) { + this.#templates.push(template); + return this; + } + + public get doctype() { return this.#doctype; } + public get templates() { return this.#templates; } + public get children() { return this.#children; } + + public build() { + const buffer: string[] = []; + if (this.doctype) buffer.push(this.doctype); + for (const child of this.children) buffer.push(child.build()); + return buffer.join(""); + } +} + +export class HTMLElement extends HTMLNode { + #tag: string; + #attributes: Array<{ name: string, value: string | null }> = []; + #controls: Array<{ name: string, value: string }> = []; + #children: Array = []; + #closed: boolean; + #ifKey: number | null = null; + + constructor(tag: string, closed: boolean) { + super(); + this.#tag = tag; + this.#closed = closed; + } + + public addAttribute(name: string, value: string | null) { + this.#attributes.push({ name, value }); + return this; + } + + public addControl(name: string, value: string) { + if (!value) value = "false"; + if (value.startsWith("'") || value.startsWith('"')) value = value.slice(1, -1); + this.#controls.push({ name, value }); + return this; + } + + public setIf(key: number | null) { + this.#ifKey = key; + return this; + } + + public get ifKey() { return this.#ifKey } + + public addChild(child: HTMLElement | HTMLComment | HTMLText) { + this.#children.push(child); + return this; + } + + public get tag() { return this.#tag; } + public get attributes() { return this.#attributes; } + public get controls() { return this.#controls; } + public get closed() { return this.#closed; } + public get children() { return this.#children; } + + + public build() { + const resolveControl = (controls: Array<{ name: string, value: string }>, action: () => string): string => { + if (!controls.length) return action(); + const [control, ...rest] = controls; + + if (control.name === "show") { + return `\${__system__.if(${check(control.value, "false")}, null, __context__, ()=>\`${resolveControl(rest, action)}\`)}`; + } + if (control.name === "hide") { + return `\${__system__.if(${check(`!(${control.value})`, "false")}, null, __context__, ()=>\`${resolveControl(rest, action)}\`)}`; + } + else if (control.name === "for") { + const inIndex = control.value.indexOf(" in "); + if (inIndex <= 0) return ""; + const itVal = control.value.slice(0, inIndex).trim(); + const itData = control.value.slice(inIndex + 4).trim().split(" as "); + if (!itVal.length || !itData.length) return ""; + + let itKey: string | null = null; + let itValue: string | null = null; + if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(itVal)) itValue = itVal; + else { + const match = itVal.match(/^\(\s*([a-zA-Z0-9_][a-zA-Z0-9_]*)\s*,\s*([a-zA-Z0-9_][a-zA-Z0-9_]*)\s*\)$/); + if (match) { + itKey = match[1]; + itValue = match[2]; + } + } + if (!itValue) return ""; + return `\${__system__.for(${check(itData)}, (${itValue}${itKey ? `, ${itKey}` : ""})=>\`${resolveControl(rest, action)}\`)}`; + } + else if (control.name === "if") { + return `\${__system__.if(${check(control.value)}, ${this.ifKey}, __context__, ()=>\`${resolveControl(rest, action)}\`)}`; + } + else if (control.name === "else") { + if (!this.ifKey) return ""; + if (control.value.length) return `\${__system__.elseIf(${check(control.value)}, ${this.ifKey}, __context__, ()=>\`${resolveControl(rest, action)}\`)}`; + else return `\${__system__.else(${this.ifKey}, __context__, ()=>\`${resolveControl(rest, action)}\`)}`; + } + else return `\`\``; + } + + return resolveControl(this.controls, () => { + if (this.tag === "use") { + const from = this.attributes.find(a => a.name === "from")?.value; + const name = this.attributes.find(a => a.name === "name")?.value; + const data = this.attributes.find(a => a.name === "data")?.value; + return `\${__system__.use(${from ? JSON.stringify(from) : "__filename__"}, ${JSON.stringify(name)}, ${data ?? "null"}, __dirname__)}` + } + else { + const buffer = [`<${this.tag}`]; + for (const { name, value } of this.attributes) { + if (value === null) buffer.push(` ${name}`); + else buffer.push(` ${name}=${value.replace(/\{\{(.+?)\}\}/g, "${$1}")}`); + } + if (this.closed) buffer.push(` />`); + else { + buffer.push(">"); + for (const child of this.children) { + buffer.push(child.build()); + } + buffer.push(``); + } + return buffer.join(""); + } + }); + } +} + +export class HTMLText extends HTMLNode { + #text: string; + constructor(text: string) { + super(); + this.#text = text; + } + + public get text() { return this.#text; } + + + public build() { + return this.text.replace(/\`/g, "\\`").replace(/\$/g, "\\$").replace(/\{\{(.+?)\}\}/g, "${$1}"); + } +} + +export class HTMLComment extends HTMLNode { + #comment: string; + constructor(comment: string) { + super(); + this.#comment = comment; + } + public get comment() { return this.#comment; } + + + public build() { + return ``; + } +} + +export class HTMLTemplate extends HTMLNode { + #name: string; + #argument: string; + #children: Array = []; + #filename: string; + #cache: ((data: any) => string) | null = null; + + constructor(name: string, argument: string, filename: string) { + super(); + this.#name = name; + this.#argument = argument; + this.#filename = path.resolve(process.cwd(), filename); + } + + public addChild(child: HTMLElement | HTMLComment | HTMLText) { + this.#children.push(child); + return this; + } + + public get name() { return this.#name; } + public get argument() { return this.#argument; } + public get children() { return this.#children; } + + public build() { + return this.children.map(child => child.build()).join(""); + } + + public resolve() { + if (!this.#cache) { + const body = this.build(); + const func = new Function("data", "__system__", "__context__", "__filename__", "__dirname__", `return \`${body}\``); + this.#cache = (data: any) => func(data, __system__, { if: 0 }, this.#filename, path.dirname(this.#filename)); + } + return this.#cache; + } +} \ No newline at end of file diff --git a/src/render/index.ts b/src/render/index.ts new file mode 100644 index 0000000..d5407f4 --- /dev/null +++ b/src/render/index.ts @@ -0,0 +1 @@ +export { render } from "./render"; \ No newline at end of file diff --git a/src/render/module.ts b/src/render/module.ts new file mode 100644 index 0000000..0b9948f --- /dev/null +++ b/src/render/module.ts @@ -0,0 +1,147 @@ + +import path from "path"; +import { Comment, Element, Parser, Text } from "../parser"; +import { HTMLComment, HTMLDocument, HTMLElement, HTMLTemplate, HTMLText } from "./element"; +import { __system__ } from "./util"; +import { renderConfig } from "./config"; + +export class Module { + #parser: Parser; + #document: HTMLDocument; + #cache: ((data: any) => string) | null = null; + static #modules: Map = new Map(); + + public static get(filename: string) { + if (path.extname(filename) !== renderConfig.extname) filename += renderConfig.extname; + //使用缓存 + if (renderConfig.cache) { + if (!this.#modules.has(filename)) { + this.#modules.set(filename, new Module(filename)); + } + return Module.#modules.get(filename)!; + } + //不是有缓存 + else return new Module(filename); + } + + private constructor(public readonly filename: string) { + this.#parser = Parser.fromFile(filename); + this.#document = new HTMLDocument(); + const doc = this.#parser.parse(); + if (doc.doctype) this.#document.setDoctype(``); + this.#resolve(this.#document, this.#document, doc.children, { if: 0 }); + Module.#modules.set(filename, this); + } + + public template(name: string) { + return this.#document.templates.find(t => t.name === name) ?? null; + } + + #findLastElement(children: Array): HTMLElement | null { + for (let i = children.length - 1; i >= 0; i--) { + if (children[i] instanceof HTMLElement) return children[i] as HTMLElement; + } + return null; + } + + #resolve(root: HTMLDocument, parent: HTMLElement | HTMLDocument | HTMLTemplate, children: Array, context: { if: number }) { + for (const child of children) { + if (child instanceof Element) { + const tag = child.tagName; + const lowerCaseTag = tag.toLowerCase(); + + // 模板 + if (lowerCaseTag === "template") { + const nameAttr = child.attributes.find(a => a.name.name === "name"); + if (!nameAttr?.value) throw new Error("template element must have a name attribute"); + let name = nameAttr.value.value; + if (name.startsWith("'") || name.startsWith('"')) name = name.slice(1, -1); + + const argumentAttr = child.attributes.find(a => a.name.name === "argument"); + let argument = "data"; + if (argumentAttr?.value) { + const value = argumentAttr.value.value; + if (value.startsWith("'") || value.startsWith('"')) argument = value.slice(1, -1); + else argument = value; + } + const template = new HTMLTemplate(name, argument, this.filename); + root.addTemplate(template); + + if (child.children.length) this.#resolve(root, template, child.children, context); + } + + // 模板引用 + else if (lowerCaseTag === "use") { + const fromAttr = child.attributes.find(a => a.name.name === "from"); + let from: string | null = null; + if (fromAttr) { + if (!fromAttr.value) throw new Error("use element must have a from attribute"); + from = fromAttr.value.value; + if (from.startsWith("'") || from.startsWith('"')) from = from.slice(1, -1); + } + + const nameAttr = child.attributes.find(a => a.name.name === "name"); + if (!nameAttr?.value) throw new Error("use element must have a name attribute"); + let name = nameAttr.value.value; + if (name.startsWith("'") || name.startsWith('"')) name = name.slice(1, -1); + + const dataAttr = child.attributes.find(a => a.name.name === "data"); + let data: string | null = null; + if (dataAttr?.value) { + const value = dataAttr.value.value; + data = value.startsWith("'") || value.startsWith('"') ? value.slice(1, -1) : value; + } + + const use = new HTMLElement("use", true); + if (from) use.addAttribute("from", from); + use.addAttribute("name", name); + if (data) use.addAttribute("data", data); + + for (const attr of child.attributes) { + const name = attr.name.name; + if (attr.control) { + use.addControl(name, attr.value?.value ?? ""); + if (name === "if") use.setIf(++context.if); + else if (name === "else") use.setIf(this.#findLastElement(parent.children)?.ifKey ?? null); + } + } + + parent.addChild(use); + } + + //普通元素 + else { + const element = new HTMLElement(tag, child.selfClosing); + for (const attr of child.attributes) { + const name = attr.name.name; + if (attr.control) { + element.addControl(name, attr.value?.value ?? ""); + if (name === "if") element.setIf(++context.if); + else if (name === "else") element.setIf(this.#findLastElement(parent.children)?.ifKey ?? null); + } + else element.addAttribute(name, attr.value?.value ?? null); + } + parent.addChild(element); + this.#resolve(root, element, child.children, context); + } + } + //文本节点 + else if (child instanceof Text) { + parent.addChild(new HTMLText(child.value)); + } + //注释 + else if (child instanceof Comment) { + parent.addChild(new HTMLComment(child.value)); + } + } + } + + resolve() { + if (!this.#cache) { + const body = this.#document.build(); + const func = new Function("data", "__system__", "__context__", "__filename__", "__dirname__", `return \`${body}\`;`); + this.#cache = (data: any) => func(data, __system__, { if: 0 }, this.filename, path.dirname(this.filename)); + } + return this.#cache; + } +} diff --git a/src/render/render.ts b/src/render/render.ts new file mode 100644 index 0000000..d54a8de --- /dev/null +++ b/src/render/render.ts @@ -0,0 +1,13 @@ +import path from "path"; +import { Module } from "./module" + +/** + * 渲染HTML页面 + * @param filename 模板文件路径 + * @param data 要渲染的数据 + * @returns 渲染好的HTML页面 + */ +export function render(filename: string, data: any) { + filename = path.resolve(process.cwd(), filename); + return Module.get(filename).resolve()(data); +} diff --git a/src/render/util.ts b/src/render/util.ts new file mode 100644 index 0000000..31af587 --- /dev/null +++ b/src/render/util.ts @@ -0,0 +1,57 @@ +import fs from "fs"; +import path from "path"; +import { Module } from "./module"; + +export function check(value: any, defaultValue: any = null) { + return `(()=>{ try{ return (${value})??(${defaultValue}) } catch(e){ return (${defaultValue}) } })()` +} + +export const __system__ = { + for: (value: any, callback: (value: any, key: any) => any) => { + const result: any[] = []; + if (value === null || value === undefined) return ""; + if (value instanceof Array) { + for (const [key, item] of value.entries()) { + result.push(callback(item, key)); + } + } + else { + for (const [key, item] of Object.entries(value)) { + result.push(callback(item, key)); + } + } + return result.join(""); + }, + if: (value: any, key: number, context: Record, callback: () => any): any => { + if (value) { + context[key] = true; + return callback(); + } + else { + context[key] = false; + return ""; + } + }, + elseIf: (value: any, key: number, context: Record, callback: () => any) => { + if (context[key]) return ""; + if (value) { + context[key] = true; + return callback(); + } + else { + context[key] = false; + return ""; + } + }, + else: (key: number, context: Record, callback: () => any) => { + if (context[key]) return ""; + context[key] = true; + return callback(); + }, + use: (file: string, name: string, data: any, dirname: string) => { + const filename = path.resolve(dirname, file); + const template = Module.get(filename).template(name); + if (!template) return ""; + return template.resolve()(data); + } +} diff --git a/test/main.html b/test/main.html new file mode 100644 index 0000000..f4bebc0 --- /dev/null +++ b/test/main.html @@ -0,0 +1,19 @@ + + + + + + + {{data.name}}Love + + + +

{{data.name}}

+
+ + ${afdfs} +
+ + + + \ No newline at end of file diff --git a/test/mod1.html b/test/mod1.html new file mode 100644 index 0000000..64bc78e --- /dev/null +++ b/test/mod1.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5d9f945 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,108 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + "rootDir": "./src", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + "declarationDir": "./types", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}