@nml-lang/compiler-ts 2.2.0
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 +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +70 -0
- package/dist/index.js.map +1 -0
- package/dist/lexer.d.ts +30 -0
- package/dist/lexer.d.ts.map +1 -0
- package/dist/lexer.js +132 -0
- package/dist/lexer.js.map +1 -0
- package/dist/parser.d.ts +55 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +915 -0
- package/dist/parser.js.map +1 -0
- package/dist/renderer.d.ts +19 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +186 -0
- package/dist/renderer.js.map +1 -0
- package/package.json +43 -0
- package/src/index.ts +116 -0
- package/src/lexer.ts +186 -0
- package/src/parser.ts +1108 -0
- package/src/renderer.ts +236 -0
package/dist/parser.js
ADDED
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NML Parser
|
|
3
|
+
* Ports nml_parse.py's build_ast, _expand_components_pass,
|
|
4
|
+
* _inject_slot, and related helpers to TypeScript.
|
|
5
|
+
*
|
|
6
|
+
* Every ASTNode carries loc: { line, column } sourced from the lexer.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "crypto";
|
|
9
|
+
export class NMLParserError extends Error {
|
|
10
|
+
loc;
|
|
11
|
+
constructor(message, loc = { line: 0, column: 0 }) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "NMLParserError";
|
|
14
|
+
this.loc = loc;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Constants mirrored from nml_parse.py
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
const INDENT_WIDTH = 4;
|
|
21
|
+
const VOID_ELEMENTS = new Set([
|
|
22
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
23
|
+
"link", "meta", "param", "source", "track", "wbr",
|
|
24
|
+
]);
|
|
25
|
+
const BOOLEAN_ATTRIBUTES = new Set([
|
|
26
|
+
"allowfullscreen", "async", "autofocus", "autoplay", "checked",
|
|
27
|
+
"controls", "crossorigin", "default", "defer", "disabled",
|
|
28
|
+
"formnovalidate", "hidden", "ismap", "loop", "multiple",
|
|
29
|
+
"muted", "nomodule", "novalidate", "open", "readonly",
|
|
30
|
+
"required", "reversed", "selected",
|
|
31
|
+
]);
|
|
32
|
+
const WHITELISTED_ATTRS = new Set([
|
|
33
|
+
"id", "name", "type", "href", "src", "action", "method",
|
|
34
|
+
"placeholder", "value", "for", "rel", "charset", "content",
|
|
35
|
+
"lang", "dir", "tabindex", "role", "target", "download",
|
|
36
|
+
"enctype", "accept", "autocomplete", "min", "max", "step",
|
|
37
|
+
"pattern", "rows", "cols", "colspan", "rowspan", "scope",
|
|
38
|
+
"headers", "width", "height", "alt", "title", "loading",
|
|
39
|
+
"decoding", "fetchpriority", "integrity", "crossorigin",
|
|
40
|
+
"nonce", "referrerpolicy", "sandbox", "srcdoc", "srclang",
|
|
41
|
+
"poster", "preload", "autoplay", "loop", "muted", "controls", "playsinline",
|
|
42
|
+
]);
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Line parser (equivalent to parse_line in Python)
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
export function parseLine(rawContent, loc) {
|
|
47
|
+
const line = rawContent.trim();
|
|
48
|
+
// Content pipe
|
|
49
|
+
if (line.startsWith("|")) {
|
|
50
|
+
return makeNode("__text__", {}, line.slice(1).trim(), [], loc);
|
|
51
|
+
}
|
|
52
|
+
// Comments (should not reach here normally, handled by lexer)
|
|
53
|
+
if (line.startsWith("//")) {
|
|
54
|
+
return makeNode("__comment__", {}, "", [], loc);
|
|
55
|
+
}
|
|
56
|
+
// Determine the element name (everything before the first unquoted dot)
|
|
57
|
+
let element = "";
|
|
58
|
+
let rest = "";
|
|
59
|
+
if (line.startsWith("@")) {
|
|
60
|
+
// Could be @define.Name, @slot, @slot.name, @style:, @ComponentName...
|
|
61
|
+
const spaceIdx = line.indexOf(" ");
|
|
62
|
+
const candidate = spaceIdx === -1 ? line : line.slice(0, spaceIdx);
|
|
63
|
+
if (candidate.startsWith("@define.")) {
|
|
64
|
+
element = "@define";
|
|
65
|
+
const name = candidate.slice(8);
|
|
66
|
+
return makeNode("@define", { class: name }, "", [], loc);
|
|
67
|
+
}
|
|
68
|
+
if (candidate === "@slot" || candidate.startsWith("@slot.")) {
|
|
69
|
+
const slotName = candidate.startsWith("@slot.") ? candidate.slice(6) : undefined;
|
|
70
|
+
const attrs = {};
|
|
71
|
+
if (slotName)
|
|
72
|
+
attrs["name"] = slotName;
|
|
73
|
+
return makeNode("@slot", attrs, "", [], loc);
|
|
74
|
+
}
|
|
75
|
+
if (candidate === "@style:" || candidate === "@style") {
|
|
76
|
+
const node = makeNode("@style", {}, "", [], loc);
|
|
77
|
+
node.multiline_trigger = candidate.endsWith(":");
|
|
78
|
+
return node;
|
|
79
|
+
}
|
|
80
|
+
if (candidate === "@include" || candidate.startsWith("@include(")) {
|
|
81
|
+
// @include("path/to/file.nml")
|
|
82
|
+
// @include("path/to/file.nml", { key: "value" })
|
|
83
|
+
const afterAt = line.slice(line.indexOf("("));
|
|
84
|
+
return parseIncludeDirective(afterAt, loc);
|
|
85
|
+
}
|
|
86
|
+
// @each(items as item)
|
|
87
|
+
if (candidate === "@each" || candidate.startsWith("@each(")) {
|
|
88
|
+
const parenContent = extractParenContent(line);
|
|
89
|
+
const asMatch = parenContent?.match(/^([\w.]+)\s+as\s+([\w]+)$/);
|
|
90
|
+
if (!asMatch) {
|
|
91
|
+
throw new NMLParserError(`@each syntax error: expected '@each(items as item)', got '${line}'`, loc);
|
|
92
|
+
}
|
|
93
|
+
return makeNode("@each", { items: asMatch[1], as: asMatch[2] }, "", [], loc);
|
|
94
|
+
}
|
|
95
|
+
if (candidate === "@endeach") {
|
|
96
|
+
return makeNode("@endeach", {}, "", [], loc);
|
|
97
|
+
}
|
|
98
|
+
// @if(condition)
|
|
99
|
+
if (candidate === "@if" || candidate.startsWith("@if(")) {
|
|
100
|
+
const condition = extractParenContent(line) ?? "";
|
|
101
|
+
return makeNode("@if", { condition }, "", [], loc);
|
|
102
|
+
}
|
|
103
|
+
if (candidate === "@else") {
|
|
104
|
+
return makeNode("@else", {}, "", [], loc);
|
|
105
|
+
}
|
|
106
|
+
if (candidate === "@endif") {
|
|
107
|
+
return makeNode("@endif", {}, "", [], loc);
|
|
108
|
+
}
|
|
109
|
+
// @ComponentName possibly with attributes
|
|
110
|
+
const dotIdx = findFirstUnquotedDot(candidate);
|
|
111
|
+
if (dotIdx === -1) {
|
|
112
|
+
element = candidate;
|
|
113
|
+
rest = spaceIdx !== -1 ? line.slice(spaceIdx + 1) : "";
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
element = candidate.slice(0, dotIdx);
|
|
117
|
+
rest = candidate.slice(dotIdx) + (spaceIdx !== -1 ? line.slice(spaceIdx) : "");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
// Normal element: leading dot means "div" (or strip leading dot)
|
|
122
|
+
let workLine = line.startsWith(".") ? "div" + line : line;
|
|
123
|
+
// Strip trailing ':' from multiline trigger lines when parsing
|
|
124
|
+
if (workLine.trimEnd().endsWith(":")) {
|
|
125
|
+
workLine = workLine.trimEnd().slice(0, -1).trimEnd();
|
|
126
|
+
}
|
|
127
|
+
const dotIdx = findFirstUnquotedDot(workLine);
|
|
128
|
+
if (dotIdx === -1) {
|
|
129
|
+
element = workLine;
|
|
130
|
+
rest = "";
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
element = workLine.slice(0, dotIdx);
|
|
134
|
+
rest = workLine.slice(dotIdx);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Parse attribute chain from `rest`
|
|
138
|
+
const { attributes, content } = parseAttributeChain(rest, loc);
|
|
139
|
+
const node = makeNode(element, attributes, content, [], loc);
|
|
140
|
+
// Set multiline_trigger if the original line ended with ':'
|
|
141
|
+
if (!line.startsWith("@") && line.trimEnd().endsWith(":")) {
|
|
142
|
+
node.multiline_trigger = true;
|
|
143
|
+
}
|
|
144
|
+
return node;
|
|
145
|
+
}
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Attribute chain parser
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
function parseAttributeChain(chain, loc) {
|
|
150
|
+
const attributes = {};
|
|
151
|
+
let content = "";
|
|
152
|
+
let i = 0;
|
|
153
|
+
while (i < chain.length) {
|
|
154
|
+
if (chain[i] !== ".") {
|
|
155
|
+
i++;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
i++; // skip dot
|
|
159
|
+
// Read the attribute name (up to '(' or next unquoted '.' )
|
|
160
|
+
let attrName = "";
|
|
161
|
+
while (i < chain.length && chain[i] !== "(" && chain[i] !== ".") {
|
|
162
|
+
attrName += chain[i];
|
|
163
|
+
i++;
|
|
164
|
+
}
|
|
165
|
+
if (!attrName)
|
|
166
|
+
continue;
|
|
167
|
+
// Handle on:* events -> translate to native event handler name
|
|
168
|
+
if (attrName.startsWith("on:")) {
|
|
169
|
+
attrName = "on" + attrName.slice(3);
|
|
170
|
+
}
|
|
171
|
+
// Normalize hx:* → hx-* and x:* → x-* (HTMX/Alpine colon sugar)
|
|
172
|
+
// LLMs frequently emit colons instead of dashes; the compiler absorbs both forms.
|
|
173
|
+
if (attrName.startsWith("hx:")) {
|
|
174
|
+
attrName = "hx-" + attrName.slice(3);
|
|
175
|
+
}
|
|
176
|
+
else if (/^x:[a-z]/.test(attrName)) {
|
|
177
|
+
attrName = "x-" + attrName.slice(2);
|
|
178
|
+
}
|
|
179
|
+
if (i < chain.length && chain[i] === "(") {
|
|
180
|
+
// Read the parenthesised value(s)
|
|
181
|
+
i++; // skip '('
|
|
182
|
+
const args = readParenArgs(chain, i);
|
|
183
|
+
i = args.end + 1; // skip ')'
|
|
184
|
+
if (attrName === "class") {
|
|
185
|
+
// .class("val") replaces class entirely
|
|
186
|
+
// Last arg may be content if multiple args
|
|
187
|
+
if (args.values.length === 1) {
|
|
188
|
+
attributes["class"] = args.values[0];
|
|
189
|
+
}
|
|
190
|
+
else if (args.values.length >= 2) {
|
|
191
|
+
attributes["class"] = args.values[0];
|
|
192
|
+
content = args.values[args.values.length - 1];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
// General attribute — last value is content if multiple
|
|
197
|
+
if (args.values.length === 1) {
|
|
198
|
+
// Single arg: it IS the attribute value
|
|
199
|
+
// UNLESS attrName matches element name (h1("text") shorthand)
|
|
200
|
+
attributes[attrName] = args.values[0];
|
|
201
|
+
}
|
|
202
|
+
else if (args.values.length === 0) {
|
|
203
|
+
attributes[attrName] = "";
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// Multiple args: first is attr value, last is content
|
|
207
|
+
attributes[attrName] = args.values[0];
|
|
208
|
+
content = args.values[args.values.length - 1];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// Boolean or bare class shorthand (e.g., .text-xl, .disabled, .!text-2xl)
|
|
214
|
+
if (BOOLEAN_ATTRIBUTES.has(attrName)) {
|
|
215
|
+
attributes[attrName] = true;
|
|
216
|
+
}
|
|
217
|
+
else if (!attrName.includes(":") && !WHITELISTED_ATTRS.has(attrName) && !attrName.startsWith("data-") && !attrName.startsWith("aria-") && !attrName.startsWith("hx-") && !attrName.startsWith("x-")) {
|
|
218
|
+
// Bare word that's not a known HTML attr: treat as additional class
|
|
219
|
+
const existing = attributes["class"];
|
|
220
|
+
if (existing === undefined) {
|
|
221
|
+
attributes["class"] = [attrName];
|
|
222
|
+
}
|
|
223
|
+
else if (Array.isArray(existing)) {
|
|
224
|
+
existing.push(attrName);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
attributes["class"] = existing + " " + attrName;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// Could be data-*, aria-*, hx-*, x-* etc
|
|
232
|
+
attributes[attrName] = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { attributes, content };
|
|
237
|
+
}
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Helpers for element-level content shorthand e.g. h1("Hello")
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
/**
|
|
242
|
+
* The Python parser supports `h1("Hello")` as shorthand for content.
|
|
243
|
+
* In NML the element line is like `h1.class("blue", "Hello")` or `h1("Hello")`.
|
|
244
|
+
* We need to detect when the "attribute name" equals the element name
|
|
245
|
+
* and treat the value as content instead.
|
|
246
|
+
*/
|
|
247
|
+
export function parseLineRaw(rawLine, loc) {
|
|
248
|
+
// Handle the content shorthand BEFORE the attribute chain parser
|
|
249
|
+
// by checking whether the FIRST parenthesised group uses the element name.
|
|
250
|
+
const line = rawLine.trim();
|
|
251
|
+
if (line.startsWith("|")) {
|
|
252
|
+
return makeNode("__text__", {}, line.slice(1).trim(), [], loc);
|
|
253
|
+
}
|
|
254
|
+
if (line.startsWith("//")) {
|
|
255
|
+
return makeNode("__comment__", {}, "", [], loc);
|
|
256
|
+
}
|
|
257
|
+
// Detect multiline trigger before stripping
|
|
258
|
+
const isMultiline = !line.startsWith("@") && line.trimEnd().endsWith(":");
|
|
259
|
+
// Strip multiline trigger colon from the end for attribute parsing
|
|
260
|
+
const workLine = isMultiline
|
|
261
|
+
? line.trimEnd().slice(0, -1).trimEnd()
|
|
262
|
+
: line;
|
|
263
|
+
// Find element name
|
|
264
|
+
let element = "";
|
|
265
|
+
let chainStart = 0;
|
|
266
|
+
if (workLine.startsWith("@") || line.startsWith("@")) {
|
|
267
|
+
// Pass the original line so parseLine can detect '@style:'
|
|
268
|
+
return parseLine(line, loc);
|
|
269
|
+
}
|
|
270
|
+
const adjusted = workLine.startsWith(".") ? "div" + workLine : workLine;
|
|
271
|
+
const firstParen = adjusted.indexOf("(");
|
|
272
|
+
const firstDot = findFirstUnquotedDot(adjusted);
|
|
273
|
+
if (firstParen !== -1 && (firstDot === -1 || firstParen < firstDot)) {
|
|
274
|
+
// Content shorthand: `h1("Hello")` or `h1.class("x", "Hello")`
|
|
275
|
+
element = adjusted.slice(0, firstParen);
|
|
276
|
+
const rest = adjusted.slice(firstParen);
|
|
277
|
+
// Read the content from the first parens
|
|
278
|
+
const args = readParenArgs(rest, 1);
|
|
279
|
+
const afterFirst = rest.slice(args.end + 1);
|
|
280
|
+
let content = args.values[args.values.length - 1] ?? "";
|
|
281
|
+
// Parse remaining attribute chain after the first paren group
|
|
282
|
+
const { attributes } = parseAttributeChain(afterFirst, loc);
|
|
283
|
+
// If multiple values in first group, first is attr of same name? No —
|
|
284
|
+
// shorthand is: element("content") or element.attr("v", "content")
|
|
285
|
+
// When the first group immediately follows the element (no dot), it's content.
|
|
286
|
+
if (args.values.length > 1) {
|
|
287
|
+
content = args.values[args.values.length - 1];
|
|
288
|
+
}
|
|
289
|
+
const node = makeNode(element, attributes, content, [], loc);
|
|
290
|
+
if (isMultiline)
|
|
291
|
+
node.multiline_trigger = true;
|
|
292
|
+
return node;
|
|
293
|
+
}
|
|
294
|
+
if (firstDot === -1) {
|
|
295
|
+
element = adjusted;
|
|
296
|
+
chainStart = adjusted.length;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
element = adjusted.slice(0, firstDot);
|
|
300
|
+
chainStart = firstDot;
|
|
301
|
+
}
|
|
302
|
+
const chain = adjusted.slice(chainStart);
|
|
303
|
+
const { attributes, content } = parseAttributeChain(chain, loc);
|
|
304
|
+
const node = makeNode(element, attributes, content, [], loc);
|
|
305
|
+
if (isMultiline)
|
|
306
|
+
node.multiline_trigger = true;
|
|
307
|
+
return node;
|
|
308
|
+
}
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Build AST (equivalent to build_ast in Python)
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
export function buildAst(source, options = {}) {
|
|
313
|
+
const components = options.components ?? {};
|
|
314
|
+
const globalStyles = options.globalStyles ?? {};
|
|
315
|
+
const lines = source.split("\n");
|
|
316
|
+
const root = makeNode("__root__", {}, "", [], { line: 0, column: 0 });
|
|
317
|
+
const stack = [[root, -1]];
|
|
318
|
+
let lineIdx = 0;
|
|
319
|
+
while (lineIdx < lines.length) {
|
|
320
|
+
const rawLine = lines[lineIdx];
|
|
321
|
+
const lineNum = lineIdx + 1;
|
|
322
|
+
lineIdx++;
|
|
323
|
+
const stripped = rawLine.trimEnd();
|
|
324
|
+
// Skip blank lines
|
|
325
|
+
if (stripped.trim() === "")
|
|
326
|
+
continue;
|
|
327
|
+
// Skip comments
|
|
328
|
+
if (stripped.trimStart().startsWith("//"))
|
|
329
|
+
continue;
|
|
330
|
+
// Validate indentation
|
|
331
|
+
if (stripped.startsWith("\t")) {
|
|
332
|
+
throw new NMLParserError(`Indentation error on line ${lineNum}: Please use 4 spaces for indentation, not tabs.`, { line: lineNum, column: 0 });
|
|
333
|
+
}
|
|
334
|
+
const leadingSpaces = stripped.length - stripped.trimStart().length;
|
|
335
|
+
if (leadingSpaces % INDENT_WIDTH !== 0) {
|
|
336
|
+
throw new NMLParserError(`Indentation error on line ${lineNum}: Non-standard indentation. Use 4 spaces per level.`, { line: lineNum, column: 0 });
|
|
337
|
+
}
|
|
338
|
+
const level = leadingSpaces / INDENT_WIDTH;
|
|
339
|
+
const content = stripped.trimStart();
|
|
340
|
+
const loc = { line: lineNum, column: leadingSpaces };
|
|
341
|
+
// Parse the line into an AST node
|
|
342
|
+
const newNode = parseLineRaw(content, loc);
|
|
343
|
+
// Skip comment nodes
|
|
344
|
+
if (newNode.element === "__comment__")
|
|
345
|
+
continue;
|
|
346
|
+
// Validate indentation depth
|
|
347
|
+
const currentLevel = stack[stack.length - 1][1];
|
|
348
|
+
if (level > currentLevel + 1) {
|
|
349
|
+
throw new NMLParserError(`Indentation error on line ${lineNum}: Incorrect indentation level (too deep).`, { line: lineNum, column: leadingSpaces });
|
|
350
|
+
}
|
|
351
|
+
// Pop stack to find correct parent
|
|
352
|
+
while (level <= stack[stack.length - 1][1]) {
|
|
353
|
+
stack.pop();
|
|
354
|
+
}
|
|
355
|
+
const parentNode = stack[stack.length - 1][0];
|
|
356
|
+
// Handle content pipe nodes
|
|
357
|
+
if (newNode.element === "__text__") {
|
|
358
|
+
const parentLevel = stack[stack.length - 1][1];
|
|
359
|
+
if (level !== parentLevel + 1 || parentLevel === -1) {
|
|
360
|
+
throw new NMLParserError(`Indentation error on line ${lineNum}: Content pipe '|' must be indented one level deeper than its parent element.`, { line: lineNum, column: leadingSpaces });
|
|
361
|
+
}
|
|
362
|
+
parentNode.children.push(newNode);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
// Handle multiline blocks (element ends with ':')
|
|
366
|
+
if (isMultilineTrigger(content)) {
|
|
367
|
+
newNode.multiline_trigger = true;
|
|
368
|
+
const blockStartSpaces = (level + 1) * INDENT_WIDTH;
|
|
369
|
+
while (lineIdx < lines.length) {
|
|
370
|
+
const mlRaw = lines[lineIdx];
|
|
371
|
+
const mlLine = lineIdx + 1;
|
|
372
|
+
const mlStripped = mlRaw.trimEnd();
|
|
373
|
+
if (mlStripped.trim() === "") {
|
|
374
|
+
newNode.multiline_content.push("");
|
|
375
|
+
lineIdx++;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const mlSpaces = mlStripped.length - mlStripped.trimStart().length;
|
|
379
|
+
if (mlSpaces < blockStartSpaces)
|
|
380
|
+
break;
|
|
381
|
+
if (mlSpaces % INDENT_WIDTH !== 0) {
|
|
382
|
+
throw new NMLParserError(`Indentation error on line ${mlLine}: Non-standard indentation. Use 4 spaces per level.`, { line: mlLine, column: 0 });
|
|
383
|
+
}
|
|
384
|
+
const relativeIndent = " ".repeat(mlSpaces - blockStartSpaces);
|
|
385
|
+
newNode.multiline_content.push(relativeIndent + mlStripped.trimStart());
|
|
386
|
+
lineIdx++;
|
|
387
|
+
}
|
|
388
|
+
parentNode.children.push(newNode);
|
|
389
|
+
// Multiline nodes can't have children in the tree — don't push to stack
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
parentNode.children.push(newNode);
|
|
393
|
+
stack.push([newNode, level]);
|
|
394
|
+
}
|
|
395
|
+
// Component expansion pass
|
|
396
|
+
root.children = expandComponentsPass(root.children, components, globalStyles);
|
|
397
|
+
// Conditionals post-process pass: group @if/@else/@endif siblings
|
|
398
|
+
return postProcessConditionalsPass(root.children);
|
|
399
|
+
}
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// Component expansion pass
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
function expandComponentsPass(nodes, components, globalStyles) {
|
|
404
|
+
const expanded = [];
|
|
405
|
+
for (const node of nodes) {
|
|
406
|
+
const element = node.element;
|
|
407
|
+
// Process @define blocks
|
|
408
|
+
if (element === "@define") {
|
|
409
|
+
const componentName = node.attributes["class"];
|
|
410
|
+
if (componentName) {
|
|
411
|
+
// Extract @style block
|
|
412
|
+
let styleRaw = "";
|
|
413
|
+
for (const child of node.children) {
|
|
414
|
+
if (child.element === "@style") {
|
|
415
|
+
styleRaw = child.multiline_content.join("\n");
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const toHash = `${componentName}|${styleRaw}`;
|
|
420
|
+
const digest = createHash("sha1").update(toHash).digest("hex").slice(0, 6);
|
|
421
|
+
const scopeId = `nml-c-${digest}`;
|
|
422
|
+
const { componentAst, scopedCss } = extractScopedStyle(node.children, scopeId);
|
|
423
|
+
if (scopedCss) {
|
|
424
|
+
globalStyles[scopeId] = scopedCss;
|
|
425
|
+
const rootNode = findComponentRootNode(componentAst);
|
|
426
|
+
if (rootNode) {
|
|
427
|
+
rootNode.attributes[scopeId] = true;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
components[componentName] = componentAst;
|
|
431
|
+
}
|
|
432
|
+
continue; // @define never added to output AST
|
|
433
|
+
}
|
|
434
|
+
// Process @ComponentName calls
|
|
435
|
+
if (element.startsWith("@") && element !== "@define" && element !== "@slot" && element !== "@style" && element !== "@include" && element !== "@each" && element !== "@endeach" && element !== "@if" && element !== "@else" && element !== "@endif") {
|
|
436
|
+
const componentName = element.slice(1);
|
|
437
|
+
if (!(componentName in components)) {
|
|
438
|
+
throw new NMLParserError(`Undefined component: '@${componentName}' not found.`, node.loc);
|
|
439
|
+
}
|
|
440
|
+
const templateAst = deepClone(components[componentName]);
|
|
441
|
+
// Collect slot content from call site
|
|
442
|
+
const defaultChildren = [];
|
|
443
|
+
const namedSlots = {};
|
|
444
|
+
for (const child of node.children) {
|
|
445
|
+
if (child.element === "@slot") {
|
|
446
|
+
const slotName = child.attributes["name"];
|
|
447
|
+
const expandedChildren = expandComponentsPass(child.children, components, globalStyles);
|
|
448
|
+
if (slotName) {
|
|
449
|
+
if (namedSlots[slotName]) {
|
|
450
|
+
namedSlots[slotName].push(...expandedChildren);
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
namedSlots[slotName] = expandedChildren;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// Named slot children go to named slots only
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
// Default slot
|
|
460
|
+
const expandedList = expandComponentsPass([child], components, globalStyles);
|
|
461
|
+
defaultChildren.push(...expandedList);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const slots = {
|
|
465
|
+
[Symbol.for("default")]: defaultChildren,
|
|
466
|
+
...namedSlots,
|
|
467
|
+
};
|
|
468
|
+
const injected = injectSlot(templateAst, slots);
|
|
469
|
+
let resolvedAst = injected;
|
|
470
|
+
if (resolvedAst.length > 0) {
|
|
471
|
+
resolvedAst = expandComponentsPass(resolvedAst, components, globalStyles);
|
|
472
|
+
}
|
|
473
|
+
if (resolvedAst.length > 0) {
|
|
474
|
+
const baseNode = resolvedAst[0];
|
|
475
|
+
const callAttrs = node.attributes;
|
|
476
|
+
// Merge call-site attributes
|
|
477
|
+
const mergeAttrs = {};
|
|
478
|
+
const propsAttrs = {};
|
|
479
|
+
for (const [k, v] of Object.entries(callAttrs)) {
|
|
480
|
+
if (isMergeableAttr(k)) {
|
|
481
|
+
mergeAttrs[k] = v;
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
propsAttrs[k] = v;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
baseNode.attributes = mergeAttributes(baseNode.attributes, mergeAttrs);
|
|
488
|
+
if (Object.keys(propsAttrs).length > 0) {
|
|
489
|
+
const propDict = {};
|
|
490
|
+
for (const [k, v] of Object.entries(propsAttrs)) {
|
|
491
|
+
propDict[k] = String(v);
|
|
492
|
+
}
|
|
493
|
+
baseNode.__context__ = { prop: propDict };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
expanded.push(...resolvedAst);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
// Regular node — recurse into children
|
|
500
|
+
if (node.children.length > 0) {
|
|
501
|
+
node.children = expandComponentsPass(node.children, components, globalStyles);
|
|
502
|
+
}
|
|
503
|
+
expanded.push(node);
|
|
504
|
+
}
|
|
505
|
+
return expanded;
|
|
506
|
+
}
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
// Slot injection
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
function injectSlot(templateAst, slots) {
|
|
511
|
+
const result = [];
|
|
512
|
+
for (const node of templateAst) {
|
|
513
|
+
if (node.element === "@slot") {
|
|
514
|
+
const slotName = node.attributes["name"];
|
|
515
|
+
const key = slotName ?? Symbol.for("default");
|
|
516
|
+
const provided = slots[key] ?? (typeof key === "symbol" ? slots[Symbol.for("default")] : undefined);
|
|
517
|
+
if (provided && provided.length > 0) {
|
|
518
|
+
result.push(...provided);
|
|
519
|
+
}
|
|
520
|
+
else if (node.children.length > 0) {
|
|
521
|
+
// Fallback content
|
|
522
|
+
result.push(...node.children);
|
|
523
|
+
}
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (node.children.length > 0) {
|
|
527
|
+
node.children = injectSlot(node.children, slots);
|
|
528
|
+
}
|
|
529
|
+
result.push(node);
|
|
530
|
+
}
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
// Scoped style extraction
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
function extractScopedStyle(children, scopeId) {
|
|
537
|
+
const componentAst = [];
|
|
538
|
+
let scopedCss = "";
|
|
539
|
+
for (const child of children) {
|
|
540
|
+
if (child.element === "@style") {
|
|
541
|
+
const raw = child.multiline_content.join("\n");
|
|
542
|
+
scopedCss = scopeStyleBlock(raw, scopeId);
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
componentAst.push(child);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return { componentAst, scopedCss };
|
|
549
|
+
}
|
|
550
|
+
function scopeStyleBlock(css, scopeId) {
|
|
551
|
+
// Add [scopeId] attribute selector to each CSS rule selector
|
|
552
|
+
return css.replace(/\.([\w-]+)(\s*(?::[\w-]+)*)\s*\{/g, (match, className, pseudo) => {
|
|
553
|
+
return `.${className}[${scopeId}]${pseudo} {`;
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
export function findComponentRootNode(ast) {
|
|
557
|
+
for (const node of ast) {
|
|
558
|
+
if (node.element !== "@define" && node.element !== "@slot" && node.element !== "@style" && !node.element.startsWith("//")) {
|
|
559
|
+
return node;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// Attribute helpers
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
function isMergeableAttr(key) {
|
|
568
|
+
if (key === "class")
|
|
569
|
+
return true;
|
|
570
|
+
if (BOOLEAN_ATTRIBUTES.has(key))
|
|
571
|
+
return true;
|
|
572
|
+
if (WHITELISTED_ATTRS.has(key))
|
|
573
|
+
return true;
|
|
574
|
+
if (key.startsWith("data-"))
|
|
575
|
+
return true;
|
|
576
|
+
if (key.startsWith("aria-"))
|
|
577
|
+
return true;
|
|
578
|
+
if (key.startsWith("on"))
|
|
579
|
+
return true; // onclick, etc.
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
function mergeAttributes(base, overrides) {
|
|
583
|
+
const result = { ...base };
|
|
584
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
585
|
+
if (key === "class") {
|
|
586
|
+
const baseClass = result["class"];
|
|
587
|
+
if (Array.isArray(value)) {
|
|
588
|
+
// Dot-chain classes: append
|
|
589
|
+
const baseStr = Array.isArray(baseClass) ? baseClass.join(" ") : baseClass ?? "";
|
|
590
|
+
result["class"] = (baseStr + " " + value.join(" ")).trim();
|
|
591
|
+
}
|
|
592
|
+
else if (typeof value === "string") {
|
|
593
|
+
// .class("val"): replace
|
|
594
|
+
result["class"] = value;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
result[key] = value;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return result;
|
|
602
|
+
}
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// Variable rendering (equivalent to _render_variables in Python)
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// isTruthy — Pythonic UI-optimized truthiness
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
export function isTruthy(val) {
|
|
610
|
+
if (val === null || val === undefined)
|
|
611
|
+
return false;
|
|
612
|
+
if (val === 0 || val === "")
|
|
613
|
+
return false;
|
|
614
|
+
if (Array.isArray(val))
|
|
615
|
+
return val.length > 0;
|
|
616
|
+
if (typeof val === "object")
|
|
617
|
+
return Object.keys(val).length > 0;
|
|
618
|
+
return Boolean(val);
|
|
619
|
+
}
|
|
620
|
+
const BUILTIN_FILTERS = {
|
|
621
|
+
uppercase: (val) => String(val ?? "").toUpperCase(),
|
|
622
|
+
lowercase: (val) => String(val ?? "").toLowerCase(),
|
|
623
|
+
trim: (val) => String(val ?? "").trim(),
|
|
624
|
+
json: (val) => JSON.stringify(val),
|
|
625
|
+
default: (val, arg) => (isTruthy(val) ? String(val) : (arg ?? "")),
|
|
626
|
+
};
|
|
627
|
+
function applyFilter(filterName, filterArg, val, context) {
|
|
628
|
+
// 1. Check built-ins
|
|
629
|
+
const builtin = BUILTIN_FILTERS[filterName];
|
|
630
|
+
if (builtin)
|
|
631
|
+
return builtin(val, filterArg);
|
|
632
|
+
// 2. Check user-defined fn in context
|
|
633
|
+
const userFn = context[filterName];
|
|
634
|
+
if (typeof userFn === "function")
|
|
635
|
+
return String(userFn(val, filterArg));
|
|
636
|
+
// 3. Unknown → empty string
|
|
637
|
+
return "";
|
|
638
|
+
}
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
// Variable rendering (equivalent to _render_variables in Python)
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
export function renderVariables(template, context) {
|
|
643
|
+
// Matches: {{ varPath }}, {{ varPath|raw }}, {{ varPath|filter }}, {{ varPath|filter("arg") }}
|
|
644
|
+
return template.replace(/\{\{\s*([\w.]+?)(?:\|(raw|[\w]+(?:\([^)]*\))?))?\s*\}\}/g, (match, key, filterExpr) => {
|
|
645
|
+
const value = resolvePath(key, context);
|
|
646
|
+
// No filter — existing behaviour
|
|
647
|
+
if (!filterExpr) {
|
|
648
|
+
if (value === undefined)
|
|
649
|
+
return match;
|
|
650
|
+
return escapeHtml(String(value));
|
|
651
|
+
}
|
|
652
|
+
// |raw bypass
|
|
653
|
+
if (filterExpr === "raw") {
|
|
654
|
+
if (value === undefined)
|
|
655
|
+
return match;
|
|
656
|
+
return String(value);
|
|
657
|
+
}
|
|
658
|
+
// Filter expression: filterName or filterName("arg")
|
|
659
|
+
const filterMatch = filterExpr.match(/^([\w]+)(?:\(([^)]*)\))?$/);
|
|
660
|
+
if (!filterMatch) {
|
|
661
|
+
if (value === undefined)
|
|
662
|
+
return match;
|
|
663
|
+
return escapeHtml(String(value));
|
|
664
|
+
}
|
|
665
|
+
const filterName = filterMatch[1];
|
|
666
|
+
const rawArg = filterMatch[2]; // may be undefined or '"N/A"' etc.
|
|
667
|
+
const filterArg = rawArg !== undefined
|
|
668
|
+
? rawArg.trim().replace(/^["']|["']$/g, "")
|
|
669
|
+
: undefined;
|
|
670
|
+
const result = applyFilter(filterName, filterArg, value, context);
|
|
671
|
+
// json filter: raw output (not HTML-escaped)
|
|
672
|
+
if (filterName === "json")
|
|
673
|
+
return result ?? "";
|
|
674
|
+
return escapeHtml(result ?? "");
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
function resolvePath(path, context) {
|
|
678
|
+
const parts = path.split(".");
|
|
679
|
+
let current = context;
|
|
680
|
+
for (const part of parts) {
|
|
681
|
+
if (current === null || current === undefined)
|
|
682
|
+
return undefined;
|
|
683
|
+
current = current[part];
|
|
684
|
+
}
|
|
685
|
+
return current;
|
|
686
|
+
}
|
|
687
|
+
function escapeHtml(str) {
|
|
688
|
+
return str
|
|
689
|
+
.replace(/&/g, "&")
|
|
690
|
+
.replace(/</g, "<")
|
|
691
|
+
.replace(/>/g, ">")
|
|
692
|
+
.replace(/"/g, """)
|
|
693
|
+
.replace(/'/g, "'");
|
|
694
|
+
}
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
// Utility helpers
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
function makeNode(element, attributes, content, children, loc) {
|
|
699
|
+
return {
|
|
700
|
+
element,
|
|
701
|
+
attributes,
|
|
702
|
+
content,
|
|
703
|
+
children,
|
|
704
|
+
multiline_trigger: false,
|
|
705
|
+
multiline_content: [],
|
|
706
|
+
loc,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// @include directive parser
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
/**
|
|
713
|
+
* Parse @include("path") or @include("path", { key: "value" })
|
|
714
|
+
* Stores:
|
|
715
|
+
* attributes.file — the relative path string
|
|
716
|
+
* attributes.overrides — JSON-encoded override object (if provided)
|
|
717
|
+
*/
|
|
718
|
+
function parseIncludeDirective(afterAt, loc) {
|
|
719
|
+
// afterAt looks like: ("path/to/file.nml") or ("path", { k: "v" })
|
|
720
|
+
const openParen = afterAt.indexOf("(");
|
|
721
|
+
if (openParen === -1) {
|
|
722
|
+
throw new NMLParserError("@include requires a file path argument: @include(\"path.nml\")", loc);
|
|
723
|
+
}
|
|
724
|
+
// Extract everything inside the outer parens
|
|
725
|
+
const args = readParenArgs(afterAt, openParen + 1);
|
|
726
|
+
if (args.values.length === 0) {
|
|
727
|
+
throw new NMLParserError("@include requires a file path argument", loc);
|
|
728
|
+
}
|
|
729
|
+
const file = args.values[0];
|
|
730
|
+
if (!file) {
|
|
731
|
+
throw new NMLParserError("@include file path cannot be empty", loc);
|
|
732
|
+
}
|
|
733
|
+
if (file.startsWith("/")) {
|
|
734
|
+
throw new NMLParserError("@include paths must be relative, not absolute", loc);
|
|
735
|
+
}
|
|
736
|
+
// Second arg (if any) is a JSON-like override object string
|
|
737
|
+
// We store it raw as a string and parse it at render time
|
|
738
|
+
const overridesRaw = args.values.length >= 2 ? args.values[1] : "";
|
|
739
|
+
return makeNode("@include", { file, overrides: overridesRaw }, "", [], loc);
|
|
740
|
+
}
|
|
741
|
+
function deepClone(obj) {
|
|
742
|
+
return JSON.parse(JSON.stringify(obj));
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Extract the content between the outermost parentheses of a directive line.
|
|
746
|
+
* e.g. "@each(items as item)" → "items as item"
|
|
747
|
+
* "@if(user.isAdmin)" → "user.isAdmin"
|
|
748
|
+
*/
|
|
749
|
+
function extractParenContent(line) {
|
|
750
|
+
const open = line.indexOf("(");
|
|
751
|
+
if (open === -1)
|
|
752
|
+
return undefined;
|
|
753
|
+
const close = line.lastIndexOf(")");
|
|
754
|
+
if (close === -1 || close <= open)
|
|
755
|
+
return undefined;
|
|
756
|
+
return line.slice(open + 1, close).trim();
|
|
757
|
+
}
|
|
758
|
+
// ---------------------------------------------------------------------------
|
|
759
|
+
// Post-process pass: group @if/@else/@endif and strip @endeach markers
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
/**
|
|
762
|
+
* Because NML is indentation-based, @else / @endif appear as SIBLINGS of @if
|
|
763
|
+
* in the parent array (not as children). This pass:
|
|
764
|
+
* - Finds @if nodes at index i
|
|
765
|
+
* - Looks ahead for @else at i+1 → moves its children into node.elseBranch
|
|
766
|
+
* - Removes the @else and @endif siblings
|
|
767
|
+
* - Removes @endeach siblings (children already captured by indentation)
|
|
768
|
+
* - Recurses into every node's children array
|
|
769
|
+
*/
|
|
770
|
+
export function postProcessConditionalsPass(nodes) {
|
|
771
|
+
const result = [];
|
|
772
|
+
let i = 0;
|
|
773
|
+
while (i < nodes.length) {
|
|
774
|
+
const node = nodes[i];
|
|
775
|
+
if (node.element === "@if") {
|
|
776
|
+
// Recurse into then-branch children first
|
|
777
|
+
node.children = postProcessConditionalsPass(node.children);
|
|
778
|
+
let j = i + 1;
|
|
779
|
+
// Consume optional @else sibling
|
|
780
|
+
if (j < nodes.length && nodes[j].element === "@else") {
|
|
781
|
+
node.elseBranch = postProcessConditionalsPass(nodes[j].children);
|
|
782
|
+
j++;
|
|
783
|
+
}
|
|
784
|
+
// Consume required @endif sibling — throw if missing
|
|
785
|
+
if (j < nodes.length && nodes[j].element === "@endif") {
|
|
786
|
+
j++;
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
throw new NMLParserError(`Missing @endif for @if on line ${node.loc.line}.`, node.loc);
|
|
790
|
+
}
|
|
791
|
+
result.push(node);
|
|
792
|
+
i = j;
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (node.element === "@each") {
|
|
796
|
+
// Recurse into loop body children
|
|
797
|
+
node.children = postProcessConditionalsPass(node.children);
|
|
798
|
+
let j = i + 1;
|
|
799
|
+
// Consume @endeach sibling — throw if missing
|
|
800
|
+
if (j < nodes.length && nodes[j].element === "@endeach") {
|
|
801
|
+
j++;
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
throw new NMLParserError(`Missing @endeach for @each on line ${node.loc.line}.`, node.loc);
|
|
805
|
+
}
|
|
806
|
+
result.push(node);
|
|
807
|
+
i = j;
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
// For all other nodes, recurse into children
|
|
811
|
+
if (node.children.length > 0) {
|
|
812
|
+
node.children = postProcessConditionalsPass(node.children);
|
|
813
|
+
}
|
|
814
|
+
if (node.elseBranch && node.elseBranch.length > 0) {
|
|
815
|
+
node.elseBranch = postProcessConditionalsPass(node.elseBranch);
|
|
816
|
+
}
|
|
817
|
+
result.push(node);
|
|
818
|
+
i++;
|
|
819
|
+
}
|
|
820
|
+
return result;
|
|
821
|
+
}
|
|
822
|
+
function isMultilineTrigger(content) {
|
|
823
|
+
const trimmed = content.trimEnd();
|
|
824
|
+
let inQuote = false;
|
|
825
|
+
let quoteChar = "";
|
|
826
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
827
|
+
const ch = trimmed[i];
|
|
828
|
+
if (inQuote) {
|
|
829
|
+
if (ch === quoteChar)
|
|
830
|
+
inQuote = false;
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
if (ch === '"' || ch === "'") {
|
|
834
|
+
inQuote = true;
|
|
835
|
+
quoteChar = ch;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return !inQuote && trimmed.endsWith(":");
|
|
840
|
+
}
|
|
841
|
+
function findFirstUnquotedDot(s) {
|
|
842
|
+
let inQuote = false;
|
|
843
|
+
let quoteChar = "";
|
|
844
|
+
for (let i = 0; i < s.length; i++) {
|
|
845
|
+
const ch = s[i];
|
|
846
|
+
if (inQuote) {
|
|
847
|
+
if (ch === quoteChar)
|
|
848
|
+
inQuote = false;
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
if (ch === '"' || ch === "'") {
|
|
852
|
+
inQuote = true;
|
|
853
|
+
quoteChar = ch;
|
|
854
|
+
}
|
|
855
|
+
else if (ch === ".") {
|
|
856
|
+
return i;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return -1;
|
|
861
|
+
}
|
|
862
|
+
function readParenArgs(s, startIdx) {
|
|
863
|
+
// s[startIdx-1] is '(' — read comma-separated quoted strings until ')'
|
|
864
|
+
const values = [];
|
|
865
|
+
let i = startIdx;
|
|
866
|
+
let depth = 1;
|
|
867
|
+
let current = "";
|
|
868
|
+
let inQuote = false;
|
|
869
|
+
let quoteChar = "";
|
|
870
|
+
while (i < s.length && depth > 0) {
|
|
871
|
+
const ch = s[i];
|
|
872
|
+
if (inQuote) {
|
|
873
|
+
if (ch === quoteChar) {
|
|
874
|
+
inQuote = false;
|
|
875
|
+
// Don't add the closing quote
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
current += ch;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
if (ch === '"' || ch === "'") {
|
|
883
|
+
inQuote = true;
|
|
884
|
+
quoteChar = ch;
|
|
885
|
+
// Opening quote: don't add to current
|
|
886
|
+
}
|
|
887
|
+
else if (ch === "(") {
|
|
888
|
+
depth++;
|
|
889
|
+
current += ch;
|
|
890
|
+
}
|
|
891
|
+
else if (ch === ")") {
|
|
892
|
+
depth--;
|
|
893
|
+
if (depth === 0) {
|
|
894
|
+
if (current.trim() !== "" || values.length > 0) {
|
|
895
|
+
values.push(current.trim());
|
|
896
|
+
}
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
current += ch;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
else if (ch === "," && depth === 1) {
|
|
904
|
+
values.push(current.trim());
|
|
905
|
+
current = "";
|
|
906
|
+
}
|
|
907
|
+
else {
|
|
908
|
+
current += ch;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
i++;
|
|
912
|
+
}
|
|
913
|
+
return { values, end: i };
|
|
914
|
+
}
|
|
915
|
+
//# sourceMappingURL=parser.js.map
|