@monimesl/dot-dml-parser 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # @monimesl/dot-dml-parser
@@ -0,0 +1,43 @@
1
+ import { SourceError, SourceLocation } from "@monimesl/dot-core";
2
+ //#region src/ast.d.ts
3
+ interface DmlElementNode {
4
+ readonly type: 'Element';
5
+ readonly tag: string;
6
+ readonly events: Record<string, string>;
7
+ readonly attributes: Record<string, string>;
8
+ readonly children: DmlNode[];
9
+ readonly loc?: SourceLocation;
10
+ }
11
+ interface DmlTextNode {
12
+ readonly type: 'Text';
13
+ readonly content: string;
14
+ readonly loc?: SourceLocation;
15
+ }
16
+ type DmlNode = DmlElementNode | DmlTextNode;
17
+ export declare function isElementNode(node: DmlNode): node is DmlElementNode;
18
+ //#endregion
19
+ //#region src/parse.d.ts
20
+ interface ParseDmlOptions {
21
+ file?: string;
22
+ locations?: boolean;
23
+ whitespace?: 'trim' | 'collapse' | 'preserve';
24
+ }
25
+ /**
26
+ * Turns a .dml markup string into an IR tree.
27
+ * - Tag names are case-sensitive and kept exactly as written (like View, If, For).
28
+ * - Props starting with "on" followed by a capital letter (like onClick) go into events; everything else goes to attributes.
29
+ * - Double-curly bindings ({{ expression }}) are left completely alone as raw strings; no parsing there.
30
+ * - Pure whitespace text nodes are eaten, and regular text gets trimmed by default.
31
+ * - Comments and processing instructions get ignored, but CDATA is treated as normal text.
32
+ *
33
+ * @throws {DmlParseError} If tags are unclosed, mismatched, or out of order.
34
+ */
35
+ declare function parseDmlToIR(dmlString: string, options?: ParseDmlOptions): DmlNode[];
36
+ //#endregion
37
+ //#region src/errors.d.ts
38
+ export declare class DmlParseError extends SourceError {
39
+ readonly name = "DmlParseError";
40
+ }
41
+ //#endregion
42
+ export { type DmlElementNode, type DmlNode, type DmlTextNode, type ParseDmlOptions, parseDmlToIR };
43
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/ast.ts","../src/parse.ts","../src/errors.ts"],"mappings":";;UAEiB;WACN;WACA;WACA,QAAQ;WACR,YAAY;WACZ,UAAU;WACV,MAAM;;UAGA;WACN;WACA;WACA,MAAM;;KAGL,UAAU,iBAAiB;wBAEvB,cAAc,MAAM,UAAU,QAAQ;;;UCbrC;EACf;EACA;EACA;;;;;;;;;;;;iBAqBO,aAAa,mBAAmB,UAAS,kBAAuB;;;qBC5B5D,sBAAsB;WACf"}
package/dist/index.js ADDED
@@ -0,0 +1,134 @@
1
+ import { Parser } from "htmlparser2";
2
+ import { SourceError, createLocator, isEventProp } from "@monimesl/dot-core";
3
+ //#region src/errors.ts
4
+ var DmlParseError = class extends SourceError {
5
+ name = "DmlParseError";
6
+ };
7
+ //#endregion
8
+ //#region src/parse.ts
9
+ const CLOSE_TAG = /<\/([^\s>]+)\s*>/g;
10
+ const OPAQUE_REGION = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?]]>/g;
11
+ /**
12
+ * Turns a .dml markup string into an IR tree.
13
+ * - Tag names are case-sensitive and kept exactly as written (like View, If, For).
14
+ * - Props starting with "on" followed by a capital letter (like onClick) go into events; everything else goes to attributes.
15
+ * - Double-curly bindings ({{ expression }}) are left completely alone as raw strings; no parsing there.
16
+ * - Pure whitespace text nodes are eaten, and regular text gets trimmed by default.
17
+ * - Comments and processing instructions get ignored, but CDATA is treated as normal text.
18
+ *
19
+ * @throws {DmlParseError} If tags are unclosed, mismatched, or out of order.
20
+ */
21
+ function parseDmlToIR(dmlString, options = {}) {
22
+ const { file, locations = false, whitespace = "trim" } = options;
23
+ const roots = [];
24
+ const stack = [];
25
+ let textBuffer = "";
26
+ let textStart = -1;
27
+ const closedAt = /* @__PURE__ */ new Set();
28
+ const locate = createLocator(dmlString);
29
+ const errorAt = (message, offset) => {
30
+ return new DmlParseError(message, file, locate(offset));
31
+ };
32
+ const currentChildren = () => {
33
+ return stack.at(-1)?.node.children ?? roots;
34
+ };
35
+ const flushText = () => {
36
+ if (textStart < 0) return;
37
+ const content = normalizeText(textBuffer, whitespace);
38
+ if (content.length > 0) {
39
+ let node;
40
+ if (locations) {
41
+ const leading = whitespace === "preserve" ? 0 : textBuffer.length - textBuffer.trimStart().length;
42
+ node = {
43
+ type: "Text",
44
+ content,
45
+ loc: locate(textStart + leading)
46
+ };
47
+ } else node = {
48
+ type: "Text",
49
+ content
50
+ };
51
+ currentChildren().push(node);
52
+ }
53
+ textBuffer = "";
54
+ textStart = -1;
55
+ };
56
+ const parser = new Parser({
57
+ onopentag(name, attribs) {
58
+ flushText();
59
+ const events = {};
60
+ const attributes = {};
61
+ for (const [attributeName, value] of Object.entries(attribs)) if (isEventProp(attributeName)) events[attributeName] = value;
62
+ else attributes[attributeName] = value;
63
+ const node = locations ? {
64
+ type: "Element",
65
+ tag: name,
66
+ attributes,
67
+ events,
68
+ children: [],
69
+ loc: locate(parser.startIndex)
70
+ } : {
71
+ type: "Element",
72
+ tag: name,
73
+ attributes,
74
+ events,
75
+ children: []
76
+ };
77
+ currentChildren().push(node);
78
+ stack.push({
79
+ node,
80
+ start: parser.startIndex
81
+ });
82
+ },
83
+ onclosetag(name, isImplied) {
84
+ flushText();
85
+ const tag = stack.pop();
86
+ const selfClosing = parser.startIndex === tag.start;
87
+ if (isImplied && !selfClosing) {
88
+ const found = parser.startIndex >= dmlString.length ? "end of input" : `\`${dmlString.slice(parser.startIndex, parser.endIndex + 1)}\``;
89
+ throw errorAt(`Unclosed tag <${tag.node.tag}>: expected </${tag.node.tag}> but found ${found}`, tag.start);
90
+ }
91
+ if (name !== tag.node.tag) throw errorAt(`Mismatched closing tag: expected </${tag.node.tag}> but found </${name}>`, parser.startIndex);
92
+ if (!selfClosing) closedAt.add(parser.startIndex);
93
+ },
94
+ ontext(data) {
95
+ if (textStart < 0) textStart = parser.startIndex;
96
+ textBuffer += data;
97
+ },
98
+ oncomment() {
99
+ flushText();
100
+ },
101
+ onprocessinginstruction() {
102
+ flushText();
103
+ },
104
+ onerror(error) {
105
+ throw errorAt(error.message, parser.startIndex);
106
+ }
107
+ }, {
108
+ xmlMode: true,
109
+ decodeEntities: true,
110
+ recognizeCDATA: true
111
+ });
112
+ parser.write(dmlString);
113
+ parser.end();
114
+ flushText();
115
+ const stripped = dmlString.replace(OPAQUE_REGION, (region) => " ".repeat(region.length));
116
+ for (const closeTag of stripped.matchAll(CLOSE_TAG)) if (!closedAt.has(closeTag.index)) throw errorAt(`Unexpected closing tag </${closeTag[1]}> with no matching opening tag`, closeTag.index);
117
+ return roots;
118
+ }
119
+ function normalizeText(raw, mode) {
120
+ switch (mode) {
121
+ case "preserve": return raw.trim().length === 0 ? "" : raw;
122
+ case "collapse": return raw.replace(/\s+/g, " ").trim();
123
+ case "trim": return raw.trim();
124
+ }
125
+ }
126
+ //#endregion
127
+ //#region src/ast.ts
128
+ function isElementNode(node) {
129
+ return node.type === "Element";
130
+ }
131
+ //#endregion
132
+ export { DmlParseError, isElementNode, parseDmlToIR };
133
+
134
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/errors.ts","../src/parse.ts","../src/ast.ts"],"sourcesContent":["import { SourceError } from '@monimesl/dot-core';\n\nexport class DmlParseError extends SourceError {\n override readonly name = 'DmlParseError';\n}\n","import { Parser } from 'htmlparser2';\nimport { createLocator, isEventProp } from '@monimesl/dot-core';\n\nimport { DmlParseError } from './errors';\nimport type { DmlElementNode, DmlNode } from './ast';\n\nexport interface ParseDmlOptions {\n file?: string;\n locations?: boolean;\n whitespace?: 'trim' | 'collapse' | 'preserve';\n}\n\nconst CLOSE_TAG = /<\\/([^\\s>]+)\\s*>/g;\nconst OPAQUE_REGION = /<!--[\\s\\S]*?-->|<!\\[CDATA\\[[\\s\\S]*?]]>/g;\n\ninterface OpenTag {\n node: DmlElementNode;\n start: number;\n}\n\n/**\n * Turns a .dml markup string into an IR tree.\n * - Tag names are case-sensitive and kept exactly as written (like View, If, For).\n * - Props starting with \"on\" followed by a capital letter (like onClick) go into events; everything else goes to attributes.\n * - Double-curly bindings ({{ expression }}) are left completely alone as raw strings; no parsing there.\n * - Pure whitespace text nodes are eaten, and regular text gets trimmed by default.\n * - Comments and processing instructions get ignored, but CDATA is treated as normal text.\n *\n * @throws {DmlParseError} If tags are unclosed, mismatched, or out of order.\n */\nfunction parseDmlToIR(dmlString: string, options: ParseDmlOptions = {}): DmlNode[] {\n const { file, locations = false, whitespace = 'trim' } = options;\n const roots: DmlNode[] = [];\n const stack: OpenTag[] = [];\n let textBuffer = '';\n let textStart = -1;\n const closedAt = new Set<number>();\n const locate = createLocator(dmlString);\n const errorAt = (message: string, offset: number) => {\n return new DmlParseError(message, file, locate(offset));\n };\n const currentChildren = (): DmlNode[] => {\n return stack.at(-1)?.node.children ?? roots;\n };\n const flushText = (): void => {\n if (textStart < 0) {\n return;\n }\n const content = normalizeText(textBuffer, whitespace);\n if (content.length > 0) {\n let node: DmlNode;\n if (locations) {\n const leading =\n whitespace === 'preserve' ? 0 : textBuffer.length - textBuffer.trimStart().length;\n node = { type: 'Text', content, loc: locate(textStart + leading) };\n } else {\n node = { type: 'Text', content };\n }\n currentChildren().push(node);\n }\n textBuffer = '';\n textStart = -1;\n };\n const parser = new Parser(\n {\n onopentag(name, attribs) {\n flushText();\n const events: Record<string, string> = {};\n const attributes: Record<string, string> = {};\n for (const [attributeName, value] of Object.entries(attribs)) {\n if (isEventProp(attributeName)) {\n events[attributeName] = value;\n } else {\n attributes[attributeName] = value;\n }\n }\n const node: DmlElementNode = locations\n ? {\n type: 'Element',\n tag: name,\n attributes,\n events,\n children: [],\n loc: locate(parser.startIndex),\n }\n : { type: 'Element', tag: name, attributes, events, children: [] };\n currentChildren().push(node);\n stack.push({ node, start: parser.startIndex });\n },\n onclosetag(name, isImplied) {\n flushText();\n // htmlparser2 only reports closing tags it has seen open.\n const tag = stack.pop()!;\n const selfClosing = parser.startIndex === tag.start;\n if (isImplied && !selfClosing) {\n const found =\n parser.startIndex >= dmlString.length\n ? 'end of input'\n : `\\`${dmlString.slice(parser.startIndex, parser.endIndex + 1)}\\``;\n throw errorAt(\n `Unclosed tag <${tag.node.tag}>: expected </${tag.node.tag}> but found ${found}`,\n tag.start,\n );\n }\n if (name !== tag.node.tag) {\n throw errorAt(\n `Mismatched closing tag: expected </${tag.node.tag}> but found </${name}>`,\n parser.startIndex,\n );\n }\n if (!selfClosing) {\n closedAt.add(parser.startIndex);\n }\n },\n ontext(data) {\n // htmlparser2 splits text around entities; buffer until the next tag.\n if (textStart < 0) {\n textStart = parser.startIndex;\n }\n textBuffer += data;\n },\n oncomment() {\n flushText();\n },\n onprocessinginstruction() {\n flushText();\n },\n onerror(error) {\n throw errorAt(error.message, parser.startIndex);\n },\n },\n {\n xmlMode: true,\n decodeEntities: true,\n recognizeCDATA: true,\n },\n );\n\n parser.write(dmlString);\n parser.end();\n flushText();\n\n // htmlparser2 ignores unmatched closing tags instead of throwing, so we catch them here.\n // Comments and CDATA are blanked with spaces, not removed, so offsets still match the source.\n const stripped = dmlString.replace(OPAQUE_REGION, (region) => ' '.repeat(region.length));\n for (const closeTag of stripped.matchAll(CLOSE_TAG)) {\n if (!closedAt.has(closeTag.index)) {\n throw errorAt(\n `Unexpected closing tag </${closeTag[1]!}> with no matching opening tag`,\n closeTag.index,\n );\n }\n }\n return roots;\n}\n\nexport default parseDmlToIR;\n\nfunction normalizeText(raw: string, mode: NonNullable<ParseDmlOptions['whitespace']>): string {\n switch (mode) {\n case 'preserve':\n return raw.trim().length === 0 ? '' : raw;\n case 'collapse':\n return raw.replace(/\\s+/g, ' ').trim();\n case 'trim':\n return raw.trim();\n }\n}\n","import type { SourceLocation } from '@monimesl/dot-core';\n\nexport interface DmlElementNode {\n readonly type: 'Element';\n readonly tag: string;\n readonly events: Record<string, string>;\n readonly attributes: Record<string, string>;\n readonly children: DmlNode[];\n readonly loc?: SourceLocation;\n}\n\nexport interface DmlTextNode {\n readonly type: 'Text';\n readonly content: string;\n readonly loc?: SourceLocation;\n}\n\nexport type DmlNode = DmlElementNode | DmlTextNode;\n\nexport function isElementNode(node: DmlNode): node is DmlElementNode {\n return node.type === 'Element';\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@monimesl/dot-dml-parser",
3
+ "version": "0.1.0-alpha.2",
4
+ "description": "Dot .dml template parser: markup -> intermediary representation",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/monime-lab/dot.git",
9
+ "directory": "packages/parsers/dml"
10
+ },
11
+ "sideEffects": false,
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "exports": {
16
+ ".": {
17
+ "@monimesl/dot-source": "./src/index.ts",
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "devDependencies": {
27
+ "publint": "^0.3.24",
28
+ "rimraf": "^6.1.3",
29
+ "tsdown": "^0.23.0",
30
+ "typescript": "^5.9.3",
31
+ "vitest": "^5.0.1"
32
+ },
33
+ "dependencies": {
34
+ "htmlparser2": "^12.0.0",
35
+ "@monimesl/dot-core": "0.1.0-alpha.2"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "tsdown",
42
+ "dev": "tsdown --watch",
43
+ "typecheck": "tsc --noEmit -p tsconfig.json",
44
+ "test": "vitest run",
45
+ "clean": "rimraf dist *.tsbuildinfo"
46
+ }
47
+ }