@monimesl/dot-dml-parser 0.1.0-alpha.4 → 0.1.0-alpha.41

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/dist/index.d.ts CHANGED
@@ -20,19 +20,9 @@ export declare function isElementNode(node: DmlNode): node is DmlElementNode;
20
20
  interface ParseDmlOptions {
21
21
  file?: string;
22
22
  locations?: boolean;
23
- whitespace?: 'trim' | 'collapse' | 'preserve';
23
+ whitespace?: 'trim' | 'collapse' | 'inline' | 'preserve';
24
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[];
25
+ declare function parseDmlToIR(source: string, options?: ParseDmlOptions): DmlNode[];
36
26
  //#endregion
37
27
  //#region src/errors.d.ts
38
28
  export declare class DmlParseError extends SourceError {
@@ -1 +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"}
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;;iBAcO,aAAa,gBAAgB,UAAS,kBAAuB;;;qBCrBzD,sBAAsB;WACf"}
package/dist/index.js CHANGED
@@ -6,49 +6,39 @@ var DmlParseError = class extends SourceError {
6
6
  };
7
7
  //#endregion
8
8
  //#region src/parse.ts
9
- const CLOSE_TAG = /<\/([^\s>]+)\s*>/g;
9
+ const CLOSING_TAG = /<\/([^\s>]+)\s*>/g;
10
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 = {}) {
11
+ function parseDmlToIR(source, options = {}) {
22
12
  const { file, locations = false, whitespace = "trim" } = options;
23
13
  const roots = [];
24
- const stack = [];
14
+ const openTags = [];
25
15
  let textBuffer = "";
26
16
  let textStart = -1;
27
- const closedAt = /* @__PURE__ */ new Set();
28
- const locate = createLocator(dmlString);
17
+ const matchedClosingTagOffsets = /* @__PURE__ */ new Set();
18
+ const locate = createLocator(source);
29
19
  const errorAt = (message, offset) => {
30
20
  return new DmlParseError(message, file, locate(offset));
31
21
  };
22
+ const withLocation = (node, offset) => {
23
+ if (!locations) return node;
24
+ return {
25
+ ...node,
26
+ loc: locate(offset)
27
+ };
28
+ };
32
29
  const currentChildren = () => {
33
- return stack.at(-1)?.node.children ?? roots;
30
+ return openTags.at(-1)?.node.children ?? roots;
34
31
  };
35
32
  const flushText = () => {
36
33
  if (textStart < 0) return;
37
34
  const content = normalizeText(textBuffer, whitespace);
38
35
  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 = {
36
+ let contentStart = textStart;
37
+ if (whitespace !== "preserve") contentStart += leadingWhitespaceLength(textBuffer);
38
+ currentChildren().push(withLocation({
48
39
  type: "Text",
49
40
  content
50
- };
51
- currentChildren().push(node);
41
+ }, contentStart));
52
42
  }
53
43
  textBuffer = "";
54
44
  textStart = -1;
@@ -61,44 +51,36 @@ function parseDmlToIR(dmlString, options = {}) {
61
51
  start: parser.startIndex
62
52
  };
63
53
  },
64
- onopentag(name, attribs) {
54
+ onopentag(name, rawAttributes) {
65
55
  unfinishedTag = void 0;
66
56
  flushText();
67
- const events = {};
68
- const attributes = {};
69
- for (const [attributeName, value] of Object.entries(attribs)) if (isEventProp(attributeName)) events[attributeName] = value;
70
- else attributes[attributeName] = value;
71
- const node = locations ? {
72
- type: "Element",
73
- tag: name,
74
- attributes,
75
- events,
76
- children: [],
77
- loc: locate(parser.startIndex)
78
- } : {
57
+ const { attributes, events } = splitEvents(rawAttributes);
58
+ const node = withLocation({
79
59
  type: "Element",
80
60
  tag: name,
81
61
  attributes,
82
62
  events,
83
63
  children: []
84
- };
64
+ }, parser.startIndex);
85
65
  currentChildren().push(node);
86
- stack.push({
66
+ openTags.push({
87
67
  node,
88
68
  start: parser.startIndex
89
69
  });
90
70
  },
91
71
  onclosetag(name, isImplied) {
92
72
  flushText();
93
- if (unfinishedTag) throw errorAt(`Unfinished tag <${unfinishedTag.name}>: expected \`>\` or \`/>\` but found end of input`, unfinishedTag.start);
94
- const tag = stack.pop();
95
- const selfClosing = parser.startIndex === tag.start;
73
+ if (unfinishedTag) throw errorAt(`<${unfinishedTag.name}> isn't finished: add \`>\` or \`/>\``, unfinishedTag.start);
74
+ const openTag = openTags.pop();
75
+ const openTagName = openTag.node.tag;
76
+ const selfClosing = parser.startIndex === openTag.start;
96
77
  if (isImplied && !selfClosing) {
97
- const found = parser.startIndex >= dmlString.length ? "end of input" : `\`${dmlString.slice(parser.startIndex, parser.endIndex + 1)}\``;
98
- throw errorAt(`Unclosed tag <${tag.node.tag}>: expected </${tag.node.tag}> but found ${found}`, tag.start);
78
+ let found = "end of input";
79
+ if (parser.startIndex < source.length) found = `\`${source.slice(parser.startIndex, parser.endIndex + 1)}\``;
80
+ throw errorAt(`<${openTagName}> isn't closed: expected </${openTagName}>, found ${found}`, openTag.start);
99
81
  }
100
- if (name !== tag.node.tag) throw errorAt(`Mismatched closing tag: expected </${tag.node.tag}> but found </${name}>`, parser.startIndex);
101
- if (!selfClosing) closedAt.add(parser.startIndex);
82
+ if (name !== openTagName) throw errorAt(`</${name}> doesn't match <${openTagName}>: expected </${openTagName}>`, parser.startIndex);
83
+ if (!selfClosing) matchedClosingTagOffsets.add(parser.startIndex);
102
84
  },
103
85
  ontext(data) {
104
86
  if (textStart < 0) textStart = parser.startIndex;
@@ -118,20 +100,43 @@ function parseDmlToIR(dmlString, options = {}) {
118
100
  decodeEntities: true,
119
101
  recognizeCDATA: true
120
102
  });
121
- parser.write(dmlString);
103
+ parser.write(source);
122
104
  parser.end();
123
105
  flushText();
124
- const stripped = dmlString.replace(OPAQUE_REGION, (region) => " ".repeat(region.length));
125
- 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);
106
+ const markupOnly = source.replace(OPAQUE_REGION, (region) => " ".repeat(region.length));
107
+ for (const closingTag of markupOnly.matchAll(CLOSING_TAG)) if (!matchedClosingTagOffsets.has(closingTag.index)) throw errorAt(`</${closingTag[1]}> has no matching opening tag`, closingTag.index);
126
108
  return roots;
127
109
  }
110
+ function splitEvents(rawAttributes) {
111
+ const attributes = {};
112
+ const events = {};
113
+ for (const [name, value] of Object.entries(rawAttributes)) if (isEventProp(name)) events[name] = value;
114
+ else attributes[name] = value;
115
+ return {
116
+ attributes,
117
+ events
118
+ };
119
+ }
128
120
  function normalizeText(raw, mode) {
129
121
  switch (mode) {
130
- case "preserve": return raw.trim().length === 0 ? "" : raw;
122
+ case "preserve":
123
+ if (raw.trim() === "") return "";
124
+ return raw;
131
125
  case "collapse": return raw.replace(/\s+/g, " ").trim();
126
+ case "inline": return inlineText(raw);
132
127
  case "trim": return raw.trim();
133
128
  }
134
129
  }
130
+ function inlineText(raw) {
131
+ if (raw.trim() === "") {
132
+ if (raw.includes("\n")) return "";
133
+ return " ";
134
+ }
135
+ return raw.replace(/^\s*\n\s*/, "").replace(/\s*\n\s*$/, "").replace(/\s+/g, " ");
136
+ }
137
+ function leadingWhitespaceLength(text) {
138
+ return text.length - text.trimStart().length;
139
+ }
135
140
  //#endregion
136
141
  //#region src/ast.ts
137
142
  function isElementNode(node) {
package/dist/index.js.map CHANGED
@@ -1 +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 // A tag whose name has been read but not its closing `>`.\n let unfinishedTag: { name: string; start: number } | undefined;\n const parser = new Parser(\n {\n onopentagname(name) {\n unfinishedTag = { name, start: parser.startIndex };\n },\n onopentag(name, attribs) {\n unfinishedTag = undefined;\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 if (unfinishedTag) {\n throw errorAt(\n `Unfinished tag <${unfinishedTag.name}>: expected \\`>\\` or \\`/>\\` but found end of input`,\n unfinishedTag.start,\n );\n }\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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"}
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' | 'inline' | 'preserve';\n}\n\ntype WhitespaceMode = NonNullable<ParseDmlOptions['whitespace']>;\n\nconst CLOSING_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// Parses .dml markup into a tree, leaving `{{ }}` bindings as raw strings.\nfunction parseDmlToIR(source: string, options: ParseDmlOptions = {}): DmlNode[] {\n const { file, locations = false, whitespace = 'trim' } = options;\n const roots: DmlNode[] = [];\n const openTags: OpenTag[] = [];\n let textBuffer = '';\n let textStart = -1;\n const matchedClosingTagOffsets = new Set<number>();\n const locate = createLocator(source);\n const errorAt = (message: string, offset: number) => {\n return new DmlParseError(message, file, locate(offset));\n };\n // Without `locations`, nodes have no `loc` key at all.\n const withLocation = <Node extends DmlNode>(node: Node, offset: number): Node => {\n if (!locations) {\n return node;\n }\n return { ...node, loc: locate(offset) };\n };\n const currentChildren = (): DmlNode[] => {\n return openTags.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 // Point at the first character kept, past any whitespace trimmed off the front.\n let contentStart = textStart;\n if (whitespace !== 'preserve') {\n contentStart += leadingWhitespaceLength(textBuffer);\n }\n currentChildren().push(withLocation({ type: 'Text', content }, contentStart));\n }\n textBuffer = '';\n textStart = -1;\n };\n // A tag whose name has been read but not its closing `>`.\n let unfinishedTag: { name: string; start: number } | undefined;\n const parser = new Parser(\n {\n onopentagname(name) {\n unfinishedTag = { name, start: parser.startIndex };\n },\n onopentag(name, rawAttributes) {\n unfinishedTag = undefined;\n flushText();\n const { attributes, events } = splitEvents(rawAttributes);\n const node = withLocation<DmlElementNode>(\n { type: 'Element', tag: name, attributes, events, children: [] },\n parser.startIndex,\n );\n currentChildren().push(node);\n openTags.push({ node, start: parser.startIndex });\n },\n onclosetag(name, isImplied) {\n flushText();\n if (unfinishedTag) {\n throw errorAt(\n `<${unfinishedTag.name}> isn't finished: add \\`>\\` or \\`/>\\``,\n unfinishedTag.start,\n );\n }\n // htmlparser2 only reports closing tags it has seen open.\n const openTag = openTags.pop()!;\n const openTagName = openTag.node.tag;\n const selfClosing = parser.startIndex === openTag.start;\n if (isImplied && !selfClosing) {\n let found = 'end of input';\n if (parser.startIndex < source.length) {\n found = `\\`${source.slice(parser.startIndex, parser.endIndex + 1)}\\``;\n }\n throw errorAt(\n `<${openTagName}> isn't closed: expected </${openTagName}>, found ${found}`,\n openTag.start,\n );\n }\n if (name !== openTagName) {\n throw errorAt(\n `</${name}> doesn't match <${openTagName}>: expected </${openTagName}>`,\n parser.startIndex,\n );\n }\n if (!selfClosing) {\n matchedClosingTagOffsets.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(source);\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 markupOnly = source.replace(OPAQUE_REGION, (region) => ' '.repeat(region.length));\n for (const closingTag of markupOnly.matchAll(CLOSING_TAG)) {\n if (!matchedClosingTagOffsets.has(closingTag.index)) {\n throw errorAt(`</${closingTag[1]!}> has no matching opening tag`, closingTag.index);\n }\n }\n return roots;\n}\n\nexport default parseDmlToIR;\n\nfunction splitEvents(rawAttributes: Record<string, string>): {\n attributes: Record<string, string>;\n events: Record<string, string>;\n} {\n const attributes: Record<string, string> = {};\n const events: Record<string, string> = {};\n for (const [name, value] of Object.entries(rawAttributes)) {\n if (isEventProp(name)) {\n events[name] = value;\n } else {\n attributes[name] = value;\n }\n }\n return { attributes, events };\n}\n\nfunction normalizeText(raw: string, mode: WhitespaceMode): string {\n switch (mode) {\n case 'preserve':\n if (raw.trim() === '') {\n return '';\n }\n return raw;\n case 'collapse':\n return raw.replace(/\\s+/g, ' ').trim();\n case 'inline':\n return inlineText(raw);\n case 'trim':\n return raw.trim();\n }\n}\n\n// As text flows around inline elements: whitespace with a line break in it only lays out the markup,\n// so it goes, but a space on the same line as an element stays, as in `paid <Text>SLE 50</Text> today`.\nfunction inlineText(raw: string): string {\n if (raw.trim() === '') {\n if (raw.includes('\\n')) {\n return '';\n }\n return ' ';\n }\n return raw\n .replace(/^\\s*\\n\\s*/, '')\n .replace(/\\s*\\n\\s*$/, '')\n .replace(/\\s+/g, ' ');\n}\n\nfunction leadingWhitespaceLength(text: string): number {\n return text.length - text.trimStart().length;\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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monimesl/dot-dml-parser",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.41",
4
4
  "description": "Dot .dml template parser: markup -> intermediary representation",
5
5
  "type": "module",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "htmlparser2": "^12.0.0",
35
- "@monimesl/dot-core": "0.1.0-alpha.4"
35
+ "@monimesl/dot-core": "0.1.0-alpha.41"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"