sommark 4.5.3 → 5.0.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/core/parser.js CHANGED
@@ -4,12 +4,9 @@
4
4
  import TOKEN_TYPES from "./tokenTypes.js";
5
5
  import peek from "../helpers/peek.js";
6
6
  import { parserError } from "./errors.js";
7
- import { safeDataParse } from "../helpers/safeDataParser.js";
8
7
  import {
9
8
  BLOCK,
10
9
  TEXT,
11
- INLINE,
12
- ATBLOCK,
13
10
  COMMENT,
14
11
  COMMENT_BLOCK,
15
12
  STATIC_LOGIC,
@@ -19,11 +16,6 @@ import {
19
16
  block_id,
20
17
  block_key,
21
18
  block_value,
22
- inline_id,
23
- inline_text,
24
- at_id,
25
- atblock_key,
26
- at_value,
27
19
  end_keyword,
28
20
  SLOT,
29
21
  slot_keyword,
@@ -93,7 +85,7 @@ function validateName(
93
85
  : "must contain only letters, numbers, hyphens, underscores, or dollar signs ($)";
94
86
 
95
87
  if (!keyRegex.test(id)) {
96
- parserError([`{line}<$red:Invalid ${name}:$><$blue: '${id}'$>{N}<$yellow:${name} ${ruleMessage}$> <$cyan: ${rule}.$>{line}`]);
88
+ parserError([`{line}<$red:Invalid ${name}:$><$blue: '${id}'$>{N}<$yellow:${name} ${ruleMessage}$> <$cyan: ${rule}.$>`]);
97
89
  }
98
90
  }
99
91
 
@@ -103,7 +95,7 @@ function makeBlockNode() {
103
95
  type: BLOCK,
104
96
  structure: "Block",
105
97
  id: "",
106
- args: {},
98
+ props: {},
107
99
  body: [],
108
100
  depth: 0,
109
101
  range: {
@@ -138,41 +130,6 @@ function makeCommentNode() {
138
130
  }
139
131
  };
140
132
  }
141
- /** Creates a new empty Inline node. */
142
- function makeInlineNode() {
143
- return {
144
- type: INLINE,
145
- structure: "Inline",
146
- value: "",
147
- id: "",
148
- args: {},
149
- depth: 0,
150
- range: {
151
- start: { line: 0, character: 0 },
152
- end: { line: 0, character: 0 }
153
- }
154
- };
155
- }
156
-
157
- // ========================================================================== //
158
- // Node Creators //
159
- // ========================================================================== //
160
- /** Creates a new empty AtBlock node. */
161
- function makeAtBlockNode() {
162
- return {
163
- type: ATBLOCK,
164
- structure: "AtBlock",
165
- id: "",
166
- args: {},
167
- content: "",
168
- depth: 0,
169
- range: {
170
- start: { line: 0, character: 0 },
171
- end: { line: 0, character: 0 }
172
- }
173
- };
174
- }
175
-
176
133
  /** Creates a new empty Logic node. */
177
134
  function makeLogicNode(type = RUNTIME_LOGIC) {
178
135
  return {
@@ -218,53 +175,66 @@ const updateData = (tokens, i) => {
218
175
 
219
176
  const errorMessage = (tokens, i, expectedValue, behindValue, frontText, filename = null) => {
220
177
  const current = tokens[i] || fallback;
221
- const errorLineNumber = current.range.start.line;
222
- const errorCharNumber = current.range.start.character;
178
+ const errorLine = current.range.start.line;
179
+ const errorColStart = current.range.start.character;
180
+ const errorColEnd = current.range.end.character;
223
181
  const source = current.source || filename;
224
- const sourceLabel = source ? ` [${source}]` : "";
225
182
 
183
+ // Collect all tokens on the error line for the source snippet
226
184
  let lineStartIndex = i;
227
185
  while (
228
186
  lineStartIndex > 0 &&
229
187
  tokens[lineStartIndex - 1] &&
230
- tokens[lineStartIndex - 1].range.start.line === errorLineNumber &&
188
+ tokens[lineStartIndex - 1].range.start.line === errorLine &&
231
189
  (tokens[lineStartIndex - 1].source || filename) === source
232
190
  ) {
233
191
  lineStartIndex--;
234
192
  }
235
-
236
193
  let lineEndIndex = i;
237
194
  while (
238
195
  lineEndIndex < tokens.length - 1 &&
239
196
  tokens[lineEndIndex + 1] &&
240
- tokens[lineEndIndex + 1].range.start.line === errorLineNumber &&
197
+ tokens[lineEndIndex + 1].range.start.line === errorLine &&
241
198
  (tokens[lineEndIndex + 1].source || filename) === source
242
199
  ) {
243
200
  lineEndIndex++;
244
201
  }
245
202
 
246
- // Get all tokens on the error line
247
- const lineTokens = tokens.slice(lineStartIndex, lineEndIndex + 1);
248
- const lineContent = lineTokens.map(t => t.value).join('');
249
-
250
- // Get content on the line before the error token
251
- const tokensBeforeErrorOnLine = tokens.slice(lineStartIndex, i);
252
- const contentBeforeErrorOnLine = tokensBeforeErrorOnLine.map(t => t.value).join('');
253
-
254
- const pointerPadding = " ".repeat(contentBeforeErrorOnLine.length);
255
- const rangeInfo = current.range.start.line === current.range.end.line
256
- ? `from column <$yellow:${current.range.start.character}$> to <$yellow:${current.range.end.character}$>`
257
- : `from line <$yellow:${current.range.start.line + 1}$>, column <$yellow:${current.range.start.character}$> to line <$yellow:${current.range.end.line + 1}$>, column <$yellow:${current.range.end.character}$>`;
258
-
259
- return [
260
- `<$blue:{line}$><$red:Here where error occurred${sourceLabel}:$>{N}${lineContent}{N}${pointerPadding}<$yellow:^$>{N}{N}`,
261
- `<$red:${frontText ? frontText : "Expected token"}$>${!frontText ? " <$blue:'" + expectedValue + "'$>" : ""} ${behindValue ? "after <$blue:'" + behindValue + "'$>" : ""} at line <$yellow:${current.range.start.line + 1}$>,`,
262
- ` ${rangeInfo}`,
263
- `{N}<$yellow:Received:$> <$blue:'${current.value === "\n" ? "\\n' (newline)" : current.value}'$>`,
264
- ` at line <$yellow:${current.range.start.line + 1}$>,`,
265
- ` ${rangeInfo}{N}`,
266
- "<$blue:{line}$>"
267
- ];
203
+ const lineContent = tokens.slice(lineStartIndex, lineEndIndex + 1).map(t => t.value).join('');
204
+ const contentBefore = tokens.slice(lineStartIndex, i).map(t => t.value).join('');
205
+ const pointerPadding = " ".repeat(contentBefore.length);
206
+
207
+ // Location header file, line, column
208
+ const lineNum = errorLine + 1;
209
+ const isMultiLine = current.range.start.line !== current.range.end.line;
210
+ const colDisplay = isMultiLine
211
+ ? `${errorColStart} line ${current.range.end.line + 1} col ${errorColEnd}`
212
+ : errorColStart === errorColEnd ? `${errorColStart}` : `${errorColStart}–${errorColEnd}`;
213
+
214
+ // Error description avoid nested <$color:...$> tags (breaks the non-greedy regex)
215
+ let errorDesc;
216
+ if (frontText) {
217
+ errorDesc = `<$red:${frontText}$>`;
218
+ } else {
219
+ errorDesc = `<$red:Expected$> <$blue:'${expectedValue}'$>`;
220
+ if (behindValue) errorDesc += ` <$red:after$> <$blue:'${behindValue}'$>`;
221
+ }
222
+
223
+ const tokenDisplay = current.value === "" ? "end of input"
224
+ : current.value === "\n" ? "newline (\\n)"
225
+ : `'${current.value}'`;
226
+
227
+ const parts = [`{line}`];
228
+ if (source) parts.push(`<$cyan:File:$> ${source}{N}`);
229
+ parts.push(`<$cyan:Line:$> <$yellow:${lineNum}$> <$cyan:Col:$> <$yellow:${colDisplay}$>{N}`);
230
+ parts.push(`{line}`);
231
+ parts.push(`<$red:Here where error occurred:$>{N}`);
232
+ parts.push(` ${lineContent}{N}`);
233
+ parts.push(` ${pointerPadding}<$yellow:^$>{N}`);
234
+ parts.push(`${errorDesc}{N}`);
235
+ parts.push(`<$yellow:Received:$> <$blue:${tokenDisplay}$>{N}`);
236
+ parts.push(`{line}`);
237
+ return parts;
268
238
  };
269
239
  // ========================================================================== //
270
240
  // Parse Key //
@@ -286,6 +256,88 @@ function parseKey(tokens, i) {
286
256
  return [key, i];
287
257
  }
288
258
  // ========================================================================== //
259
+ // Read Prefix Key/Fallback from structured p{}/v{} tokens //
260
+ // ========================================================================== //
261
+ function readPrefixKeyFallback(tokens, i, prefixType = "p") {
262
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.PREFIX_OPEN) i++;
263
+ i = skipJunk(tokens, i);
264
+
265
+ let key = "";
266
+ let fallback = undefined;
267
+
268
+ // Read key — must be quoted or unquoted identifier
269
+ const keyToken = current_token(tokens, i);
270
+ if (!keyToken || keyToken.type === TOKEN_TYPES.PREFIX_CLOSE) {
271
+ parserError(errorMessage(tokens, i, "key", "{", 'Prefix requires a key — write p{key} or p{key | "fallback"}'));
272
+ }
273
+ if (keyToken.type === TOKEN_TYPES.QUOTE) {
274
+ i++; // skip opening QUOTE
275
+ while (current_token(tokens, i) &&
276
+ current_token(tokens, i).type !== TOKEN_TYPES.QUOTE &&
277
+ current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_CLOSE &&
278
+ current_token(tokens, i).type !== TOKEN_TYPES.PIPELINE) {
279
+ key += current_token(tokens, i).value;
280
+ i++;
281
+ }
282
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.QUOTE) i++;
283
+ } else if (keyToken.type === TOKEN_TYPES.KEY) {
284
+ key = keyToken.value.trim();
285
+ const isValidIdent = /^[a-zA-Z_$][a-zA-Z0-9_$-]*$/.test(key);
286
+ const isNumeric = /^\d+$/.test(key);
287
+ // p{} keys must be identifiers; v{} keys may also be positional integers
288
+ if (!isValidIdent && !(prefixType === "v" && isNumeric)) {
289
+ parserError(errorMessage(tokens, i, "key", "{", `Invalid prefix key '${key}' — must start with a letter, _ or $`));
290
+ }
291
+ i++;
292
+ } else {
293
+ parserError(errorMessage(tokens, i, "key", "{", "Invalid prefix key — must be a quoted string or identifier"));
294
+ }
295
+
296
+ i = skipJunk(tokens, i);
297
+
298
+ // After key: only | or } is valid
299
+ const afterKey = current_token(tokens, i);
300
+ if (!afterKey || (afterKey.type !== TOKEN_TYPES.PIPELINE && afterKey.type !== TOKEN_TYPES.PREFIX_CLOSE)) {
301
+ parserError(errorMessage(tokens, i, "| or }", key, "Expected '|' or '}' after prefix key"));
302
+ }
303
+
304
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.PIPELINE) {
305
+ i++; // skip PIPELINE
306
+ i = skipJunk(tokens, i);
307
+
308
+ // Fallback must be a quoted string — any content allowed inside quotes
309
+ const fallbackToken = current_token(tokens, i);
310
+ if (!fallbackToken || fallbackToken.type === TOKEN_TYPES.PREFIX_CLOSE) {
311
+ parserError(errorMessage(tokens, i, '"fallback"', "|", 'Expected a quoted fallback after \'|\' — write p{key | "default"}'));
312
+ }
313
+ if (fallbackToken.type === TOKEN_TYPES.QUOTE) {
314
+ fallback = "";
315
+ i++; // skip opening QUOTE
316
+ while (current_token(tokens, i) &&
317
+ current_token(tokens, i).type !== TOKEN_TYPES.QUOTE &&
318
+ current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_CLOSE) {
319
+ fallback += current_token(tokens, i).value;
320
+ i++;
321
+ }
322
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.QUOTE) i++;
323
+ } else {
324
+ parserError(errorMessage(tokens, i, '"fallback"', "|", 'Fallback must be a quoted string — write p{key | "default"}'));
325
+ }
326
+ }
327
+
328
+ i = skipJunk(tokens, i);
329
+
330
+ // After key (or fallback): only } is valid
331
+ const afterFallback = current_token(tokens, i);
332
+ if (!afterFallback || afterFallback.type !== TOKEN_TYPES.PREFIX_CLOSE) {
333
+ parserError(errorMessage(tokens, i, "}", key, "Unexpected content inside prefix — only one key and one optional fallback are allowed"));
334
+ }
335
+
336
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.PREFIX_CLOSE) i++;
337
+
338
+ return [key, fallback, i];
339
+ }
340
+ // ========================================================================== //
289
341
  // Parse Value //
290
342
  // ========================================================================== //
291
343
  function parseValue(tokens, i, placeholders = {}, variables = {}, allowLogic = true) {
@@ -296,7 +348,7 @@ function parseValue(tokens, i, placeholders = {}, variables = {}, allowLogic = t
296
348
  val = "";
297
349
  while (i < tokens.length && current_token(tokens, i).type !== TOKEN_TYPES.QUOTE) {
298
350
  const token = current_token(tokens, i);
299
- if (token.type === TOKEN_TYPES.PREFIX_P || token.type === TOKEN_TYPES.PREFIX_JS || token.type === TOKEN_TYPES.PREFIX_V) {
351
+ if (token.type === TOKEN_TYPES.PREFIX_P || token.type === TOKEN_TYPES.PREFIX_V) {
300
352
  const [resolvedVal, nextI] = parseValue(tokens, i, placeholders, variables, allowLogic);
301
353
  val += resolvedVal;
302
354
  i = nextI;
@@ -312,72 +364,55 @@ function parseValue(tokens, i, placeholders = {}, variables = {}, allowLogic = t
312
364
 
313
365
  i++; // consume closing QUOTE
314
366
  return [val, i, true];
315
- } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_JS) {
316
- val = current_token(tokens, i).value;
317
- // V4 NATIVE DATA: Strip js{ } and parse safely
318
- if (val.startsWith("js{") && val.endsWith("}")) {
319
- const clean = val.slice(3, -1).trim();
320
- val = safeDataParse(clean);
321
- }
322
- i++;
323
- return [val, i, false];
324
- } else if (current_token(tokens, i).type === TOKEN_TYPES.LOGIC || current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD || current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD) {
367
+ } else if (current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD || current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD) {
325
368
  if (!allowLogic) {
326
369
  parserError(errorMessage(tokens, i, "literal value", "", "Logic blocks are not allowed in this context."));
327
370
  }
328
371
  let isStatic = current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD;
329
- let isRuntimeKeyword = current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD;
330
- let nextI = i;
331
-
332
- if (isStatic || isRuntimeKeyword) {
333
- nextI = skipJunk(tokens, i + 1);
334
- if (!current_token(tokens, nextI) || current_token(tokens, nextI).type !== TOKEN_TYPES.LOGIC) {
335
- // Treat as literal text if keyword is not followed by a logic block
336
- return [current_token(tokens, i).value, i + 1, false];
337
- }
338
- i = nextI;
372
+ let nextI = skipJunk(tokens, i + 1);
373
+
374
+ if (!current_token(tokens, nextI) || current_token(tokens, nextI).type !== TOKEN_TYPES.LOGIC_OPEN) {
375
+ // Keyword not followed by ${ — treat as literal text
376
+ return [current_token(tokens, i).value, i + 1, false];
339
377
  }
340
378
 
341
- const logicToken = current_token(tokens, i);
379
+ // Skip LOGIC_OPEN, read LOGIC body
380
+ nextI++;
381
+ const logicToken = current_token(tokens, nextI);
342
382
  const node = makeLogicNode(isStatic ? STATIC_LOGIC : RUNTIME_LOGIC);
343
- node.code = logicToken.value;
344
- node.range = logicToken.range;
383
+ node.code = logicToken ? logicToken.value : "";
384
+ node.range = logicToken ? logicToken.range : current_token(tokens, i).range;
385
+ nextI++;
386
+
387
+ // Consume LOGIC_CLOSE if present
388
+ if (current_token(tokens, nextI) && current_token(tokens, nextI).type === TOKEN_TYPES.LOGIC_CLOSE) {
389
+ nextI++;
390
+ }
345
391
 
346
- return [node, i + 1, false];
392
+ return [node, nextI, false];
347
393
  } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_V) {
348
- val = current_token(tokens, i).value;
349
- // V4.1.0 VARIABLE: Strip v{ } and resolve from local variables
350
- if (val.startsWith("v{") && val.endsWith("}")) {
351
- const key = val.slice(2, -1).trim();
352
- if (variables[key] !== undefined) {
353
- val = variables[key];
354
- if (!variables.__consumed__) {
355
- Object.defineProperty(variables, "__consumed__", {
356
- value: new Set(),
357
- enumerable: false,
358
- configurable: true
359
- });
360
- }
361
- variables.__consumed__.add(key);
362
- } else {
363
- val = getPrefixValue('v', key);
394
+ i++; // consume PREFIX_V keyword
395
+ const [vKey, vFallback, vNextI] = readPrefixKeyFallback(tokens, i, "v");
396
+ i = vNextI;
397
+ if (variables[vKey] !== undefined) {
398
+ val = variables[vKey];
399
+ if (!variables.__consumed__) {
400
+ Object.defineProperty(variables, "__consumed__", {
401
+ value: new Set(),
402
+ enumerable: false,
403
+ configurable: true
404
+ });
364
405
  }
406
+ variables.__consumed__.add(vKey);
407
+ } else {
408
+ val = vFallback !== undefined ? vFallback : getPrefixValue('v', vKey);
365
409
  }
366
- i++;
367
- return [val, i, false];
368
- } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_C) {
369
- val = current_token(tokens, i).value;
370
- // PREFIX_C is preserved for the resolveModules expansion phase
371
- i++;
372
410
  return [val, i, false];
373
411
  } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_P) {
374
- val = current_token(tokens, i).value;
375
- // V4 PLACEHOLDER: Strip p{ } and resolve from config
376
- if (val.startsWith("p{") && val.endsWith("}")) {
377
- const key = val.slice(2, -1).trim();
378
- val = placeholders[key] !== undefined ? placeholders[key] : getPrefixValue('p', key);
379
- }
380
- i++;
412
+ i++; // consume PREFIX_P keyword
413
+ const [pKey, pFallback, pNextI] = readPrefixKeyFallback(tokens, i);
414
+ i = pNextI;
415
+ val = placeholders[pKey] !== undefined ? placeholders[pKey] : (pFallback !== undefined ? pFallback : getPrefixValue('p', pKey));
381
416
  return [val, i, false];
382
417
  } else {
383
418
  val = "";
@@ -390,9 +425,7 @@ function parseValue(tokens, i, placeholders = {}, variables = {}, allowLogic = t
390
425
  token.type === TOKEN_TYPES.COMMA ||
391
426
  token.type === TOKEN_TYPES.CLOSE_BRACKET ||
392
427
  token.type === TOKEN_TYPES.COLON ||
393
- token.type === TOKEN_TYPES.SEMICOLON ||
394
- token.type === TOKEN_TYPES.EXCLAMATION_MARK ||
395
- token.type === TOKEN_TYPES.CLOSE_PAREN) break;
428
+ token.type === TOKEN_TYPES.EXCLAMATION_MARK) break;
396
429
 
397
430
  if (token.type === TOKEN_TYPES.ESCAPE) {
398
431
  // Remove backslash
@@ -425,7 +458,6 @@ function parseComma(tokens, i, beforeChar = "") {
425
458
  current_token(tokens, i).type !== TOKEN_TYPES.IDENTIFIER &&
426
459
  current_token(tokens, i).type !== TOKEN_TYPES.KEY &&
427
460
  current_token(tokens, i).type !== TOKEN_TYPES.QUOTE &&
428
- current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_JS &&
429
461
  current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_P)
430
462
  ) {
431
463
  parserError(errorMessage(tokens, i, "value", ","));
@@ -460,19 +492,6 @@ function parseColon(tokens, i, afterChar = "") {
460
492
  updateData(tokens, i);
461
493
  return i;
462
494
  }
463
- // ========================================================================== //
464
- // Parse ';' //
465
- // ========================================================================== //
466
- function parseSemiColon(tokens, i, afterChar = "") {
467
- i = skipJunk(tokens, i);
468
- if (!current_token(tokens, i) || (current_token(tokens, i) && current_token(tokens, i).type !== TOKEN_TYPES.SEMICOLON)) {
469
- parserError(errorMessage(tokens, i, ";", afterChar));
470
- }
471
- i++;
472
- i = skipJunk(tokens, i);
473
- updateData(tokens, i);
474
- return i;
475
- }
476
495
  /**
477
496
  * Parses a standard SomMark Block ([id] ... [end]).
478
497
  * Blocks are structural elements that can contain nested content.
@@ -495,7 +514,7 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
495
514
  updateData(tokens, i);
496
515
 
497
516
  const idToken = current_token(tokens, i);
498
- if (!idToken || idToken.type === TOKEN_TYPES.EOF) {
517
+ if (!idToken || idToken.type === TOKEN_TYPES.EOF || idToken.type === TOKEN_TYPES.CLOSE_BRACKET) {
499
518
  parserError(errorMessage(tokens, i, "Block ID", "[", "Missing Block Identifier"));
500
519
  }
501
520
  const id = idToken.value;
@@ -547,10 +566,9 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
547
566
  current_token(tokens, i).type !== TOKEN_TYPES.END_KEYWORD &&
548
567
  current_token(tokens, i).type !== TOKEN_TYPES.KEY &&
549
568
  current_token(tokens, i).type !== TOKEN_TYPES.QUOTE &&
550
- current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_JS &&
551
569
  current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_V &&
552
570
  current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_P &&
553
- current_token(tokens, i).type !== TOKEN_TYPES.LOGIC &&
571
+ current_token(tokens, i).type !== TOKEN_TYPES.LOGIC_OPEN &&
554
572
  current_token(tokens, i).type !== TOKEN_TYPES.STATIC_KEYWORD &&
555
573
  current_token(tokens, i).type !== TOKEN_TYPES.RUNTIME_KEYWORD)
556
574
  ) {
@@ -597,9 +615,9 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
597
615
  i = valueIndex;
598
616
 
599
617
  // Store Argument
600
- blockNode.args[String(argIndex++)] = v;
618
+ blockNode.props[String(argIndex++)] = v;
601
619
  if (k) {
602
- blockNode.args[k] = v;
620
+ blockNode.props[k] = v;
603
621
  }
604
622
  k = "";
605
623
  v = "";
@@ -700,6 +718,23 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
700
718
  i++;
701
719
  i = skipJunk(tokens, i);
702
720
  updateData(tokens, i);
721
+
722
+ // Named closing: [end:blockname] — the lexer emits END_KEYWORD "end:name" as one
723
+ // token because ':' is stripped from stop chars at block-start (XML namespace support).
724
+ const endValue = current.value.trim();
725
+ if (endValue.includes(":")) {
726
+ const closingName = endValue.slice(endValue.indexOf(":") + 1);
727
+ if (!closingName) {
728
+ parserError(errorMessage(tokens, i - 1, "block name", "", "Missing block name — write [end:blockname] to name the closing tag"));
729
+ }
730
+ const expected = end_stack[end_stack.length - 1];
731
+ if (expected && closingName !== expected.id) {
732
+ parserError(errorMessage(tokens, i - 1, closingName, "",
733
+ `Mismatched closing tag: [end:${closingName}] cannot close '${closingName}' — '${expected.id}' is still open (opened at line ${expected.line}, col ${expected.col})`
734
+ ));
735
+ }
736
+ }
737
+
703
738
  if (
704
739
  !current_token(tokens, i) ||
705
740
  (current_token(tokens, i) && current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_BRACKET)
@@ -734,147 +769,6 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
734
769
  }
735
770
  return [blockNode, i];
736
771
  }
737
- /**
738
- * Parses an Inline Statement ((content) -> (id)).
739
- * Inlines are fast, non-nesting formatting elements.
740
- *
741
- * @param {Object[]} tokens - Token stream.
742
- * @param {number} i - Initial index.
743
- * @param {Object} placeholders - Dynamic public API data.
744
- * @returns {[Object, number]} The parsed Inline node and new index.
745
- */
746
- function parseInline(tokens, i, placeholders = {}, depth = 0) {
747
- const inlineNode = makeInlineNode();
748
- inlineNode.depth = depth;
749
- const openParenToken = current_token(tokens, i);
750
- inlineNode.range.start = openParenToken.range.start;
751
-
752
- // consume '('
753
- i++;
754
- updateData(tokens, i);
755
-
756
- // Phase 1: Content capture (Lexer provides high-level TEXT/ESCAPE tokens here)
757
- while (i < tokens.length) {
758
- const token = current_token(tokens, i);
759
- if (!token || token.type === TOKEN_TYPES.CLOSE_PAREN) break;
760
-
761
- if (token.type === TOKEN_TYPES.ESCAPE) {
762
- inlineNode.value += token.value.slice(1);
763
- } else if (token.type !== TOKEN_TYPES.COMMENT) {
764
- inlineNode.value += token.value;
765
- }
766
- i++;
767
- }
768
-
769
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_PAREN) {
770
- parserError(errorMessage(tokens, i, ")", "inline content"));
771
- }
772
- i++; // consume ')'
773
-
774
- // Collapse newlines and whitespace for "inline" behavior
775
- inlineNode.value = inlineNode.value.replace(/\s+/g, " ").trim();
776
-
777
- i = skipJunk(tokens, i);
778
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.THIN_ARROW) {
779
- parserError(errorMessage(tokens, i, "->", ")"));
780
- }
781
- i++; // consume '->'
782
-
783
- i = skipJunk(tokens, i);
784
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.OPEN_PAREN) {
785
- parserError(errorMessage(tokens, i, "(", "->"));
786
- }
787
- i++; // consume '('
788
- i = skipJunk(tokens, i);
789
- const idToken = current_token(tokens, i);
790
- const allowedInlineIdTypes = new Set([
791
- TOKEN_TYPES.IDENTIFIER,
792
- TOKEN_TYPES.KEY,
793
- TOKEN_TYPES.IMPORT,
794
- TOKEN_TYPES.USE_MODULE,
795
- TOKEN_TYPES.SLOT_KEYWORD,
796
- TOKEN_TYPES.FOR_EACH
797
- ]);
798
- if (!idToken || !allowedInlineIdTypes.has(idToken.type)) {
799
- parserError(errorMessage(tokens, i, inline_id, "("));
800
- }
801
- inlineNode.id = idToken.value.trim();
802
- validateName(inlineNode.id);
803
-
804
- i++; // consume ID
805
- i = skipJunk(tokens, i);
806
-
807
- const hasArgsTrigger = current_token(tokens, i) && (
808
- current_token(tokens, i).type === TOKEN_TYPES.COLON ||
809
- current_token(tokens, i).type === TOKEN_TYPES.EQUAL
810
- );
811
-
812
- if (hasArgsTrigger) {
813
- const separator = current_token(tokens, i).value;
814
- i++; // consume ':' or '='
815
- i = skipJunk(tokens, i);
816
-
817
- // Ensure there is a value after the separator
818
- const nextToken = current_token(tokens, i);
819
- if (!nextToken || nextToken.type === TOKEN_TYPES.CLOSE_PAREN || nextToken.type === TOKEN_TYPES.COMMA) {
820
- parserError(errorMessage(tokens, i, inline_value, separator, `Missing value after ${separator === "=" ? "equals" : "colon"}`));
821
- }
822
-
823
- let k = "";
824
- let v = "";
825
- let argIndex = 0;
826
-
827
- while (i < tokens.length) {
828
- i = skipJunk(tokens, i);
829
- const token = current_token(tokens, i);
830
- if (!token || token.type === TOKEN_TYPES.CLOSE_PAREN) break;
831
-
832
- if (token.type === TOKEN_TYPES.KEY) {
833
- let [key, keyIndex] = parseKey(tokens, i);
834
- k = key;
835
- i = keyIndex;
836
- i = skipJunk(tokens, i);
837
- i = parseColon(tokens, i, "inline argument");
838
- i = skipJunk(tokens, i);
839
-
840
- // Ensure there is a value after the colon
841
- const nextToken = current_token(tokens, i);
842
- if (!nextToken || nextToken.type === TOKEN_TYPES.CLOSE_PAREN || nextToken.type === TOKEN_TYPES.COMMA) {
843
- parserError(errorMessage(tokens, i, inline_value, ":", "Missing value after colon"));
844
- }
845
- validateName(k);
846
- }
847
-
848
- let [value, valueIndex, isQuoted] = parseValue(tokens, i, placeholders, {}, false);
849
- v = value;
850
- i = valueIndex;
851
-
852
- inlineNode.args[String(argIndex++)] = v;
853
- if (k) {
854
- inlineNode.args[k] = v;
855
- }
856
- k = "";
857
- v = "";
858
-
859
- i = skipJunk(tokens, i);
860
- if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.COMMA) {
861
- i = parseComma(tokens, i, "inline argument");
862
- } else {
863
- break;
864
- }
865
- }
866
- }
867
-
868
- i = skipJunk(tokens, i);
869
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_PAREN) {
870
- parserError(errorMessage(tokens, i, ")", inlineNode.id));
871
- }
872
- const finalParenToken = current_token(tokens, i);
873
- i++; // consume ')'
874
- inlineNode.range.end = finalParenToken.range.end;
875
-
876
- return [inlineNode, i];
877
- }
878
772
  /**
879
773
  * Parses a stream of text tokens into a single Text node.
880
774
  * Handles unescaping and placeholder resolution.
@@ -902,7 +796,7 @@ function parseText(tokens, i, placeholders = {}, variables = {}, depth = 0, opti
902
796
  i++;
903
797
  } else if (token.type === TOKEN_TYPES.STATIC_KEYWORD || token.type === TOKEN_TYPES.RUNTIME_KEYWORD) {
904
798
  const nextIdx = skipJunk(tokens, i + 1);
905
- if (tokens[nextIdx] && tokens[nextIdx].type === TOKEN_TYPES.LOGIC) {
799
+ if (tokens[nextIdx] && tokens[nextIdx].type === TOKEN_TYPES.LOGIC_OPEN) {
906
800
  // Stop consuming text; this is the start of a logic block
907
801
  break;
908
802
  }
@@ -921,44 +815,31 @@ function parseText(tokens, i, placeholders = {}, variables = {}, depth = 0, opti
921
815
  }
922
816
  i++;
923
817
  } else if (token.type === TOKEN_TYPES.PREFIX_P) {
924
- const val = token.value;
925
- if (val.startsWith("p{") && val.endsWith("}")) {
926
- const match = [val.slice(2, -1).trim(), val, 'p'];
927
- const key = match[0];
928
- const layer = match[2]; // 'p' or 'v'
929
-
930
- if (placeholders[key] !== undefined) {
931
- textNode.text += String(placeholders[key]);
932
- } else {
933
- // Use the unique 'Unresolved Envelope' format via helper
934
- textNode.text += getPrefixValue(layer, key);
935
- }
818
+ i++; // consume PREFIX_P keyword
819
+ const [tpKey, tpFallback, tpNextI] = readPrefixKeyFallback(tokens, i);
820
+ i = tpNextI;
821
+ if (placeholders[tpKey] !== undefined) {
822
+ textNode.text += String(placeholders[tpKey]);
936
823
  } else {
937
- textNode.text += val;
824
+ textNode.text += tpFallback !== undefined ? tpFallback : getPrefixValue('p', tpKey);
938
825
  }
939
- i++;
940
826
  } else if (token.type === TOKEN_TYPES.PREFIX_V) {
941
- const val = token.value;
942
- if (val.startsWith("v{") && val.endsWith("}")) {
943
- const key = val.slice(2, -1).trim();
944
- if (variables[key] !== undefined) {
945
- textNode.text += String(variables[key]);
946
- if (!variables.__consumed__) {
947
- Object.defineProperty(variables, "__consumed__", {
948
- value: new Set(),
949
- enumerable: false,
950
- configurable: true
951
- });
952
- }
953
- variables.__consumed__.add(key);
954
- } else {
955
- // Use the unique 'Unresolved Envelope' format via helper
956
- textNode.text += getPrefixValue('v', key);
827
+ i++; // consume PREFIX_V keyword
828
+ const [tvKey, tvFallback, tvNextI] = readPrefixKeyFallback(tokens, i, "v");
829
+ i = tvNextI;
830
+ if (variables[tvKey] !== undefined) {
831
+ textNode.text += String(variables[tvKey]);
832
+ if (!variables.__consumed__) {
833
+ Object.defineProperty(variables, "__consumed__", {
834
+ value: new Set(),
835
+ enumerable: false,
836
+ configurable: true
837
+ });
957
838
  }
839
+ variables.__consumed__.add(tvKey);
958
840
  } else {
959
- textNode.text += val;
841
+ textNode.text += tvFallback !== undefined ? tvFallback : getPrefixValue('v', tvKey);
960
842
  }
961
- i++;
962
843
  } else {
963
844
  break;
964
845
  }
@@ -968,155 +849,6 @@ function parseText(tokens, i, placeholders = {}, variables = {}, depth = 0, opti
968
849
  }
969
850
  return [textNode, i];
970
851
  }
971
- /**
972
- * Parses an At-Block (@_id_@: args; content @_end_@).
973
- * At-Blocks maintain raw content preservation.
974
- *
975
- * @param {Object[]} tokens - Token stream.
976
- * @param {number} i - Initial index.
977
- * @param {string|null} filename - Source filename.
978
- * @param {Object} placeholders - Dynamic public API data.
979
- * @returns {[Object, number]} The At-Block node and new index.
980
- */
981
- function parseAtBlock(tokens, i, filename = null, placeholders = {}, depth = 0) {
982
- const atBlockNode = makeAtBlockNode();
983
- atBlockNode.depth = depth;
984
- const openAtToken = current_token(tokens, i);
985
- atBlockNode.range.start = openAtToken.range.start;
986
-
987
- // consume '@_'
988
- i++;
989
- i = skipJunk(tokens, i);
990
- updateData(tokens, i);
991
-
992
- const idToken = current_token(tokens, i);
993
- if (!idToken || idToken.type === TOKEN_TYPES.EOF) {
994
- parserError(errorMessage(tokens, i, "AtBlock ID", "@_", "Missing AtBlock Identifier"));
995
- }
996
-
997
- const id = idToken.value;
998
- if (id.trim() === end_keyword) {
999
- parserError(errorMessage(tokens, i, id, "", `'${id.trim()}' is a reserved keyword and cannot be used as an identifier.`));
1000
- }
1001
-
1002
- atBlockNode.id = id.trim();
1003
- validateName(atBlockNode.id);
1004
-
1005
- // consume ID
1006
- i++;
1007
- i = skipJunk(tokens, i);
1008
- updateData(tokens, i);
1009
-
1010
- if (!current_token(tokens, i) || (current_token(tokens, i) && current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_AT)) {
1011
- parserError(errorMessage(tokens, i, "_@", "at-block identifier"));
1012
- }
1013
- // consume '_@'
1014
- i++;
1015
- i = skipJunk(tokens, i);
1016
- updateData(tokens, i);
1017
-
1018
- if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.COLON) {
1019
- // consume ':'
1020
- i++;
1021
- i = skipJunk(tokens, i);
1022
-
1023
- // Ensure there is a value after the colon
1024
- const nextToken = current_token(tokens, i);
1025
- if (!nextToken || nextToken.type === TOKEN_TYPES.SEMICOLON || nextToken.type === TOKEN_TYPES.COMMA) {
1026
- parserError(errorMessage(tokens, i, at_value, ":", "Missing value after colon"));
1027
- }
1028
-
1029
- let k = "";
1030
- let v = "";
1031
- let argIndex = 0;
1032
-
1033
- while (i < tokens.length) {
1034
- i = skipJunk(tokens, i);
1035
- const token = current_token(tokens, i);
1036
- if (!token || token.type === TOKEN_TYPES.SEMICOLON) break;
1037
-
1038
- const isQuotedKey = token.type === TOKEN_TYPES.QUOTE && peek(tokens, i, 1) && (peek(tokens, i, 1).type === TOKEN_TYPES.KEY);
1039
-
1040
- if (token.type === TOKEN_TYPES.KEY || isQuotedKey) {
1041
- let [key, keyIndex] = parseKey(tokens, i);
1042
- k = key;
1043
- i = keyIndex;
1044
- i = skipJunk(tokens, i);
1045
- i = parseColon(tokens, i, "at-block argument");
1046
- i = skipJunk(tokens, i);
1047
-
1048
- // Ensure there is a value after the colon
1049
- const nextToken = current_token(tokens, i);
1050
- if (!nextToken || nextToken.type === TOKEN_TYPES.SEMICOLON || nextToken.type === TOKEN_TYPES.COMMA) {
1051
- parserError(errorMessage(tokens, i, at_value, ":", "Missing value after colon"));
1052
- }
1053
-
1054
- if (token.type === TOKEN_TYPES.KEY) {
1055
- validateName(k);
1056
- }
1057
- }
1058
-
1059
- let [value, valueIndex, isQuoted] = parseValue(tokens, i, placeholders, {}, false);
1060
- v = value;
1061
- i = valueIndex;
1062
-
1063
- atBlockNode.args[String(argIndex++)] = v;
1064
- if (k) {
1065
- atBlockNode.args[k] = v;
1066
- }
1067
- k = "";
1068
- v = "";
1069
-
1070
- i = skipJunk(tokens, i);
1071
- if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.COMMA) {
1072
- i = parseComma(tokens, i, "at-block argument");
1073
- } else {
1074
- break;
1075
- }
1076
- }
1077
- }
1078
-
1079
- // Semicolon is ALWAYS required after ID or ARGS
1080
- i = parseSemiColon(tokens, i, "at-block header");
1081
-
1082
- // Body Capture
1083
- i = skipJunk(tokens, i);
1084
- if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.TEXT) {
1085
- atBlockNode.content = current_token(tokens, i).value;
1086
- i++;
1087
- } else {
1088
- parserError(errorMessage(tokens, i, "content", "at-block body"));
1089
- }
1090
-
1091
- // End Marker (@_end_@)
1092
- i = skipJunk(tokens, i);
1093
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.OPEN_AT) {
1094
- parserError(errorMessage(tokens, i, "@_", "at-block content"));
1095
- }
1096
- i++; // consume '@_'
1097
- i = skipJunk(tokens, i);
1098
- const endToken = current_token(tokens, i);
1099
- if (!endToken || (endToken.type !== TOKEN_TYPES.END_KEYWORD && endToken.value.trim() !== end_keyword)) {
1100
- let extraInfo = "";
1101
- if (endToken && endToken.value) {
1102
- const dist = levenshtein(endToken.value.trim().toLowerCase(), "end");
1103
- if (dist > 0 && dist <= 2) {
1104
- extraInfo = ` (Did you mean '@_end_@'?)`;
1105
- }
1106
- }
1107
- parserError(errorMessage(tokens, i, "end", "AtBlock Body", extraInfo));
1108
- }
1109
- i++; // consume 'end'
1110
- i = skipJunk(tokens, i);
1111
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_AT) {
1112
- parserError(errorMessage(tokens, i, "_@", "end marker"));
1113
- }
1114
- const closeAtToken = current_token(tokens, i);
1115
- i++; // consume '_@'
1116
- atBlockNode.range.end = closeAtToken.range.end;
1117
-
1118
- return [atBlockNode, i];
1119
- }
1120
852
  // ========================================================================== //
1121
853
  // Parse Comments //
1122
854
  // ========================================================================== //
@@ -1172,73 +904,38 @@ function parseNode(tokens, i, filename = null, placeholders = {}, variables = {}
1172
904
  return parseBlock(tokens, i, filename, placeholders, variables, depth);
1173
905
  }
1174
906
  // ========================================================================== //
1175
- // Inline Statement or Text //
1176
- // ========================================================================== //
1177
- else if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.OPEN_PAREN) {
1178
- let j = i + 1;
1179
- let parenCount = 1;
1180
- let foundArrow = false;
1181
- while (j < tokens.length) {
1182
- const token = tokens[j];
1183
- if (token.type === TOKEN_TYPES.OPEN_PAREN) {
1184
- parenCount++;
1185
- } else if (token.type === TOKEN_TYPES.CLOSE_PAREN) {
1186
- parenCount--;
1187
- }
1188
-
1189
- if (parenCount === 0) {
1190
- const nextIdx = skipJunk(tokens, j + 1);
1191
- if (tokens[nextIdx] && tokens[nextIdx].type === TOKEN_TYPES.THIN_ARROW) {
1192
- foundArrow = true;
1193
- }
1194
- break;
1195
- }
1196
- // Safe-guard: If we hit a [ or @, it's highly unlikely to be an inline statement content
1197
- // unless it's escaped, but lexer already handles [ and @ as structural tokens if not escaped.
1198
- if (token.type === TOKEN_TYPES.OPEN_BRACKET || token.type === TOKEN_TYPES.OPEN_AT) break;
1199
- j++;
1200
- }
1201
-
1202
- if (foundArrow) {
1203
- return parseInline(tokens, i, placeholders, depth);
1204
- }
1205
-
1206
- // Treat as text if not an inline
1207
- const textNode = makeTextNode();
1208
- textNode.text = current_token(tokens, i).value;
1209
- textNode.depth = depth;
1210
- textNode.range = current_token(tokens, i).range;
1211
- return [textNode, i + 1];
1212
- }
1213
- // ========================================================================== //
1214
907
  // Logic Block //
1215
908
  // ========================================================================== //
1216
- else if (current_token(tokens, i) && (current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD || current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD || current_token(tokens, i).type === TOKEN_TYPES.LOGIC)) {
909
+ else if (current_token(tokens, i) && (current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD || current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD)) {
1217
910
  let isStatic = current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD;
1218
- let isRuntimeKeyword = current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD;
1219
911
  let startRange = current_token(tokens, i).range;
1220
- let nextI = i;
1221
-
1222
- if (isStatic || isRuntimeKeyword) {
1223
- if (isStatic) global_static_logic_count++;
1224
- nextI = skipJunk(tokens, i + 1);
1225
- if (!current_token(tokens, nextI) || current_token(tokens, nextI).type !== TOKEN_TYPES.LOGIC) {
1226
- // Treat as normal text if keyword is not followed by a logic block
1227
- return parseText(tokens, i, placeholders, variables, depth);
1228
- }
1229
- i = nextI;
912
+ let nextI = skipJunk(tokens, i + 1);
913
+
914
+ if (!current_token(tokens, nextI) || current_token(tokens, nextI).type !== TOKEN_TYPES.LOGIC_OPEN) {
915
+ // Keyword not followed by ${ — treat as normal text
916
+ return parseText(tokens, i, placeholders, variables, depth);
1230
917
  }
1231
918
 
1232
- const logicToken = current_token(tokens, i);
919
+ if (isStatic) global_static_logic_count++;
920
+
921
+ // Skip LOGIC_OPEN, read LOGIC body
922
+ nextI++;
923
+ const logicToken = current_token(tokens, nextI);
1233
924
  const node = makeLogicNode(isStatic ? STATIC_LOGIC : RUNTIME_LOGIC);
1234
- node.code = logicToken.value;
925
+ node.code = logicToken ? logicToken.value : "";
1235
926
  node.depth = depth;
1236
927
  node.range = {
1237
- start: (isStatic || isRuntimeKeyword) ? startRange.start : logicToken.range.start,
1238
- end: logicToken.range.end
928
+ start: startRange.start,
929
+ end: logicToken ? logicToken.range.end : startRange.end
1239
930
  };
931
+ nextI++;
1240
932
 
1241
- return [node, i + 1];
933
+ // Consume LOGIC_CLOSE if present
934
+ if (current_token(tokens, nextI) && current_token(tokens, nextI).type === TOKEN_TYPES.LOGIC_CLOSE) {
935
+ nextI++;
936
+ }
937
+
938
+ return [node, nextI];
1242
939
  }
1243
940
  // ========================================================================== //
1244
941
  // Text or Placeholder //
@@ -1253,12 +950,6 @@ function parseNode(tokens, i, filename = null, placeholders = {}, variables = {}
1253
950
  current_token(tokens, i).type === TOKEN_TYPES.PREFIX_P)
1254
951
  ) {
1255
952
  return parseText(tokens, i, placeholders, variables, depth);
1256
- }
1257
- // ========================================================================== //
1258
- // Atblock //
1259
- // ========================================================================== //
1260
- else if (current_token(tokens, i) && (current_token(tokens, i).type === TOKEN_TYPES.OPEN_AT)) {
1261
- return parseAtBlock(tokens, i, filename, placeholders, depth);
1262
953
  } else {
1263
954
  // FALLBACK: Treat any other token as TEXT to avoid infinite loops and allow literal content
1264
955
  const textNode = makeTextNode();
@@ -1313,7 +1004,7 @@ function parser(tokens, filename = null, placeholders = {}, variables = {}) {
1313
1004
  const val = token.value.trim().toLowerCase();
1314
1005
  if (val === "") return "";
1315
1006
  const dist = levenshtein(val, "end");
1316
- if (dist > 0 && dist <= 2) return ` (Did you mean <$cyan:'[end]'$>?)`;
1007
+ if (dist > 0 && dist <= 2) return ` Did you mean '[end]'?`;
1317
1008
  }
1318
1009
  return "";
1319
1010
  };