sommark 4.5.3 → 5.0.1

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.
Files changed (41) hide show
  1. package/README.md +315 -179
  2. package/cli/cli.mjs +1 -1
  3. package/cli/commands/color.js +36 -14
  4. package/cli/commands/help.js +3 -0
  5. package/cli/commands/init.js +1 -3
  6. package/cli/constants.js +5 -2
  7. package/constants/html_props.js +0 -5
  8. package/core/errors.js +5 -4
  9. package/core/evaluator.js +1 -2
  10. package/core/formats.js +7 -1
  11. package/core/helpers/config-loader.js +2 -4
  12. package/core/helpers/lib.js +1 -1
  13. package/core/labels.js +2 -15
  14. package/core/lexer.js +197 -313
  15. package/core/modules.js +13 -13
  16. package/core/parser.js +226 -535
  17. package/core/tokenTypes.js +6 -15
  18. package/core/transpiler.js +129 -110
  19. package/core/validator.js +6 -26
  20. package/dist/sommark.browser.js +1781 -2172
  21. package/dist/sommark.browser.lite.js +1779 -2169
  22. package/dist/sommark.lexer.js +392 -544
  23. package/dist/sommark.parser.js +604 -1200
  24. package/formatter/mark.js +34 -0
  25. package/formatter/tag.js +7 -33
  26. package/helpers/utils.js +15 -16
  27. package/index.js +9 -1
  28. package/index.shared.js +26 -16
  29. package/mappers/languages/csv.js +62 -0
  30. package/mappers/languages/html.js +12 -66
  31. package/mappers/languages/json.js +74 -156
  32. package/mappers/languages/jsonc.js +21 -63
  33. package/mappers/languages/markdown.js +159 -276
  34. package/mappers/languages/mdx.js +7 -62
  35. package/mappers/languages/text.js +2 -19
  36. package/mappers/languages/toml.js +231 -0
  37. package/mappers/languages/xml.js +25 -25
  38. package/mappers/languages/yaml.js +323 -0
  39. package/mappers/mapper.js +1 -22
  40. package/mappers/shared/index.js +3 -16
  41. package/package.json +5 -2
@@ -8,16 +8,10 @@
8
8
  * @property {string} END_KEYWORD - 'end' value.
9
9
  * @property {string} IDENTIFIER - Block or inline name (e.g. 'Person', 'import', '$use-module').
10
10
  * @property {string} EQUAL - '=' char.
11
- * @property {string} VALUE - Data values. Encapsulates Quoted Strings ("...") and Prefix Layers (js{}, p{}).
11
+ * @property {string} VALUE - Data values. Encapsulates Quoted Strings ("...") and Prefix Layers (p{}, v{}).
12
12
  * @property {string} TEXT - Plain unformatted text content.
13
- * @property {string} THIN_ARROW - '->' sequence.
14
- * @property {string} OPEN_PAREN - '(' char.
15
- * @property {string} CLOSE_PAREN - ')' char.
16
- * @property {string} OPEN_AT - '@_' sequence (At-Block start).
17
- * @property {string} CLOSE_AT - '_@' sequence (At-Header end).
18
13
  * @property {string} COLON - ':' char.
19
14
  * @property {string} COMMA - ',' char.
20
- * @property {string} SEMICOLON - ';' char (At-Block separator).
21
15
  * @property {string} COMMENT - '#' comments.
22
16
  * @property {string} COMMENT_BLOCK - '###' comments.
23
17
  * @property {string} ESCAPE - '\' char. Used for literalizing structural chars like '\"' or '\['.
@@ -25,7 +19,6 @@
25
19
  * @property {string} EXCLAMATION_MARK - '!' char.
26
20
  * @property {string} IMPORT - 'import' keyword.
27
21
  * @property {string} USE_MODULE - '$use-module' keyword.
28
- * @property {string} PREFIX_JS - 'js{}' prefix layer.
29
22
  * @property {string} PREFIX_P - 'p{}' placeholder layer.
30
23
  * @property {string} PREFIX_V - 'v{}' local variable layer.
31
24
  * @property {string} EOF - End of File indicator.
@@ -40,18 +33,11 @@ const TOKEN_TYPES = {
40
33
  EQUAL: "EQUAL",
41
34
  VALUE: "VALUE",
42
35
  QUOTE: "QUOTE",
43
- PREFIX_JS: "PREFIX_JS",
44
36
  PREFIX_P: "PREFIX_P",
45
37
  PREFIX_V: "PREFIX_V",
46
38
  TEXT: "TEXT",
47
- THIN_ARROW: "THIN_ARROW",
48
- OPEN_PAREN: "OPEN_PAREN",
49
- CLOSE_PAREN: "CLOSE_PAREN",
50
- OPEN_AT: "OPEN_AT",
51
- CLOSE_AT: "CLOSE_AT",
52
39
  COLON: "COLON",
53
40
  COMMA: "COMMA",
54
- SEMICOLON: "SEMICOLON",
55
41
  COMMENT: "COMMENT",
56
42
  COMMENT_BLOCK: "COMMENT_BLOCK",
57
43
  ESCAPE: "ESCAPE",
@@ -61,8 +47,13 @@ const TOKEN_TYPES = {
61
47
  WHITESPACE: "WHITESPACE",
62
48
  STATIC_KEYWORD: "STATIC_KEYWORD",
63
49
  RUNTIME_KEYWORD: "RUNTIME_KEYWORD",
50
+ LOGIC_OPEN: "LOGIC_OPEN",
64
51
  LOGIC: "LOGIC",
52
+ LOGIC_CLOSE: "LOGIC_CLOSE",
65
53
  FOR_EACH: "FOR_EACH",
54
+ PREFIX_OPEN: "PREFIX_OPEN",
55
+ PREFIX_CLOSE: "PREFIX_CLOSE",
56
+ PIPELINE: "PIPELINE",
66
57
  EOF: "EOF"
67
58
  };
68
59
 
@@ -93,8 +84,6 @@ function peek(input, index, offset) {
93
84
  */
94
85
  const BLOCK = "Block",
95
86
  TEXT = "Text",
96
- INLINE = "Inline",
97
- ATBLOCK = "AtBlock",
98
87
  COMMENT = "Comment",
99
88
  COMMENT_BLOCK = "CommentBlock",
100
89
  IMPORT = "Import",
@@ -107,13 +96,8 @@ const BLOCK = "Block",
107
96
  /**
108
97
  * Names for symbols used to separate parts of the code (like commas and colons).
109
98
  */
110
- const SEMICOLON = "Semicolon",
111
- BLOCKCOMMA = "Block-comma",
112
- ATBLOCKCOMMA = "Atblock-comma",
113
- INLINECOMMA = "Inline-comma",
114
- BLOCKCOLON = "Block-colon",
115
- ATBLOCKCOLON = "Atblock-colon",
116
- INLINECOLON = "Inline-colon";
99
+ const BLOCKCOMMA = "Block-comma",
100
+ BLOCKCOLON = "Block-colon";
117
101
 
118
102
  /**
119
103
  * These names are used in error messages to tell you exactly which part
@@ -123,12 +107,6 @@ const block_id = "Block Identifier",
123
107
  block_value = "Block Value",
124
108
  block_key = "Block Key",
125
109
  block_end = "Block end",
126
- inline_id = "Inline Identifier",
127
- inline_text = "Inline Text",
128
- at_id = "At Identifier",
129
- at_value = "At Value",
130
- atblock_key = "AtBlock Key",
131
- at_end = "Atblock End",
132
110
  /** Reserved keyword for closing blocks */
133
111
  end_keyword = "end",
134
112
  slot_keyword = "slot",
@@ -136,9 +114,6 @@ const block_id = "Block Identifier",
136
114
 
137
115
  var labels = /*#__PURE__*/Object.freeze({
138
116
  __proto__: null,
139
- ATBLOCK: ATBLOCK,
140
- ATBLOCKCOLON: ATBLOCKCOLON,
141
- ATBLOCKCOMMA: ATBLOCKCOMMA,
142
117
  BLOCK: BLOCK,
143
118
  BLOCKCOLON: BLOCKCOLON,
144
119
  BLOCKCOMMA: BLOCKCOMMA,
@@ -146,225 +121,20 @@ var labels = /*#__PURE__*/Object.freeze({
146
121
  COMMENT_BLOCK: COMMENT_BLOCK,
147
122
  FOR_EACH: FOR_EACH,
148
123
  IMPORT: IMPORT,
149
- INLINE: INLINE,
150
- INLINECOLON: INLINECOLON,
151
- INLINECOMMA: INLINECOMMA,
152
124
  RUNTIME_LOGIC: RUNTIME_LOGIC,
153
- SEMICOLON: SEMICOLON,
154
125
  SLOT: SLOT,
155
126
  STATIC_LOGIC: STATIC_LOGIC,
156
127
  TEXT: TEXT,
157
128
  USE_MODULE: USE_MODULE,
158
- at_end: at_end,
159
- at_id: at_id,
160
- at_value: at_value,
161
- atblock_key: atblock_key,
162
129
  block_end: block_end,
163
130
  block_id: block_id,
164
131
  block_key: block_key,
165
132
  block_value: block_value,
166
133
  end_keyword: end_keyword,
167
134
  for_each_keyword: for_each_keyword,
168
- inline_id: inline_id,
169
- inline_text: inline_text,
170
135
  slot_keyword: slot_keyword
171
136
  });
172
137
 
173
- /**
174
- * Wraps your text in a color if colors are turned on.
175
- *
176
- * @param {string} color - The color to use (red, green, yellow, blue, magenta, or cyan).
177
- * @param {string} text - The text you want to color.
178
- * @returns {string} - The colored text, or plain text if colors are off.
179
- * @throws {Error} - Fails if you forget to provide the text.
180
- */
181
- function colorize(color, text) {
182
- if (!text) throw new Error("argument 'text' is not defined.");
183
- return text;
184
- }
185
-
186
- /**
187
- * SomMark Errors
188
- * Handles formatting and throwing errors with beautiful CLI coloring and pointers.
189
- */
190
-
191
- // ========================================================================== //
192
- // Message Formatting //
193
- // ========================================================================== //
194
-
195
- /**
196
- * Processes a message by applying colors and formatting.
197
- * Supports:
198
- * - {line} : Adds a horizontal line
199
- * - {N} : Adds a new line
200
- * - <$color: Text$> : Adds color (red, yellow, green, blue, magenta, cyan)
201
- *
202
- * @param {string|string[]} text - The message or list of message parts to format.
203
- * @returns {string} - The final formatted and colored string.
204
- */
205
- function formatMessage(text) {
206
- const horizontal_rule = "\n----------------------------------------------------------------------------------------------\n";
207
- const pattern = /<\$([^:]+):([\s\S]*?)\$>/g;
208
-
209
- if (Array.isArray(text)) {
210
- text = text.join("");
211
- }
212
-
213
- text = text.replace(pattern, (match, color, content) => {
214
- return colorize(color, content.trim());
215
- });
216
- text = text.replaceAll("{line}", horizontal_rule);
217
- text = text.replaceAll("{N}", "\n");
218
-
219
- text = text
220
- .split("\n")
221
- .filter(value => value !== "")
222
- .join("\n")
223
- .trim();
224
-
225
- return text;
226
- }
227
-
228
- /**
229
- * Creates a detailed error message showing where the error happened in the code.
230
- * It adds a line number, a snippet of the code, and a pointer (^) to the exact spot.
231
- *
232
- * @param {string} src - The original code being parsed.
233
- * @param {Object} range - The location of the error (line and character).
234
- * @param {string|null} filename - The name of the file (optional).
235
- * @param {string|string[]} message - The error message to show.
236
- * @param {string} typeName - The type of error (e.g., "Lexer" or "Parser").
237
- * @returns {string[]} - A list of message parts that make up the final error report.
238
- */
239
- function formatErrorWithContext(src, range, filename, message, typeName) {
240
- if (!src || !range || !range.start) return message;
241
-
242
- const lines = src.split("\n");
243
- const lineIndex = range.start.line;
244
- const lineContent = lines[lineIndex] || "";
245
- const pointerPadding = " ".repeat(range.start.character);
246
- const sourceLabel = filename ? ` [${filename}]` : "";
247
-
248
- const rangeInfo =
249
- range.start.line === range.end.line
250
- ? `from column <$yellow:${range.start.character}$> to <$yellow:${range.end.character}$>`
251
- : `from line <$yellow:${range.start.line + 1}$>, column <$yellow:${range.start.character}$> to line <$yellow:${range.end.line + 1}$>, column <$yellow:${range.end.character}$>`;
252
-
253
- const formattedMessage = [
254
- `<$blue:{line}$><$red:Here where error occurred${sourceLabel}:$>{N}${lineContent}{N}${pointerPadding}<$yellow:^$>{N}{N}`,
255
- `<$red:${typeName} Error:$> `,
256
- ...(Array.isArray(message) ? message : [message]),
257
- `{N}at line <$yellow:${range.start.line + 1}$>, ${rangeInfo}{N}`,
258
- "<$blue:{line}$>"
259
- ];
260
-
261
- return formattedMessage;
262
- }
263
-
264
- // ========================================================================== //
265
- // Error Classes //
266
- // ========================================================================== //
267
-
268
- /** Base class for all SomMark errors that automatically formats messages for the terminal. */
269
- class CustomError extends Error {
270
- /**
271
- * Creates a new error.
272
- *
273
- * @param {string|string[]} message - The text describing what went wrong.
274
- * @param {string} name - The name of the error type.
275
- */
276
- constructor(message, name) {
277
- super(message);
278
- this.name = name;
279
- this.message = formatMessage(`<$cyan:[${this.name}]$>:`) + "\n" + formatMessage(message);
280
- if (Error.captureStackTrace) {
281
- Error.captureStackTrace(this, this.constructor);
282
- }
283
- }
284
- }
285
-
286
- class ParserError extends CustomError {
287
- constructor(message) { super(message, "Parser Error"); }
288
- }
289
-
290
- class LexerError extends CustomError {
291
- constructor(message) { super(message, "Lexer Error"); }
292
- }
293
-
294
- class TranspilerError extends CustomError {
295
- constructor(message) { super(message, "Transpiler Error"); }
296
- }
297
-
298
- class CLIError extends CustomError {
299
- constructor(message) { super(message, "CLI Error"); }
300
- }
301
-
302
- class RuntimeError extends CustomError {
303
- constructor(message) { super(message, "Runtime Error"); }
304
- }
305
-
306
- class SommarkError extends CustomError {
307
- constructor(message) { super(message, "SomMark Error"); }
308
- }
309
-
310
- // ========================================================================== //
311
- // Error Dispatcher (Helper) //
312
- // ========================================================================== //
313
-
314
- /**
315
- * A helper that creates an error "dispatcher" for a specific category.
316
- *
317
- * @param {string} type - The category of error (e.g., 'lexer', 'parser').
318
- * @returns {Function} - A function that throws the formatted error.
319
- */
320
- function getError(type) {
321
- const validate_msg = msg => (Array.isArray(msg) && msg.length > 0) || typeof msg === "string";
322
- const typeNames = {
323
- parser: "Parser",
324
- transpiler: "Transpiler",
325
- lexer: "Lexer",
326
- cli: "CLI",
327
- runtime: "Runtime",
328
- sommark: "SomMark"
329
- };
330
- const ErrorClasses = {
331
- parser: ParserError,
332
- transpiler: TranspilerError,
333
- lexer: LexerError,
334
- cli: CLIError,
335
- runtime: RuntimeError,
336
- sommark: SommarkError
337
- };
338
-
339
- return (errorMessage, context = null) => {
340
- if (validate_msg(errorMessage)) {
341
- let finalMessage = errorMessage;
342
- if (context && context.src && context.range) {
343
- finalMessage = formatErrorWithContext(
344
- context.src,
345
- context.range,
346
- context.filename,
347
- errorMessage,
348
- typeNames[type]
349
- );
350
- }
351
- throw new ErrorClasses[type](finalMessage).message;
352
- }
353
- };
354
- }
355
-
356
- /** Helper to throw Lexer errors. */
357
- const lexerError = getError("lexer");
358
-
359
- /** Helper to throw Parser errors. */
360
- const parserError = getError("parser");
361
-
362
- /** Helper to throw Runtime or Module errors. */
363
- const runtimeError = getError("runtime");
364
-
365
- /** Helper to throw general internal SomMark errors. */
366
- const sommarkError = getError("sommark");
367
-
368
138
  /**
369
139
  * SomMark Lexer
370
140
  *
@@ -384,12 +154,12 @@ function lexer(src, filename = "anonymous") {
384
154
  let line = 0, character = 0;
385
155
 
386
156
  // State Variables
387
- let isInAtBlockBody = false;
388
157
  let isInQuote = false;
389
- let isInHeader = false; // Tracks if we are in a structural header context
390
- let isInAtBlockHeader = false; // Specific for At-Block headers (@_ ... _@)
391
- let isInInlineHead = false; // Specific for (key:val) after ->
392
- let parenDepth = 0; // To track balanced parentheses in inlines
158
+ let isInHeader = false; // Tracks if we are in a structural header context
159
+ let isInPVPrefix = false; // Tracks if we are scanning inside a p{} or v{} prefix
160
+ let pendingSmarkRaw = false; // Set when KEY "smark-raw" is seen — waiting for value
161
+ let hasSmarkRaw = false; // Set when smark-raw: true is confirmed in header
162
+ let isRawContent = false; // Set when inside a smark-raw block — content collected as-is, not parsed
393
163
 
394
164
  /**
395
165
  * Adds a token to the stream and updates the scanner's position tracking.
@@ -453,35 +223,63 @@ function lexer(src, filename = "anonymous") {
453
223
  }
454
224
 
455
225
  while (i < src.length) {
456
- // --- PHASE 1: AT-BLOCK BODY MODE ---
457
- // In this mode, we consume everything as raw text until we hit the @_ marker.
458
- if (isInAtBlockBody) {
459
- if (src[i] === "@" && src[i + 1] === "_") {
460
- isInAtBlockBody = false;
461
- } else {
462
- let body = "";
226
+ const char = src[i];
227
+ const next = src[i + 1];
228
+
229
+ // --- RAW CONTENT MODE ---
230
+ // Collect everything as-is until [end] or [end:name]. \[ escapes a literal [.
231
+ if (isRawContent) {
232
+ let raw = "";
233
+ while (i < src.length) {
234
+ if (src[i] === "\\" && src[i + 1] === "[") {
235
+ raw += "[";
236
+ i += 2;
237
+ continue;
238
+ }
239
+ if (src[i] === "[") {
240
+ if (src.startsWith(`[${end_keyword}]`, i) || src.startsWith(`[${end_keyword}:`, i)) break;
241
+ }
242
+ raw += src[i];
243
+ i++;
244
+ }
245
+ if (raw) addToken(TOKEN_TYPES.TEXT, raw);
246
+ isRawContent = false;
247
+ continue;
248
+ }
249
+
250
+ // --- PHASE 1.5: PV PREFIX CONTENT MODE ---
251
+ // Handles structured content inside p{} and v{} prefixes.
252
+ if (isInPVPrefix && !isInQuote) {
253
+ if (char === '"' || char === "'") {
254
+ addToken(TOKEN_TYPES.QUOTE, char);
255
+ i++;
256
+ isInQuote = true;
257
+ continue;
258
+ }
259
+ if (char === '|') {
260
+ addToken(TOKEN_TYPES.PIPELINE, "|");
261
+ i++;
262
+ continue;
263
+ }
264
+ if (char === '}') {
265
+ addToken(TOKEN_TYPES.PREFIX_CLOSE, "}");
266
+ isInPVPrefix = false;
267
+ i++;
268
+ continue;
269
+ }
270
+ if (char !== ' ' && char !== '\t' && char !== '\n' && char !== '\r') {
271
+ let word = '';
463
272
  while (i < src.length) {
464
- // Handle escapes in At-Block Body
465
- if (src[i] === "\\" && i + 1 < src.length) {
466
- body += src[i + 1];
467
- i += 2;
468
- continue;
469
- }
470
- // Stop at end marker
471
- if (src[i] === "@" && src[i + 1] === "_") {
472
- break;
473
- }
474
- body += src[i];
273
+ const c = src[i];
274
+ if (c === '}' || c === '|' || c === '"' || c === "'" || c === ' ' || c === '\t' || c === '\n' || c === '\r') break;
275
+ word += c;
475
276
  i++;
476
277
  }
477
- if (body.length > 0) {
478
- addToken(TOKEN_TYPES.TEXT, body);
479
- }
278
+ if (word) addToken(TOKEN_TYPES.KEY, word);
480
279
  continue;
481
280
  }
281
+ // Whitespace: fall through to PHASE 3 whitespace handling
482
282
  }
483
- const char = src[i];
484
- const next = src[i + 1];
485
283
 
486
284
  // --- PHASE 2: QUOTE MODE ---
487
285
  // Handles balanced strings and allows prefix layers (js{}, p{}) inside them.
@@ -499,50 +297,57 @@ function lexer(src, filename = "anonymous") {
499
297
  }
500
298
 
501
299
  // Support Prefix Layers inside quotes!
502
- if ((src[i] === "j" && src[i + 1] === "s" && src[i + 2] === "{") || (src[i] === "p" && src[i + 1] === "{") || (src[i] === "v" && src[i + 1] === "{")) {
503
- const isJS = (src[i] === "j");
300
+ if ((src[i] === "p" && src[i + 1] === "{") || (src[i] === "v" && src[i + 1] === "{")) {
504
301
  const isV = (src[i] === "v");
505
302
  if (quoteValue.length > 0) {
506
303
  addToken(TOKEN_TYPES.VALUE, quoteValue);
507
304
  quoteValue = "";
508
305
  }
509
306
 
510
- let braceDepth = 1;
511
- let prefixValue = isJS ? "js{" : (isV ? "v{" : "p{");
512
- i += isJS ? 3 : 2;
513
-
514
- let internalString = null;
515
- while (i < src.length && braceDepth > 0) {
516
- const c = src[i];
517
- const n = src[i + 1];
518
- if (internalString) {
519
- if (c === "\\" && (n === internalString || n === "\\")) {
520
- prefixValue += c + n;
521
- i += 2;
522
- continue;
307
+ {
308
+ // p{} or v{}: keyword + PREFIX_OPEN + unquoted key + optional PIPELINE + fallback + PREFIX_CLOSE
309
+ addToken(isV ? TOKEN_TYPES.PREFIX_V : TOKEN_TYPES.PREFIX_P, isV ? "v" : "p");
310
+ addToken(TOKEN_TYPES.PREFIX_OPEN, "{");
311
+ i += 2;
312
+ // Scan unquoted key (cannot use same quote char as outer string)
313
+ let key = "";
314
+ while (i < src.length && src[i] !== "|" && src[i] !== "}" && src[i] !== quoteChar) {
315
+ key += src[i];
316
+ i++;
317
+ }
318
+ if (key.trim()) addToken(TOKEN_TYPES.KEY, key.trim());
319
+ // Optional PIPELINE + fallback
320
+ if (i < src.length && src[i] === "|") {
321
+ addToken(TOKEN_TYPES.PIPELINE, "|");
322
+ i++;
323
+ let fallback = "";
324
+ while (i < src.length && src[i] !== "}" && src[i] !== quoteChar) {
325
+ fallback += src[i];
326
+ i++;
523
327
  }
524
- if (c === internalString) internalString = null;
525
- } else {
526
- if (c === "\"" || c === "'") internalString = c;
527
- else if (c === "{") braceDepth++;
528
- else if (c === "}") braceDepth--;
328
+ if (fallback.trim()) addToken(TOKEN_TYPES.VALUE, fallback.trim());
329
+ }
330
+ // PREFIX_CLOSE
331
+ if (i < src.length && src[i] === "}") {
332
+ addToken(TOKEN_TYPES.PREFIX_CLOSE, "}");
333
+ i++;
529
334
  }
530
- prefixValue += c;
531
- i++;
532
335
  }
533
- let tokenType = isJS ? TOKEN_TYPES.PREFIX_JS : (isV ? TOKEN_TYPES.PREFIX_V : TOKEN_TYPES.PREFIX_P);
534
- addToken(tokenType, prefixValue);
535
336
  continue;
536
337
  }
537
338
 
538
339
  if (src[i] === quoteChar) {
539
340
  // Guess role based on next structural character
540
341
  let nextStructural = peekStructural(i + 1);
541
- let tokenType = (isInHeader || isInInlineHead) && (nextStructural === ":" || nextStructural === "=")
342
+ let tokenType = isInHeader && (nextStructural === ":" || nextStructural === "=")
542
343
  ? TOKEN_TYPES.KEY
543
344
  : TOKEN_TYPES.VALUE;
544
345
 
545
346
  if (quoteValue.length > 0) addToken(tokenType, quoteValue);
347
+ if (pendingSmarkRaw && tokenType === TOKEN_TYPES.VALUE && quoteValue === "true") {
348
+ hasSmarkRaw = true;
349
+ pendingSmarkRaw = false;
350
+ }
546
351
  addToken(TOKEN_TYPES.QUOTE, quoteChar);
547
352
  isInQuote = false;
548
353
  i++;
@@ -610,84 +415,37 @@ function lexer(src, filename = "anonymous") {
610
415
  continue;
611
416
  }
612
417
 
613
- // PREFIX LAYERS (js{...} or p{...} or v{...})
614
- if ((char === "j" && next === "s" && src[i + 2] === "{") || (char === "p" && next === "{") || (char === "v" && next === "{")) {
615
- const isJS = (char === "j");
418
+ // PREFIX LAYERS (p{...} or v{...})
419
+ if ((char === "p" && next === "{") || (char === "v" && next === "{")) {
616
420
  const isP = (char === "p");
617
421
  const isV = (char === "v");
618
422
 
619
423
  // Context Check
620
- const isBlockHeader = isInHeader && !isInAtBlockHeader;
621
- const isNormalText = !isInHeader && !isInInlineHead && !isInAtBlockBody && parenDepth === 0;
424
+ const isBlockHeader = isInHeader;
425
+ const isNormalText = !isInHeader;
622
426
 
623
427
  let allowed = false;
624
- if (isJS && isBlockHeader) allowed = true;
625
428
  if (isP && (isBlockHeader || isNormalText)) allowed = true;
626
429
  if (isV && (isBlockHeader || isNormalText)) allowed = true;
627
430
 
628
431
  if (allowed) {
629
- let braceDepth = 1;
630
- let prefixValue = isJS ? "js{" : (isV ? "v{" : "p{");
631
- i += isJS ? 3 : 2;
632
-
633
- let inString = null; // Track if we are inside " " or ' '
634
- while (i < src.length && braceDepth > 0) {
635
- const c = src[i];
636
- const n = src[i + 1];
637
-
638
- if (inString) {
639
- if (c === "\\" && (n === inString || n === "\\")) {
640
- prefixValue += c + n;
641
- i += 2;
642
- continue;
643
- }
644
- if (c === inString) inString = null;
645
- } else {
646
- if (c === "\"" || c === "'") inString = c;
647
- else if (c === "{") braceDepth++;
648
- else if (c === "}") braceDepth--;
649
- }
650
- prefixValue += c;
651
- i++;
652
- }
653
- let tokenType = isJS ? TOKEN_TYPES.PREFIX_JS : (isV ? TOKEN_TYPES.PREFIX_V : TOKEN_TYPES.PREFIX_P);
654
- addToken(tokenType, prefixValue);
432
+ // p{} or v{}: emit keyword + PREFIX_OPEN, enter structured content mode
433
+ addToken(isV ? TOKEN_TYPES.PREFIX_V : TOKEN_TYPES.PREFIX_P, isV ? "v" : "p");
434
+ addToken(TOKEN_TYPES.PREFIX_OPEN, "{");
435
+ i += 2; // skip "p{" or "v{"
436
+ isInPVPrefix = true;
655
437
  continue;
656
438
  }
657
439
  // If not allowed, it will fall through to normal word scanning
658
440
  }
659
441
 
660
- // MULTI-CHAR MARKERS
661
- if (char === "@" && next === "_") {
662
- addToken(TOKEN_TYPES.OPEN_AT, "@_");
663
- i += 2;
664
- isInHeader = true; // At-Blocks start with a header part
665
- isInAtBlockHeader = true;
666
- continue;
667
- }
668
- if (char === "-" && next === ">") {
669
- if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
670
- addToken(TOKEN_TYPES.TEXT, "-");
671
- i++; // Swallowed one char
672
- } else {
673
- addToken(TOKEN_TYPES.THIN_ARROW, "->");
674
- i += 2;
675
- isInInlineHead = true; // The following ( ) will be structural
676
- }
677
- continue;
678
- }
679
-
680
442
  // STATIC KEYWORD
681
443
  if (char === "s" && src.slice(i, i + 6) === "static") {
682
444
  const afterStatic = src.slice(i + 6);
683
445
  const hasSpace = afterStatic.startsWith(" ");
684
446
  const hasLogic = hasSpace ? afterStatic.slice(1).startsWith("${") : afterStatic.startsWith("${");
685
447
 
686
- const isMainIdentifier = (
687
- last_non_junk_type === TOKEN_TYPES.OPEN_BRACKET ||
688
- last_non_junk_type === TOKEN_TYPES.OPEN_AT ||
689
- (last_non_junk_type === TOKEN_TYPES.OPEN_PAREN && isInInlineHead)
690
- );
448
+ const isMainIdentifier = last_non_junk_type === TOKEN_TYPES.OPEN_BRACKET;
691
449
 
692
450
  if ((hasLogic || isInHeader) && !isMainIdentifier) {
693
451
  addToken(TOKEN_TYPES.STATIC_KEYWORD, hasSpace ? "static " : "static");
@@ -702,11 +460,7 @@ function lexer(src, filename = "anonymous") {
702
460
  const hasSpace = afterRuntime.startsWith(" ");
703
461
  const hasLogic = hasSpace ? afterRuntime.slice(1).startsWith("${") : afterRuntime.startsWith("${");
704
462
 
705
- const isMainIdentifier = (
706
- last_non_junk_type === TOKEN_TYPES.OPEN_BRACKET ||
707
- last_non_junk_type === TOKEN_TYPES.OPEN_AT ||
708
- (last_non_junk_type === TOKEN_TYPES.OPEN_PAREN && isInInlineHead)
709
- );
463
+ const isMainIdentifier = last_non_junk_type === TOKEN_TYPES.OPEN_BRACKET;
710
464
 
711
465
  if ((hasLogic || isInHeader) && !isMainIdentifier) {
712
466
  addToken(TOKEN_TYPES.RUNTIME_KEYWORD, hasSpace ? "runtime " : "runtime");
@@ -715,213 +469,126 @@ function lexer(src, filename = "anonymous") {
715
469
  }
716
470
  }
717
471
 
718
- // LOGIC BLOCKS (${ ... }$)
719
- if (char === "$" && next === "{" && (last_non_junk_type === TOKEN_TYPES.STATIC_KEYWORD || last_non_junk_type === TOKEN_TYPES.RUNTIME_KEYWORD)) {
720
- const startLine = line;
721
- const startCharacter = character;
722
- i += 2;
723
- let logicCode = "";
724
- let braceDepth = 1;
725
- let internalString = null;
726
- let foundClosing = false;
472
+ // LOGIC BLOCKS (${ ... }$) — explicit: static/runtime ${ }$ shorthand: ${ }$ = static ${ }$
473
+ if (char === "$" && next === "{") {
474
+ {
475
+ const hasExplicitKeyword = last_non_junk_type === TOKEN_TYPES.STATIC_KEYWORD || last_non_junk_type === TOKEN_TYPES.RUNTIME_KEYWORD;
476
+ if (!hasExplicitKeyword) addToken(TOKEN_TYPES.STATIC_KEYWORD, "static");
477
+ addToken(TOKEN_TYPES.LOGIC_OPEN, "${");
478
+ i += 2;
727
479
 
728
- while (i < src.length) {
729
- const c = src[i];
730
- const n = src[i + 1];
480
+ let logicCode = "";
481
+ let depth = 0;
482
+ let internalString = null;
731
483
 
732
- // Stop condition: }$ (only if not inside a JS string and at top-level brace depth)
733
- if (c === "}" && n === "$" && !internalString && braceDepth === 1) {
734
- i += 2;
735
- braceDepth = 0;
736
- foundClosing = true;
737
- break;
738
- }
484
+ while (i < src.length) {
485
+ const c = src[i];
486
+ const n = src[i + 1];
739
487
 
740
- if (internalString) {
741
- if (c === "\\" && (n === internalString || n === "\\")) {
742
- logicCode += c + n;
743
- i += 2;
744
- continue;
488
+ // Close condition: }$ at depth 0, not followed by { (}${ is a template expression boundary)
489
+ if (c === "}" && n === "$" && !internalString && depth === 0 && src[i + 2] !== "{") {
490
+ break;
745
491
  }
746
- if (c === internalString) internalString = null;
747
- } else {
748
- if (c === "/" && n === "/") {
749
- logicCode += c + n;
750
- i += 2;
751
- while (i < src.length && src[i] !== "\n" && src[i] !== "\r") {
752
- logicCode += src[i];
753
- i++;
492
+
493
+ if (internalString) {
494
+ if (c === "\\" && (n === internalString || n === "\\")) {
495
+ logicCode += c + n;
496
+ i += 2;
497
+ continue;
754
498
  }
755
- continue;
756
- }
757
- if (c === "/" && n === "*") {
758
- logicCode += c + n;
759
- i += 2;
760
- while (i < src.length) {
761
- if (src[i] === "*" && src[i + 1] === "/") {
762
- logicCode += "*/";
763
- i += 2;
764
- break;
499
+ if (c === internalString) internalString = null;
500
+ } else {
501
+ if (c === "/" && n === "/") {
502
+ logicCode += c + n;
503
+ i += 2;
504
+ while (i < src.length && src[i] !== "\n" && src[i] !== "\r") {
505
+ logicCode += src[i];
506
+ i++;
765
507
  }
766
- logicCode += src[i];
767
- i++;
508
+ continue;
509
+ }
510
+ if (c === "/" && n === "*") {
511
+ logicCode += c + n;
512
+ i += 2;
513
+ while (i < src.length) {
514
+ if (src[i] === "*" && src[i + 1] === "/") {
515
+ logicCode += "*/";
516
+ i += 2;
517
+ break;
518
+ }
519
+ logicCode += src[i];
520
+ i++;
521
+ }
522
+ continue;
768
523
  }
769
- continue;
524
+
525
+ if (c === "\"" || c === "'" || c === "`") internalString = c;
526
+ else if (c === "{") depth++;
527
+ else if (c === "}") depth--;
770
528
  }
771
529
 
772
- if (c === "\"" || c === "'" || c === "`") internalString = c;
773
- else if (c === "{") braceDepth++;
774
- else if (c === "}") braceDepth--;
530
+ logicCode += c;
531
+ i++;
775
532
  }
776
533
 
777
- logicCode += c;
778
- i++;
779
- }
534
+ addToken(TOKEN_TYPES.LOGIC, logicCode);
780
535
 
781
- if (!foundClosing) {
782
- lexerError("Unclosed logic block. Expected '}$' to close the block starting with '${'.", {
783
- src,
784
- filename,
785
- range: {
786
- start: { line: startLine, character: startCharacter },
787
- end: { line: startLine, character: startCharacter + 2 }
788
- }
789
- });
790
- }
536
+ if (i < src.length && src[i] === "}" && src[i + 1] === "$") {
537
+ addToken(TOKEN_TYPES.LOGIC_CLOSE, "}$");
538
+ i += 2;
539
+ }
791
540
 
792
- addToken(TOKEN_TYPES.LOGIC, logicCode);
793
- continue;
541
+ continue;
542
+ }
794
543
  }
795
544
 
796
545
  // SINGLE-CHAR MARKERS
797
546
  if (char === "[") {
798
- if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
799
- addToken(TOKEN_TYPES.TEXT, "[");
800
- } else {
801
- addToken(TOKEN_TYPES.OPEN_BRACKET, "[");
802
- isInHeader = true;
803
- }
547
+ addToken(TOKEN_TYPES.OPEN_BRACKET, "[");
548
+ isInHeader = true;
549
+ pendingSmarkRaw = false;
550
+ hasSmarkRaw = false;
804
551
  i++;
805
552
  continue;
806
553
  }
807
- if (char === "_" && next === "@") {
808
- if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
809
- addToken(TOKEN_TYPES.TEXT, "_@");
810
- } else {
811
- const lastRealType = last_non_junk_type;
812
- addToken(TOKEN_TYPES.CLOSE_AT, "_@");
813
- // Removed delimiter stack check
814
- if (lastRealType === TOKEN_TYPES.END_KEYWORD) {
815
- isInAtBlockBody = false;
816
- isInHeader = false;
817
- isInAtBlockHeader = false;
818
- }
819
- }
820
- i += 2;
821
- continue;
822
- }
823
554
  if (char === "]") {
824
- if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
825
- addToken(TOKEN_TYPES.TEXT, "]");
826
- } else {
827
- addToken(TOKEN_TYPES.CLOSE_BRACKET, "]");
828
- isInHeader = false;
829
- }
830
- i++;
831
- continue;
832
- }
833
- if (char === "(") {
834
- if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
835
- addToken(TOKEN_TYPES.TEXT, "(");
836
- parenDepth++;
837
- } else {
838
- addToken(TOKEN_TYPES.OPEN_PAREN, "(");
839
- parenDepth++;
840
- }
841
- i++;
842
- continue;
843
- }
844
- if (char === ")") {
845
- if (isInAtBlockBody || (parenDepth > 1 && !isInInlineHead)) {
846
- addToken(TOKEN_TYPES.TEXT, ")");
847
- parenDepth--;
848
- } else if (parenDepth > 0) {
849
- // This ends the content part if depth drops to 0
850
- parenDepth--;
851
- if (parenDepth === 0) {
852
- addToken(TOKEN_TYPES.CLOSE_PAREN, ")");
853
- if (isInInlineHead) {
854
- isInInlineHead = false;
855
- isInHeader = false;
856
- }
857
- } else {
858
- addToken(TOKEN_TYPES.TEXT, ")");
859
- }
860
- } else {
861
- addToken(TOKEN_TYPES.TEXT, ")");
555
+ addToken(TOKEN_TYPES.CLOSE_BRACKET, "]");
556
+ isInHeader = false;
557
+ if (hasSmarkRaw) {
558
+ isRawContent = true;
559
+ hasSmarkRaw = false;
862
560
  }
561
+ pendingSmarkRaw = false;
863
562
  i++;
864
563
  continue;
865
564
  }
866
565
  if (char === ":") {
867
- if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
868
- addToken(TOKEN_TYPES.TEXT, ":");
566
+ const colonAllowed = [TOKEN_TYPES.IDENTIFIER, TOKEN_TYPES.KEY, TOKEN_TYPES.VALUE, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.QUOTE, TOKEN_TYPES.PREFIX_V, TOKEN_TYPES.PREFIX_P, TOKEN_TYPES.PREFIX_CLOSE, TOKEN_TYPES.IMPORT, TOKEN_TYPES.USE_MODULE, TOKEN_TYPES.END_KEYWORD, TOKEN_TYPES.TEXT, TOKEN_TYPES.LOGIC, TOKEN_TYPES.LOGIC_CLOSE, TOKEN_TYPES.STATIC_KEYWORD, TOKEN_TYPES.RUNTIME_KEYWORD, TOKEN_TYPES.FOR_EACH];
567
+ if (colonAllowed.includes(last_non_junk_type)) {
568
+ addToken(TOKEN_TYPES.COLON, ":");
569
+ isInHeader = true;
869
570
  } else {
870
- const allowed = [TOKEN_TYPES.IDENTIFIER, TOKEN_TYPES.KEY, TOKEN_TYPES.CLOSE_AT, TOKEN_TYPES.VALUE, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.QUOTE, TOKEN_TYPES.PREFIX_JS, TOKEN_TYPES.PREFIX_V, TOKEN_TYPES.PREFIX_P, TOKEN_TYPES.IMPORT, TOKEN_TYPES.USE_MODULE, TOKEN_TYPES.END_KEYWORD, TOKEN_TYPES.TEXT, TOKEN_TYPES.LOGIC, TOKEN_TYPES.STATIC_KEYWORD, TOKEN_TYPES.RUNTIME_KEYWORD, TOKEN_TYPES.FOR_EACH];
871
- if (allowed.includes(last_non_junk_type)) {
872
- addToken(TOKEN_TYPES.COLON, ":");
873
- isInHeader = true;
874
- } else {
875
- addToken(TOKEN_TYPES.TEXT, ":");
876
- }
571
+ addToken(TOKEN_TYPES.TEXT, ":");
877
572
  }
878
573
  i++;
879
574
  continue;
880
575
  }
881
576
  if (char === "=") {
882
- if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
883
- addToken(TOKEN_TYPES.TEXT, "=");
577
+ const eqAllowed = [TOKEN_TYPES.IDENTIFIER, TOKEN_TYPES.KEY, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.QUOTE, TOKEN_TYPES.PREFIX_V, TOKEN_TYPES.PREFIX_P, TOKEN_TYPES.PREFIX_CLOSE, TOKEN_TYPES.IMPORT, TOKEN_TYPES.USE_MODULE, TOKEN_TYPES.END_KEYWORD, TOKEN_TYPES.TEXT, TOKEN_TYPES.LOGIC, TOKEN_TYPES.LOGIC_CLOSE, TOKEN_TYPES.STATIC_KEYWORD, TOKEN_TYPES.RUNTIME_KEYWORD, TOKEN_TYPES.FOR_EACH];
578
+ if (eqAllowed.includes(last_non_junk_type)) {
579
+ addToken(TOKEN_TYPES.EQUAL, "=");
884
580
  } else {
885
- const allowed = [TOKEN_TYPES.IDENTIFIER, TOKEN_TYPES.KEY, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.QUOTE, TOKEN_TYPES.PREFIX_JS, TOKEN_TYPES.PREFIX_V, TOKEN_TYPES.PREFIX_P, TOKEN_TYPES.IMPORT, TOKEN_TYPES.USE_MODULE, TOKEN_TYPES.END_KEYWORD, TOKEN_TYPES.TEXT, TOKEN_TYPES.LOGIC, TOKEN_TYPES.STATIC_KEYWORD, TOKEN_TYPES.RUNTIME_KEYWORD, TOKEN_TYPES.FOR_EACH];
886
- if (allowed.includes(last_non_junk_type)) {
887
- addToken(TOKEN_TYPES.EQUAL, "=");
888
- } else {
889
- addToken(TOKEN_TYPES.TEXT, "=");
890
- }
581
+ addToken(TOKEN_TYPES.TEXT, "=");
891
582
  }
892
583
  i++;
893
584
  continue;
894
585
  }
895
586
  if (char === ",") {
896
- if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
897
- addToken(TOKEN_TYPES.TEXT, ",");
587
+ const commaAllowed = [TOKEN_TYPES.VALUE, TOKEN_TYPES.IDENTIFIER, TOKEN_TYPES.QUOTE, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.PREFIX_V, TOKEN_TYPES.PREFIX_P, TOKEN_TYPES.PREFIX_CLOSE, TOKEN_TYPES.IMPORT, TOKEN_TYPES.USE_MODULE, TOKEN_TYPES.END_KEYWORD, TOKEN_TYPES.TEXT, TOKEN_TYPES.LOGIC, TOKEN_TYPES.LOGIC_CLOSE, TOKEN_TYPES.STATIC_KEYWORD, TOKEN_TYPES.RUNTIME_KEYWORD, TOKEN_TYPES.FOR_EACH];
588
+ if (commaAllowed.includes(last_non_junk_type)) {
589
+ addToken(TOKEN_TYPES.COMMA, ",");
898
590
  } else {
899
- const allowed = [TOKEN_TYPES.VALUE, TOKEN_TYPES.IDENTIFIER, TOKEN_TYPES.QUOTE, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.PREFIX_JS, TOKEN_TYPES.PREFIX_V, TOKEN_TYPES.PREFIX_P, TOKEN_TYPES.IMPORT, TOKEN_TYPES.USE_MODULE, TOKEN_TYPES.END_KEYWORD, TOKEN_TYPES.TEXT, TOKEN_TYPES.LOGIC, TOKEN_TYPES.STATIC_KEYWORD, TOKEN_TYPES.RUNTIME_KEYWORD, TOKEN_TYPES.FOR_EACH];
900
- if (allowed.includes(last_non_junk_type)) {
901
- addToken(TOKEN_TYPES.COMMA, ",");
902
- } else {
903
- addToken(TOKEN_TYPES.TEXT, ",");
904
- }
905
- }
906
- i++;
907
- continue;
908
- }
909
- if (char === ";") {
910
- if (isInAtBlockBody || (parenDepth > 0 && !isInInlineHead)) {
911
- addToken(TOKEN_TYPES.TEXT, ";");
912
- } else {
913
- const allowed = [TOKEN_TYPES.IDENTIFIER, TOKEN_TYPES.VALUE, TOKEN_TYPES.CLOSE_AT, TOKEN_TYPES.CLOSE_PAREN, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.QUOTE, TOKEN_TYPES.PREFIX_JS, TOKEN_TYPES.PREFIX_V, TOKEN_TYPES.PREFIX_P, TOKEN_TYPES.IMPORT, TOKEN_TYPES.USE_MODULE, TOKEN_TYPES.END_KEYWORD, TOKEN_TYPES.TEXT, TOKEN_TYPES.LOGIC, TOKEN_TYPES.STATIC_KEYWORD, TOKEN_TYPES.RUNTIME_KEYWORD, TOKEN_TYPES.FOR_EACH];
914
- if (allowed.includes(last_non_junk_type)) {
915
- addToken(TOKEN_TYPES.SEMICOLON, ";");
916
- // ONLY trigger body mode if we were actually in an At-Block header
917
- if (isInAtBlockHeader) {
918
- isInHeader = false;
919
- isInAtBlockHeader = false;
920
- isInAtBlockBody = true;
921
- }
922
- } else {
923
- addToken(TOKEN_TYPES.TEXT, ";");
924
- }
591
+ addToken(TOKEN_TYPES.TEXT, ",");
925
592
  }
926
593
  i++;
927
594
  continue;
@@ -934,7 +601,7 @@ function lexer(src, filename = "anonymous") {
934
601
  }
935
602
  }
936
603
  if (char === "\"" || char === "'") {
937
- const valTriggers = [TOKEN_TYPES.COLON, TOKEN_TYPES.EQUAL, TOKEN_TYPES.COMMA, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.OPEN_BRACKET, TOKEN_TYPES.OPEN_AT];
604
+ const valTriggers = [TOKEN_TYPES.COLON, TOKEN_TYPES.EQUAL, TOKEN_TYPES.COMMA, TOKEN_TYPES.ESCAPE, TOKEN_TYPES.OPEN_BRACKET];
938
605
  const wasValueTrigger = valTriggers.includes(last_non_junk_type);
939
606
  addToken(TOKEN_TYPES.QUOTE, char);
940
607
  i++;
@@ -950,28 +617,22 @@ function lexer(src, filename = "anonymous") {
950
617
  // This is the "Fallback" mode where we scan for identifiers, keys, or values.
951
618
  // It uses lookahead and context variables to guess the role of a word.
952
619
  let word = "";
953
- // Only Blocks ([ ]) allow ':' in their main identifier.
954
- // At-Blocks (@_) and Inlines (->( )) do NOT allow ':' in the ID.
955
620
  const isStartOfBlockId = (last_non_junk_type === TOKEN_TYPES.OPEN_BRACKET);
621
+ const isInNormalText = !isInHeader;
956
622
 
957
- let stopChars = "[](){}:=;,@>\"'#\\ \t\n\r!";
958
- if (isStartOfBlockId || (parenDepth > 0 && !isInInlineHead)) {
623
+ let stopChars = "[]{}:=,\"'#\\ \t\n\r!";
624
+ if (isStartOfBlockId) {
959
625
  stopChars = stopChars.replace(":", "");
960
626
  }
961
- const isInNormalText = !isInHeader && !isInInlineHead && !isInAtBlockBody;
962
627
  if (isInNormalText) {
963
- stopChars = "[]@()>_()\\#\n\r"; // In normal text, stop at markers, comments and newlines
628
+ stopChars = "[]\\#\n\r"; // In normal text, stop only at block markers, escapes, comments and newlines
964
629
  }
965
630
 
966
631
  while (i < src.length && !stopChars.includes(src[i])) {
967
632
  // Stop ONLY if $ is followed by { (Logic block start)
968
633
  if (src[i] === "$" && src[i + 1] === "{") break;
969
634
 
970
- // Lookahead for At-Block markers (_@ or @_)
971
- if (src[i] === "_" && src[i + 1] === "@") break;
972
- if (src[i] === "@" && src[i + 1] === "_") break;
973
-
974
- // Lookahead for 'static ${' or 'runtime ${' (only if we're not at the very start of the word scanning)
635
+ // Lookahead for 'static ${' or 'runtime ${' mid-word
975
636
  if (word.length > 0) {
976
637
  if (src[i] === "s" && src.slice(i, i + 7) === "static " && src[i + 7] === "$" && src[i + 8] === "{") break;
977
638
  if (src[i] === "s" && src.slice(i, i + 6) === "static" && src[i + 6] === "$" && src[i + 7] === "{") break;
@@ -979,53 +640,47 @@ function lexer(src, filename = "anonymous") {
979
640
  if (src[i] === "r" && src.slice(i, i + 7) === "runtime" && src[i + 7] === "$" && src[i + 8] === "{") break;
980
641
  }
981
642
 
982
- // Lookahead for -> marker in normal text
983
- if (!isInHeader && src[i] === "-" && src[i + 1] === ">") break;
984
-
985
643
  // Stop if we hit an ALLOWED prefix trigger
986
644
  if ((src[i] === "p" && src[i + 1] === "{") || (src[i] === "v" && src[i + 1] === "{")) {
987
645
  if (isInHeader || isInNormalText) break;
988
646
  }
989
- if (src[i] === "j" && src[i + 1] === "s" && src[i + 2] === "{") {
990
- if (isInHeader) break;
991
- }
992
647
  word += src[i];
993
648
  i++;
994
649
  }
995
650
 
996
651
  if (word.length > 0) {
997
652
  // Guess role based on context
998
- if (parenDepth > 0 && !isInInlineHead) {
999
- // Inside Inline Content (raw text)
1000
- addToken(TOKEN_TYPES.TEXT, word);
1001
- } else if (isInHeader || isInInlineHead) {
653
+ if (isInHeader) {
1002
654
  // Inside a structural header context
1003
- const isMainIdentifier = (
1004
- last_non_junk_type === TOKEN_TYPES.OPEN_BRACKET ||
1005
- last_non_junk_type === TOKEN_TYPES.OPEN_AT ||
1006
- (last_non_junk_type === TOKEN_TYPES.OPEN_PAREN && isInInlineHead)
1007
- );
655
+ const isMainIdentifier = last_non_junk_type === TOKEN_TYPES.OPEN_BRACKET;
1008
656
 
1009
657
  if (isMainIdentifier) {
1010
- if (word === end_keyword) {
658
+ if (word === end_keyword || word.startsWith(end_keyword + ":")) {
1011
659
  addToken(TOKEN_TYPES.END_KEYWORD, word);
1012
660
  }
1013
661
  else if (word === "import") addToken(TOKEN_TYPES.IMPORT, word);
1014
662
  else if (word === "$use-module") addToken(TOKEN_TYPES.USE_MODULE, word);
1015
663
  else if (word === "slot") addToken(TOKEN_TYPES.SLOT_KEYWORD, word);
1016
664
  else if (word === "for-each") addToken(TOKEN_TYPES.FOR_EACH, word);
1017
- else addToken(TOKEN_TYPES.IDENTIFIER, word);
665
+ else {
666
+ addToken(TOKEN_TYPES.IDENTIFIER, word);
667
+ }
1018
668
  } else {
1019
669
  // Use lookahead to distinguish KEY from VALUE
1020
670
  const p = peekStructural(i);
1021
671
  if (p === ":") {
1022
672
  addToken(TOKEN_TYPES.KEY, word);
673
+ if (word === "smark-raw") pendingSmarkRaw = true;
1023
674
  } else if (word === "static") {
1024
675
  addToken(TOKEN_TYPES.STATIC_KEYWORD, word);
1025
676
  } else if (word === "runtime") {
1026
677
  addToken(TOKEN_TYPES.RUNTIME_KEYWORD, word);
1027
678
  } else {
1028
679
  addToken(TOKEN_TYPES.VALUE, word);
680
+ if (pendingSmarkRaw) {
681
+ if (word === "true") hasSmarkRaw = true;
682
+ pendingSmarkRaw = false;
683
+ }
1029
684
  }
1030
685
  }
1031
686
  } else {
@@ -1052,120 +707,198 @@ function lexer(src, filename = "anonymous") {
1052
707
  }
1053
708
 
1054
709
  /**
1055
- * A safe parser that turns Javascript-like strings into real objects and arrays.
1056
- * It is built to handle data structures without running any dangerous code or
1057
- * accessing other parts of your project.
710
+ * Wraps your text in a color if colors are turned on.
1058
711
  *
1059
- * It supports:
1060
- * - Standard JSON: {"key": "val"}
1061
- * - Javascript-style: { key: 'val' }
1062
- * - Basic data: true, false, null, numbers, and strings
712
+ * @param {string} color - The color to use (red, green, yellow, blue, magenta, or cyan).
713
+ * @param {string} text - The text you want to color.
714
+ * @returns {string} - The colored text, or plain text if colors are off.
715
+ * @throws {Error} - Fails if you forget to provide the text.
716
+ */
717
+ function colorize(color, text) {
718
+ if (!text) throw new Error("argument 'text' is not defined.");
719
+ return text;
720
+ }
721
+
722
+ /**
723
+ * SomMark Errors
724
+ * Handles formatting and throwing errors with beautiful CLI coloring and pointers.
1063
725
  */
1064
- function safeDataParse(str) {
1065
- if (typeof str !== "string") return str;
1066
- const s = str.trim();
1067
- if (!s) return null;
1068
726
 
1069
- let index = 0;
727
+ // ========================================================================== //
728
+ // Message Formatting //
729
+ // ========================================================================== //
1070
730
 
1071
- function skipWhitespace() {
1072
- while (index < s.length && /\s/.test(s[index])) {
1073
- index++;
1074
- }
1075
- }
731
+ /**
732
+ * Processes a message by applying colors and formatting.
733
+ * Supports:
734
+ * - {line} : Adds a horizontal line
735
+ * - {N} : Adds a new line
736
+ * - <$color: Text$> : Adds color (red, yellow, green, blue, magenta, cyan)
737
+ *
738
+ * @param {string|string[]} text - The message or list of message parts to format.
739
+ * @returns {string} - The final formatted and colored string.
740
+ */
741
+ function formatMessage(text) {
742
+ const horizontal_rule = "\n" + colorize("blue", "-".repeat(90)) + "\n";
743
+ const pattern = /<\$([^:]+):([\s\S]*?)\$>/g;
1076
744
 
1077
- function parseValue() {
1078
- skipWhitespace();
1079
- const char = s[index];
745
+ if (Array.isArray(text)) {
746
+ text = text.join("");
747
+ }
1080
748
 
1081
- if (char === '{') return parseObject();
1082
- if (char === '[') return parseArray();
1083
- if (char === '"' || char === "'") return parseString();
749
+ // Apply {line} before color tags so the rule is never nested inside a color wrapper.
750
+ text = text.replaceAll("{line}", horizontal_rule);
751
+ text = text.replace(pattern, (match, color, content) => {
752
+ return colorize(color, content.trim());
753
+ });
754
+ text = text.replaceAll("{N}", "\n");
1084
755
 
1085
- // Primitives or Unquoted identifiers
1086
- return parsePrimitiveOrIdentifier();
1087
- }
756
+ text = text
757
+ .split("\n")
758
+ .filter(value => value !== "")
759
+ .join("\n")
760
+ .trim();
1088
761
 
1089
- function parseString() {
1090
- const quote = s[index++];
1091
- let result = "";
1092
- while (index < s.length && s[index] !== quote) {
1093
- if (s[index] === '\\') index++; // Skip escape
1094
- result += s[index++];
1095
- }
1096
- index++; // Skip closing quote
1097
- return result;
1098
- }
762
+ return text;
763
+ }
1099
764
 
1100
- function parseObject() {
1101
- index++; // Skip {
1102
- const obj = {};
1103
- skipWhitespace();
1104
-
1105
- while (index < s.length && s[index] !== '}') {
1106
- skipWhitespace();
1107
- // Key can be unquoted, quoted "key", or quoted 'key'
1108
- let key;
1109
- if (s[index] === '"' || s[index] === "'") {
1110
- key = parseString();
1111
- } else {
1112
- let keyMatch = s.slice(index).match(/^[a-zA-Z_$][a-zA-Z0-9_$]*/);
1113
- if (!keyMatch) break;
1114
- key = keyMatch[0];
1115
- index += key.length;
1116
- }
1117
-
1118
- skipWhitespace();
1119
- if (s[index] !== ':') break;
1120
- index++; // Skip :
1121
-
1122
- obj[key] = parseValue();
1123
-
1124
- skipWhitespace();
1125
- if (s[index] === ',') index++; // Skip optional comma
1126
- skipWhitespace();
1127
- }
1128
- index++; // Skip }
1129
- return obj;
1130
- }
765
+ /**
766
+ * Creates a detailed error message showing where the error happened in the code.
767
+ * It adds a line number, a snippet of the code, and a pointer (^) to the exact spot.
768
+ *
769
+ * @param {string} src - The original code being parsed.
770
+ * @param {Object} range - The location of the error (line and character).
771
+ * @param {string|null} filename - The name of the file (optional).
772
+ * @param {string|string[]} message - The error message to show.
773
+ * @param {string} typeName - The type of error (e.g., "Lexer" or "Parser").
774
+ * @returns {string[]} - A list of message parts that make up the final error report.
775
+ */
776
+ function formatErrorWithContext(src, range, filename, message, typeName) {
777
+ if (!src || !range || !range.start) return message;
1131
778
 
1132
- function parseArray() {
1133
- index++; // Skip [
1134
- const arr = [];
1135
- skipWhitespace();
779
+ const lines = src.split("\n");
780
+ const lineIndex = range.start.line;
781
+ const lineContent = lines[lineIndex] || "";
782
+ const pointerPadding = " ".repeat(range.start.character);
783
+ const sourceLabel = filename ? ` [${filename}]` : "";
1136
784
 
1137
- while (index < s.length && s[index] !== ']') {
1138
- arr.push(parseValue());
1139
- skipWhitespace();
1140
- if (s[index] === ',') index++; // Skip optional comma
1141
- skipWhitespace();
1142
- }
1143
- index++; // Skip ]
1144
- return arr;
1145
- }
785
+ const rangeInfo =
786
+ range.start.line === range.end.line
787
+ ? `from column <$yellow:${range.start.character}$> to <$yellow:${range.end.character}$>`
788
+ : `from line <$yellow:${range.start.line + 1}$>, column <$yellow:${range.start.character}$> to line <$yellow:${range.end.line + 1}$>, column <$yellow:${range.end.character}$>`;
1146
789
 
1147
- function parsePrimitiveOrIdentifier() {
1148
- const start = index;
1149
- while (index < s.length && /[a-zA-Z0-9_$+\-.]/.test(s[index])) {
1150
- index++;
1151
- }
1152
- const token = s.slice(start, index);
790
+ const formattedMessage = [
791
+ `{line}<$red:Here where error occurred${sourceLabel}:$>{N}${lineContent}{N}${pointerPadding}<$yellow:^$>{N}`,
792
+ `<$red:${typeName} Error:$> `,
793
+ ...(Array.isArray(message) ? message : [message]),
794
+ `{N}at line <$yellow:${range.start.line + 1}$>, ${rangeInfo}{N}`,
795
+ `{line}`
796
+ ];
1153
797
 
1154
- if (token === "true") return true;
1155
- if (token === "false") return false;
1156
- if (token === "null") return null;
1157
- if (!isNaN(Number(token))) return Number(token);
798
+ return formattedMessage;
799
+ }
1158
800
 
1159
- return token; // Fallback to string if it looks like an identifier
1160
- }
801
+ // ========================================================================== //
802
+ // Error Classes //
803
+ // ========================================================================== //
1161
804
 
1162
- try {
1163
- return parseValue();
1164
- } catch (e) {
1165
- return str; // Fallback to raw string if parsing fails
1166
- }
805
+ /** Base class for all SomMark errors that automatically formats messages for the terminal. */
806
+ class CustomError extends Error {
807
+ /**
808
+ * Creates a new error.
809
+ *
810
+ * @param {string|string[]} message - The text describing what went wrong.
811
+ * @param {string} name - The name of the error type.
812
+ */
813
+ constructor(message, name) {
814
+ super(message);
815
+ this.name = name;
816
+ this.message = formatMessage(`<$cyan:[${this.name}]$>:`) + "\n" + formatMessage(message);
817
+ if (Error.captureStackTrace) {
818
+ Error.captureStackTrace(this, this.constructor);
819
+ }
820
+ }
821
+ }
822
+
823
+ class ParserError extends CustomError {
824
+ constructor(message) { super(message, "Parser Error"); }
825
+ }
826
+
827
+ class LexerError extends CustomError {
828
+ constructor(message) { super(message, "Lexer Error"); }
829
+ }
830
+
831
+ class TranspilerError extends CustomError {
832
+ constructor(message) { super(message, "Transpiler Error"); }
833
+ }
834
+
835
+ class CLIError extends CustomError {
836
+ constructor(message) { super(message, "CLI Error"); }
1167
837
  }
1168
838
 
839
+ class RuntimeError extends CustomError {
840
+ constructor(message) { super(message, "Runtime Error"); }
841
+ }
842
+
843
+ class SommarkError extends CustomError {
844
+ constructor(message) { super(message, "SomMark Error"); }
845
+ }
846
+
847
+ // ========================================================================== //
848
+ // Error Dispatcher (Helper) //
849
+ // ========================================================================== //
850
+
851
+ /**
852
+ * A helper that creates an error "dispatcher" for a specific category.
853
+ *
854
+ * @param {string} type - The category of error (e.g., 'lexer', 'parser').
855
+ * @returns {Function} - A function that throws the formatted error.
856
+ */
857
+ function getError(type) {
858
+ const validate_msg = msg => (Array.isArray(msg) && msg.length > 0) || typeof msg === "string";
859
+ const typeNames = {
860
+ parser: "Parser",
861
+ transpiler: "Transpiler",
862
+ lexer: "Lexer",
863
+ cli: "CLI",
864
+ runtime: "Runtime",
865
+ sommark: "SomMark"
866
+ };
867
+ const ErrorClasses = {
868
+ parser: ParserError,
869
+ transpiler: TranspilerError,
870
+ lexer: LexerError,
871
+ cli: CLIError,
872
+ runtime: RuntimeError,
873
+ sommark: SommarkError
874
+ };
875
+
876
+ return (errorMessage, context = null) => {
877
+ if (validate_msg(errorMessage)) {
878
+ let finalMessage = errorMessage;
879
+ if (context && context.src && context.range) {
880
+ finalMessage = formatErrorWithContext(
881
+ context.src,
882
+ context.range,
883
+ context.filename,
884
+ errorMessage,
885
+ typeNames[type]
886
+ );
887
+ }
888
+ throw new ErrorClasses[type](finalMessage).message;
889
+ }
890
+ };
891
+ }
892
+
893
+ /** Helper to throw Parser errors. */
894
+ const parserError = getError("parser");
895
+
896
+ /** Helper to throw Runtime or Module errors. */
897
+ const runtimeError = getError("runtime");
898
+
899
+ /** Helper to throw general internal SomMark errors. */
900
+ const sommarkError = getError("sommark");
901
+
1169
902
  /**
1170
903
  * Calculates the Levenshtein distance between two strings.
1171
904
  * Used for "Did you mean?" suggestions and fuzzy matching in validation.
@@ -1287,7 +1020,7 @@ function validateName(
1287
1020
  : "must contain only letters, numbers, hyphens, underscores, or dollar signs ($)";
1288
1021
 
1289
1022
  if (!keyRegex.test(id)) {
1290
- parserError([`{line}<$red:Invalid ${name}:$><$blue: '${id}'$>{N}<$yellow:${name} ${ruleMessage}$> <$cyan: ${rule}.$>{line}`]);
1023
+ parserError([`{line}<$red:Invalid ${name}:$><$blue: '${id}'$>{N}<$yellow:${name} ${ruleMessage}$> <$cyan: ${rule}.$>`]);
1291
1024
  }
1292
1025
  }
1293
1026
 
@@ -1297,7 +1030,7 @@ function makeBlockNode() {
1297
1030
  type: BLOCK,
1298
1031
  structure: "Block",
1299
1032
  id: "",
1300
- args: {},
1033
+ props: {},
1301
1034
  body: [],
1302
1035
  depth: 0,
1303
1036
  range: {
@@ -1332,41 +1065,6 @@ function makeCommentNode() {
1332
1065
  }
1333
1066
  };
1334
1067
  }
1335
- /** Creates a new empty Inline node. */
1336
- function makeInlineNode() {
1337
- return {
1338
- type: INLINE,
1339
- structure: "Inline",
1340
- value: "",
1341
- id: "",
1342
- args: {},
1343
- depth: 0,
1344
- range: {
1345
- start: { line: 0, character: 0 },
1346
- end: { line: 0, character: 0 }
1347
- }
1348
- };
1349
- }
1350
-
1351
- // ========================================================================== //
1352
- // Node Creators //
1353
- // ========================================================================== //
1354
- /** Creates a new empty AtBlock node. */
1355
- function makeAtBlockNode() {
1356
- return {
1357
- type: ATBLOCK,
1358
- structure: "AtBlock",
1359
- id: "",
1360
- args: {},
1361
- content: "",
1362
- depth: 0,
1363
- range: {
1364
- start: { line: 0, character: 0 },
1365
- end: { line: 0, character: 0 }
1366
- }
1367
- };
1368
- }
1369
-
1370
1068
  /** Creates a new empty Logic node. */
1371
1069
  function makeLogicNode(type = RUNTIME_LOGIC) {
1372
1070
  return {
@@ -1399,53 +1097,66 @@ const updateData = (tokens, i) => {
1399
1097
 
1400
1098
  const errorMessage = (tokens, i, expectedValue, behindValue, frontText, filename = null) => {
1401
1099
  const current = tokens[i] || fallback;
1402
- const errorLineNumber = current.range.start.line;
1403
- current.range.start.character;
1100
+ const errorLine = current.range.start.line;
1101
+ const errorColStart = current.range.start.character;
1102
+ const errorColEnd = current.range.end.character;
1404
1103
  const source = current.source || filename;
1405
- const sourceLabel = source ? ` [${source}]` : "";
1406
1104
 
1105
+ // Collect all tokens on the error line for the source snippet
1407
1106
  let lineStartIndex = i;
1408
1107
  while (
1409
1108
  lineStartIndex > 0 &&
1410
1109
  tokens[lineStartIndex - 1] &&
1411
- tokens[lineStartIndex - 1].range.start.line === errorLineNumber &&
1110
+ tokens[lineStartIndex - 1].range.start.line === errorLine &&
1412
1111
  (tokens[lineStartIndex - 1].source || filename) === source
1413
1112
  ) {
1414
1113
  lineStartIndex--;
1415
1114
  }
1416
-
1417
1115
  let lineEndIndex = i;
1418
1116
  while (
1419
1117
  lineEndIndex < tokens.length - 1 &&
1420
1118
  tokens[lineEndIndex + 1] &&
1421
- tokens[lineEndIndex + 1].range.start.line === errorLineNumber &&
1119
+ tokens[lineEndIndex + 1].range.start.line === errorLine &&
1422
1120
  (tokens[lineEndIndex + 1].source || filename) === source
1423
1121
  ) {
1424
1122
  lineEndIndex++;
1425
1123
  }
1426
1124
 
1427
- // Get all tokens on the error line
1428
- const lineTokens = tokens.slice(lineStartIndex, lineEndIndex + 1);
1429
- const lineContent = lineTokens.map(t => t.value).join('');
1430
-
1431
- // Get content on the line before the error token
1432
- const tokensBeforeErrorOnLine = tokens.slice(lineStartIndex, i);
1433
- const contentBeforeErrorOnLine = tokensBeforeErrorOnLine.map(t => t.value).join('');
1434
-
1435
- const pointerPadding = " ".repeat(contentBeforeErrorOnLine.length);
1436
- const rangeInfo = current.range.start.line === current.range.end.line
1437
- ? `from column <$yellow:${current.range.start.character}$> to <$yellow:${current.range.end.character}$>`
1438
- : `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}$>`;
1439
-
1440
- return [
1441
- `<$blue:{line}$><$red:Here where error occurred${sourceLabel}:$>{N}${lineContent}{N}${pointerPadding}<$yellow:^$>{N}{N}`,
1442
- `<$red:${frontText ? frontText : "Expected token"}$>${!frontText ? " <$blue:'" + expectedValue + "'$>" : ""} ${behindValue ? "after <$blue:'" + behindValue + "'$>" : ""} at line <$yellow:${current.range.start.line + 1}$>,`,
1443
- ` ${rangeInfo}`,
1444
- `{N}<$yellow:Received:$> <$blue:'${current.value === "\n" ? "\\n' (newline)" : current.value}'$>`,
1445
- ` at line <$yellow:${current.range.start.line + 1}$>,`,
1446
- ` ${rangeInfo}{N}`,
1447
- "<$blue:{line}$>"
1448
- ];
1125
+ const lineContent = tokens.slice(lineStartIndex, lineEndIndex + 1).map(t => t.value).join('');
1126
+ const contentBefore = tokens.slice(lineStartIndex, i).map(t => t.value).join('');
1127
+ const pointerPadding = " ".repeat(contentBefore.length);
1128
+
1129
+ // Location header file, line, column
1130
+ const lineNum = errorLine + 1;
1131
+ const isMultiLine = current.range.start.line !== current.range.end.line;
1132
+ const colDisplay = isMultiLine
1133
+ ? `${errorColStart} line ${current.range.end.line + 1} col ${errorColEnd}`
1134
+ : errorColStart === errorColEnd ? `${errorColStart}` : `${errorColStart}–${errorColEnd}`;
1135
+
1136
+ // Error description avoid nested <$color:...$> tags (breaks the non-greedy regex)
1137
+ let errorDesc;
1138
+ if (frontText) {
1139
+ errorDesc = `<$red:${frontText}$>`;
1140
+ } else {
1141
+ errorDesc = `<$red:Expected$> <$blue:'${expectedValue}'$>`;
1142
+ if (behindValue) errorDesc += ` <$red:after$> <$blue:'${behindValue}'$>`;
1143
+ }
1144
+
1145
+ const tokenDisplay = current.value === "" ? "end of input"
1146
+ : current.value === "\n" ? "newline (\\n)"
1147
+ : `'${current.value}'`;
1148
+
1149
+ const parts = [`{line}`];
1150
+ if (source) parts.push(`<$cyan:File:$> ${source}{N}`);
1151
+ parts.push(`<$cyan:Line:$> <$yellow:${lineNum}$> <$cyan:Col:$> <$yellow:${colDisplay}$>{N}`);
1152
+ parts.push(`{line}`);
1153
+ parts.push(`<$red:Here where error occurred:$>{N}`);
1154
+ parts.push(` ${lineContent}{N}`);
1155
+ parts.push(` ${pointerPadding}<$yellow:^$>{N}`);
1156
+ parts.push(`${errorDesc}{N}`);
1157
+ parts.push(`<$yellow:Received:$> <$blue:${tokenDisplay}$>{N}`);
1158
+ parts.push(`{line}`);
1159
+ return parts;
1449
1160
  };
1450
1161
  // ========================================================================== //
1451
1162
  // Parse Key //
@@ -1467,6 +1178,88 @@ function parseKey(tokens, i) {
1467
1178
  return [key, i];
1468
1179
  }
1469
1180
  // ========================================================================== //
1181
+ // Read Prefix Key/Fallback from structured p{}/v{} tokens //
1182
+ // ========================================================================== //
1183
+ function readPrefixKeyFallback(tokens, i, prefixType = "p") {
1184
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.PREFIX_OPEN) i++;
1185
+ i = skipJunk(tokens, i);
1186
+
1187
+ let key = "";
1188
+ let fallback = undefined;
1189
+
1190
+ // Read key — must be quoted or unquoted identifier
1191
+ const keyToken = current_token(tokens, i);
1192
+ if (!keyToken || keyToken.type === TOKEN_TYPES.PREFIX_CLOSE) {
1193
+ parserError(errorMessage(tokens, i, "key", "{", 'Prefix requires a key — write p{key} or p{key | "fallback"}'));
1194
+ }
1195
+ if (keyToken.type === TOKEN_TYPES.QUOTE) {
1196
+ i++; // skip opening QUOTE
1197
+ while (current_token(tokens, i) &&
1198
+ current_token(tokens, i).type !== TOKEN_TYPES.QUOTE &&
1199
+ current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_CLOSE &&
1200
+ current_token(tokens, i).type !== TOKEN_TYPES.PIPELINE) {
1201
+ key += current_token(tokens, i).value;
1202
+ i++;
1203
+ }
1204
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.QUOTE) i++;
1205
+ } else if (keyToken.type === TOKEN_TYPES.KEY) {
1206
+ key = keyToken.value.trim();
1207
+ const isValidIdent = /^[a-zA-Z_$][a-zA-Z0-9_$-]*$/.test(key);
1208
+ const isNumeric = /^\d+$/.test(key);
1209
+ // p{} keys must be identifiers; v{} keys may also be positional integers
1210
+ if (!isValidIdent && !(prefixType === "v" && isNumeric)) {
1211
+ parserError(errorMessage(tokens, i, "key", "{", `Invalid prefix key '${key}' — must start with a letter, _ or $`));
1212
+ }
1213
+ i++;
1214
+ } else {
1215
+ parserError(errorMessage(tokens, i, "key", "{", "Invalid prefix key — must be a quoted string or identifier"));
1216
+ }
1217
+
1218
+ i = skipJunk(tokens, i);
1219
+
1220
+ // After key: only | or } is valid
1221
+ const afterKey = current_token(tokens, i);
1222
+ if (!afterKey || (afterKey.type !== TOKEN_TYPES.PIPELINE && afterKey.type !== TOKEN_TYPES.PREFIX_CLOSE)) {
1223
+ parserError(errorMessage(tokens, i, "| or }", key, "Expected '|' or '}' after prefix key"));
1224
+ }
1225
+
1226
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.PIPELINE) {
1227
+ i++; // skip PIPELINE
1228
+ i = skipJunk(tokens, i);
1229
+
1230
+ // Fallback must be a quoted string — any content allowed inside quotes
1231
+ const fallbackToken = current_token(tokens, i);
1232
+ if (!fallbackToken || fallbackToken.type === TOKEN_TYPES.PREFIX_CLOSE) {
1233
+ parserError(errorMessage(tokens, i, '"fallback"', "|", 'Expected a quoted fallback after \'|\' — write p{key | "default"}'));
1234
+ }
1235
+ if (fallbackToken.type === TOKEN_TYPES.QUOTE) {
1236
+ fallback = "";
1237
+ i++; // skip opening QUOTE
1238
+ while (current_token(tokens, i) &&
1239
+ current_token(tokens, i).type !== TOKEN_TYPES.QUOTE &&
1240
+ current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_CLOSE) {
1241
+ fallback += current_token(tokens, i).value;
1242
+ i++;
1243
+ }
1244
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.QUOTE) i++;
1245
+ } else {
1246
+ parserError(errorMessage(tokens, i, '"fallback"', "|", 'Fallback must be a quoted string — write p{key | "default"}'));
1247
+ }
1248
+ }
1249
+
1250
+ i = skipJunk(tokens, i);
1251
+
1252
+ // After key (or fallback): only } is valid
1253
+ const afterFallback = current_token(tokens, i);
1254
+ if (!afterFallback || afterFallback.type !== TOKEN_TYPES.PREFIX_CLOSE) {
1255
+ parserError(errorMessage(tokens, i, "}", key, "Unexpected content inside prefix — only one key and one optional fallback are allowed"));
1256
+ }
1257
+
1258
+ if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.PREFIX_CLOSE) i++;
1259
+
1260
+ return [key, fallback, i];
1261
+ }
1262
+ // ========================================================================== //
1470
1263
  // Parse Value //
1471
1264
  // ========================================================================== //
1472
1265
  function parseValue(tokens, i, placeholders = {}, variables = {}, allowLogic = true) {
@@ -1477,7 +1270,7 @@ function parseValue(tokens, i, placeholders = {}, variables = {}, allowLogic = t
1477
1270
  val = "";
1478
1271
  while (i < tokens.length && current_token(tokens, i).type !== TOKEN_TYPES.QUOTE) {
1479
1272
  const token = current_token(tokens, i);
1480
- if (token.type === TOKEN_TYPES.PREFIX_P || token.type === TOKEN_TYPES.PREFIX_JS || token.type === TOKEN_TYPES.PREFIX_V) {
1273
+ if (token.type === TOKEN_TYPES.PREFIX_P || token.type === TOKEN_TYPES.PREFIX_V) {
1481
1274
  const [resolvedVal, nextI] = parseValue(tokens, i, placeholders, variables, allowLogic);
1482
1275
  val += resolvedVal;
1483
1276
  i = nextI;
@@ -1492,73 +1285,56 @@ function parseValue(tokens, i, placeholders = {}, variables = {}, allowLogic = t
1492
1285
  }
1493
1286
 
1494
1287
  i++; // consume closing QUOTE
1495
- return [val, i, true];
1496
- } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_JS) {
1497
- val = current_token(tokens, i).value;
1498
- // V4 NATIVE DATA: Strip js{ } and parse safely
1499
- if (val.startsWith("js{") && val.endsWith("}")) {
1500
- const clean = val.slice(3, -1).trim();
1501
- val = safeDataParse(clean);
1502
- }
1503
- i++;
1504
- return [val, i, false];
1505
- } 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) {
1288
+ return [val, i, true];
1289
+ } else if (current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD || current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD) {
1506
1290
  if (!allowLogic) {
1507
1291
  parserError(errorMessage(tokens, i, "literal value", "", "Logic blocks are not allowed in this context."));
1508
1292
  }
1509
1293
  let isStatic = current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD;
1510
- let isRuntimeKeyword = current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD;
1511
- let nextI = i;
1512
-
1513
- if (isStatic || isRuntimeKeyword) {
1514
- nextI = skipJunk(tokens, i + 1);
1515
- if (!current_token(tokens, nextI) || current_token(tokens, nextI).type !== TOKEN_TYPES.LOGIC) {
1516
- // Treat as literal text if keyword is not followed by a logic block
1517
- return [current_token(tokens, i).value, i + 1, false];
1518
- }
1519
- i = nextI;
1294
+ let nextI = skipJunk(tokens, i + 1);
1295
+
1296
+ if (!current_token(tokens, nextI) || current_token(tokens, nextI).type !== TOKEN_TYPES.LOGIC_OPEN) {
1297
+ // Keyword not followed by ${ — treat as literal text
1298
+ return [current_token(tokens, i).value, i + 1, false];
1520
1299
  }
1521
1300
 
1522
- const logicToken = current_token(tokens, i);
1301
+ // Skip LOGIC_OPEN, read LOGIC body
1302
+ nextI++;
1303
+ const logicToken = current_token(tokens, nextI);
1523
1304
  const node = makeLogicNode(isStatic ? STATIC_LOGIC : RUNTIME_LOGIC);
1524
- node.code = logicToken.value;
1525
- node.range = logicToken.range;
1305
+ node.code = logicToken ? logicToken.value : "";
1306
+ node.range = logicToken ? logicToken.range : current_token(tokens, i).range;
1307
+ nextI++;
1308
+
1309
+ // Consume LOGIC_CLOSE if present
1310
+ if (current_token(tokens, nextI) && current_token(tokens, nextI).type === TOKEN_TYPES.LOGIC_CLOSE) {
1311
+ nextI++;
1312
+ }
1526
1313
 
1527
- return [node, i + 1, false];
1314
+ return [node, nextI, false];
1528
1315
  } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_V) {
1529
- val = current_token(tokens, i).value;
1530
- // V4.1.0 VARIABLE: Strip v{ } and resolve from local variables
1531
- if (val.startsWith("v{") && val.endsWith("}")) {
1532
- const key = val.slice(2, -1).trim();
1533
- if (variables[key] !== undefined) {
1534
- val = variables[key];
1535
- if (!variables.__consumed__) {
1536
- Object.defineProperty(variables, "__consumed__", {
1537
- value: new Set(),
1538
- enumerable: false,
1539
- configurable: true
1540
- });
1541
- }
1542
- variables.__consumed__.add(key);
1543
- } else {
1544
- val = getPrefixValue('v', key);
1316
+ i++; // consume PREFIX_V keyword
1317
+ const [vKey, vFallback, vNextI] = readPrefixKeyFallback(tokens, i, "v");
1318
+ i = vNextI;
1319
+ if (variables[vKey] !== undefined) {
1320
+ val = variables[vKey];
1321
+ if (!variables.__consumed__) {
1322
+ Object.defineProperty(variables, "__consumed__", {
1323
+ value: new Set(),
1324
+ enumerable: false,
1325
+ configurable: true
1326
+ });
1545
1327
  }
1328
+ variables.__consumed__.add(vKey);
1329
+ } else {
1330
+ val = vFallback !== undefined ? vFallback : getPrefixValue('v', vKey);
1546
1331
  }
1547
- i++;
1548
- return [val, i, false];
1549
- } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_C) {
1550
- val = current_token(tokens, i).value;
1551
- // PREFIX_C is preserved for the resolveModules expansion phase
1552
- i++;
1553
1332
  return [val, i, false];
1554
1333
  } else if (current_token(tokens, i).type === TOKEN_TYPES.PREFIX_P) {
1555
- val = current_token(tokens, i).value;
1556
- // V4 PLACEHOLDER: Strip p{ } and resolve from config
1557
- if (val.startsWith("p{") && val.endsWith("}")) {
1558
- const key = val.slice(2, -1).trim();
1559
- val = placeholders[key] !== undefined ? placeholders[key] : getPrefixValue('p', key);
1560
- }
1561
- i++;
1334
+ i++; // consume PREFIX_P keyword
1335
+ const [pKey, pFallback, pNextI] = readPrefixKeyFallback(tokens, i);
1336
+ i = pNextI;
1337
+ val = placeholders[pKey] !== undefined ? placeholders[pKey] : (pFallback !== undefined ? pFallback : getPrefixValue('p', pKey));
1562
1338
  return [val, i, false];
1563
1339
  } else {
1564
1340
  val = "";
@@ -1571,9 +1347,7 @@ function parseValue(tokens, i, placeholders = {}, variables = {}, allowLogic = t
1571
1347
  token.type === TOKEN_TYPES.COMMA ||
1572
1348
  token.type === TOKEN_TYPES.CLOSE_BRACKET ||
1573
1349
  token.type === TOKEN_TYPES.COLON ||
1574
- token.type === TOKEN_TYPES.SEMICOLON ||
1575
- token.type === TOKEN_TYPES.EXCLAMATION_MARK ||
1576
- token.type === TOKEN_TYPES.CLOSE_PAREN) break;
1350
+ token.type === TOKEN_TYPES.EXCLAMATION_MARK) break;
1577
1351
 
1578
1352
  if (token.type === TOKEN_TYPES.ESCAPE) {
1579
1353
  // Remove backslash
@@ -1589,34 +1363,6 @@ function parseValue(tokens, i, placeholders = {}, variables = {}, allowLogic = t
1589
1363
  return [val, i, false];
1590
1364
  }
1591
1365
  // ========================================================================== //
1592
- // Parse ',' //
1593
- // ========================================================================== //
1594
- function parseComma(tokens, i, beforeChar = "") {
1595
- i = skipJunk(tokens, i);
1596
- if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.COMMA) {
1597
- i++;
1598
- i = skipJunk(tokens, i);
1599
- updateData(tokens, i);
1600
-
1601
- if (
1602
- !current_token(tokens, i) ||
1603
- (current_token(tokens, i) &&
1604
- current_token(tokens, i).type !== TOKEN_TYPES.VALUE &&
1605
- current_token(tokens, i).type !== TOKEN_TYPES.ESCAPE &&
1606
- current_token(tokens, i).type !== TOKEN_TYPES.IDENTIFIER &&
1607
- current_token(tokens, i).type !== TOKEN_TYPES.KEY &&
1608
- current_token(tokens, i).type !== TOKEN_TYPES.QUOTE &&
1609
- current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_JS &&
1610
- current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_P)
1611
- ) {
1612
- parserError(errorMessage(tokens, i, "value", ","));
1613
- }
1614
- } else {
1615
- parserError(errorMessage(tokens, i, ",", beforeChar));
1616
- }
1617
- return i;
1618
- }
1619
- // ========================================================================== //
1620
1366
  // Parse ':' //
1621
1367
  // ========================================================================== //
1622
1368
  function parseColon(tokens, i, afterChar = "") {
@@ -1629,19 +1375,6 @@ function parseColon(tokens, i, afterChar = "") {
1629
1375
  updateData(tokens, i);
1630
1376
  return i;
1631
1377
  }
1632
- // ========================================================================== //
1633
- // Parse ';' //
1634
- // ========================================================================== //
1635
- function parseSemiColon(tokens, i, afterChar = "") {
1636
- i = skipJunk(tokens, i);
1637
- if (!current_token(tokens, i) || (current_token(tokens, i) && current_token(tokens, i).type !== TOKEN_TYPES.SEMICOLON)) {
1638
- parserError(errorMessage(tokens, i, ";", afterChar));
1639
- }
1640
- i++;
1641
- i = skipJunk(tokens, i);
1642
- updateData(tokens, i);
1643
- return i;
1644
- }
1645
1378
  /**
1646
1379
  * Parses a standard SomMark Block ([id] ... [end]).
1647
1380
  * Blocks are structural elements that can contain nested content.
@@ -1664,7 +1397,7 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
1664
1397
  updateData(tokens, i);
1665
1398
 
1666
1399
  const idToken = current_token(tokens, i);
1667
- if (!idToken || idToken.type === TOKEN_TYPES.EOF) {
1400
+ if (!idToken || idToken.type === TOKEN_TYPES.EOF || idToken.type === TOKEN_TYPES.CLOSE_BRACKET) {
1668
1401
  parserError(errorMessage(tokens, i, "Block ID", "[", "Missing Block Identifier"));
1669
1402
  }
1670
1403
  const id = idToken.value;
@@ -1716,10 +1449,9 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
1716
1449
  current_token(tokens, i).type !== TOKEN_TYPES.END_KEYWORD &&
1717
1450
  current_token(tokens, i).type !== TOKEN_TYPES.KEY &&
1718
1451
  current_token(tokens, i).type !== TOKEN_TYPES.QUOTE &&
1719
- current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_JS &&
1720
1452
  current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_V &&
1721
1453
  current_token(tokens, i).type !== TOKEN_TYPES.PREFIX_P &&
1722
- current_token(tokens, i).type !== TOKEN_TYPES.LOGIC &&
1454
+ current_token(tokens, i).type !== TOKEN_TYPES.LOGIC_OPEN &&
1723
1455
  current_token(tokens, i).type !== TOKEN_TYPES.STATIC_KEYWORD &&
1724
1456
  current_token(tokens, i).type !== TOKEN_TYPES.RUNTIME_KEYWORD)
1725
1457
  ) {
@@ -1766,9 +1498,9 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
1766
1498
  i = valueIndex;
1767
1499
 
1768
1500
  // Store Argument
1769
- blockNode.args[String(argIndex++)] = v;
1501
+ blockNode.props[String(argIndex++)] = v;
1770
1502
  if (k) {
1771
- blockNode.args[k] = v;
1503
+ blockNode.props[k] = v;
1772
1504
  }
1773
1505
  k = "";
1774
1506
  v = "";
@@ -1869,6 +1601,23 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
1869
1601
  i++;
1870
1602
  i = skipJunk(tokens, i);
1871
1603
  updateData(tokens, i);
1604
+
1605
+ // Named closing: [end:blockname] — the lexer emits END_KEYWORD "end:name" as one
1606
+ // token because ':' is stripped from stop chars at block-start (XML namespace support).
1607
+ const endValue = current.value.trim();
1608
+ if (endValue.includes(":")) {
1609
+ const closingName = endValue.slice(endValue.indexOf(":") + 1);
1610
+ if (!closingName) {
1611
+ parserError(errorMessage(tokens, i - 1, "block name", "", "Missing block name — write [end:blockname] to name the closing tag"));
1612
+ }
1613
+ const expected = end_stack[end_stack.length - 1];
1614
+ if (expected && closingName !== expected.id) {
1615
+ parserError(errorMessage(tokens, i - 1, closingName, "",
1616
+ `Mismatched closing tag: [end:${closingName}] cannot close '${closingName}' — '${expected.id}' is still open (opened at line ${expected.line}, col ${expected.col})`
1617
+ ));
1618
+ }
1619
+ }
1620
+
1872
1621
  if (
1873
1622
  !current_token(tokens, i) ||
1874
1623
  (current_token(tokens, i) && current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_BRACKET)
@@ -1903,147 +1652,6 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
1903
1652
  }
1904
1653
  return [blockNode, i];
1905
1654
  }
1906
- /**
1907
- * Parses an Inline Statement ((content) -> (id)).
1908
- * Inlines are fast, non-nesting formatting elements.
1909
- *
1910
- * @param {Object[]} tokens - Token stream.
1911
- * @param {number} i - Initial index.
1912
- * @param {Object} placeholders - Dynamic public API data.
1913
- * @returns {[Object, number]} The parsed Inline node and new index.
1914
- */
1915
- function parseInline(tokens, i, placeholders = {}, depth = 0) {
1916
- const inlineNode = makeInlineNode();
1917
- inlineNode.depth = depth;
1918
- const openParenToken = current_token(tokens, i);
1919
- inlineNode.range.start = openParenToken.range.start;
1920
-
1921
- // consume '('
1922
- i++;
1923
- updateData(tokens, i);
1924
-
1925
- // Phase 1: Content capture (Lexer provides high-level TEXT/ESCAPE tokens here)
1926
- while (i < tokens.length) {
1927
- const token = current_token(tokens, i);
1928
- if (!token || token.type === TOKEN_TYPES.CLOSE_PAREN) break;
1929
-
1930
- if (token.type === TOKEN_TYPES.ESCAPE) {
1931
- inlineNode.value += token.value.slice(1);
1932
- } else if (token.type !== TOKEN_TYPES.COMMENT) {
1933
- inlineNode.value += token.value;
1934
- }
1935
- i++;
1936
- }
1937
-
1938
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_PAREN) {
1939
- parserError(errorMessage(tokens, i, ")", "inline content"));
1940
- }
1941
- i++; // consume ')'
1942
-
1943
- // Collapse newlines and whitespace for "inline" behavior
1944
- inlineNode.value = inlineNode.value.replace(/\s+/g, " ").trim();
1945
-
1946
- i = skipJunk(tokens, i);
1947
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.THIN_ARROW) {
1948
- parserError(errorMessage(tokens, i, "->", ")"));
1949
- }
1950
- i++; // consume '->'
1951
-
1952
- i = skipJunk(tokens, i);
1953
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.OPEN_PAREN) {
1954
- parserError(errorMessage(tokens, i, "(", "->"));
1955
- }
1956
- i++; // consume '('
1957
- i = skipJunk(tokens, i);
1958
- const idToken = current_token(tokens, i);
1959
- const allowedInlineIdTypes = new Set([
1960
- TOKEN_TYPES.IDENTIFIER,
1961
- TOKEN_TYPES.KEY,
1962
- TOKEN_TYPES.IMPORT,
1963
- TOKEN_TYPES.USE_MODULE,
1964
- TOKEN_TYPES.SLOT_KEYWORD,
1965
- TOKEN_TYPES.FOR_EACH
1966
- ]);
1967
- if (!idToken || !allowedInlineIdTypes.has(idToken.type)) {
1968
- parserError(errorMessage(tokens, i, inline_id, "("));
1969
- }
1970
- inlineNode.id = idToken.value.trim();
1971
- validateName(inlineNode.id);
1972
-
1973
- i++; // consume ID
1974
- i = skipJunk(tokens, i);
1975
-
1976
- const hasArgsTrigger = current_token(tokens, i) && (
1977
- current_token(tokens, i).type === TOKEN_TYPES.COLON ||
1978
- current_token(tokens, i).type === TOKEN_TYPES.EQUAL
1979
- );
1980
-
1981
- if (hasArgsTrigger) {
1982
- const separator = current_token(tokens, i).value;
1983
- i++; // consume ':' or '='
1984
- i = skipJunk(tokens, i);
1985
-
1986
- // Ensure there is a value after the separator
1987
- const nextToken = current_token(tokens, i);
1988
- if (!nextToken || nextToken.type === TOKEN_TYPES.CLOSE_PAREN || nextToken.type === TOKEN_TYPES.COMMA) {
1989
- parserError(errorMessage(tokens, i, inline_value, separator, `Missing value after ${separator === "=" ? "equals" : "colon"}`));
1990
- }
1991
-
1992
- let k = "";
1993
- let v = "";
1994
- let argIndex = 0;
1995
-
1996
- while (i < tokens.length) {
1997
- i = skipJunk(tokens, i);
1998
- const token = current_token(tokens, i);
1999
- if (!token || token.type === TOKEN_TYPES.CLOSE_PAREN) break;
2000
-
2001
- if (token.type === TOKEN_TYPES.KEY) {
2002
- let [key, keyIndex] = parseKey(tokens, i);
2003
- k = key;
2004
- i = keyIndex;
2005
- i = skipJunk(tokens, i);
2006
- i = parseColon(tokens, i, "inline argument");
2007
- i = skipJunk(tokens, i);
2008
-
2009
- // Ensure there is a value after the colon
2010
- const nextToken = current_token(tokens, i);
2011
- if (!nextToken || nextToken.type === TOKEN_TYPES.CLOSE_PAREN || nextToken.type === TOKEN_TYPES.COMMA) {
2012
- parserError(errorMessage(tokens, i, inline_value, ":", "Missing value after colon"));
2013
- }
2014
- validateName(k);
2015
- }
2016
-
2017
- let [value, valueIndex, isQuoted] = parseValue(tokens, i, placeholders, {}, false);
2018
- v = value;
2019
- i = valueIndex;
2020
-
2021
- inlineNode.args[String(argIndex++)] = v;
2022
- if (k) {
2023
- inlineNode.args[k] = v;
2024
- }
2025
- k = "";
2026
- v = "";
2027
-
2028
- i = skipJunk(tokens, i);
2029
- if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.COMMA) {
2030
- i = parseComma(tokens, i, "inline argument");
2031
- } else {
2032
- break;
2033
- }
2034
- }
2035
- }
2036
-
2037
- i = skipJunk(tokens, i);
2038
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_PAREN) {
2039
- parserError(errorMessage(tokens, i, ")", inlineNode.id));
2040
- }
2041
- const finalParenToken = current_token(tokens, i);
2042
- i++; // consume ')'
2043
- inlineNode.range.end = finalParenToken.range.end;
2044
-
2045
- return [inlineNode, i];
2046
- }
2047
1655
  /**
2048
1656
  * Parses a stream of text tokens into a single Text node.
2049
1657
  * Handles unescaping and placeholder resolution.
@@ -2071,7 +1679,7 @@ function parseText(tokens, i, placeholders = {}, variables = {}, depth = 0, opti
2071
1679
  i++;
2072
1680
  } else if (token.type === TOKEN_TYPES.STATIC_KEYWORD || token.type === TOKEN_TYPES.RUNTIME_KEYWORD) {
2073
1681
  const nextIdx = skipJunk(tokens, i + 1);
2074
- if (tokens[nextIdx] && tokens[nextIdx].type === TOKEN_TYPES.LOGIC) {
1682
+ if (tokens[nextIdx] && tokens[nextIdx].type === TOKEN_TYPES.LOGIC_OPEN) {
2075
1683
  // Stop consuming text; this is the start of a logic block
2076
1684
  break;
2077
1685
  }
@@ -2090,44 +1698,31 @@ function parseText(tokens, i, placeholders = {}, variables = {}, depth = 0, opti
2090
1698
  }
2091
1699
  i++;
2092
1700
  } else if (token.type === TOKEN_TYPES.PREFIX_P) {
2093
- const val = token.value;
2094
- if (val.startsWith("p{") && val.endsWith("}")) {
2095
- const match = [val.slice(2, -1).trim(), val, 'p'];
2096
- const key = match[0];
2097
- const layer = match[2]; // 'p' or 'v'
2098
-
2099
- if (placeholders[key] !== undefined) {
2100
- textNode.text += String(placeholders[key]);
2101
- } else {
2102
- // Use the unique 'Unresolved Envelope' format via helper
2103
- textNode.text += getPrefixValue(layer, key);
2104
- }
1701
+ i++; // consume PREFIX_P keyword
1702
+ const [tpKey, tpFallback, tpNextI] = readPrefixKeyFallback(tokens, i);
1703
+ i = tpNextI;
1704
+ if (placeholders[tpKey] !== undefined) {
1705
+ textNode.text += String(placeholders[tpKey]);
2105
1706
  } else {
2106
- textNode.text += val;
1707
+ textNode.text += tpFallback !== undefined ? tpFallback : getPrefixValue('p', tpKey);
2107
1708
  }
2108
- i++;
2109
1709
  } else if (token.type === TOKEN_TYPES.PREFIX_V) {
2110
- const val = token.value;
2111
- if (val.startsWith("v{") && val.endsWith("}")) {
2112
- const key = val.slice(2, -1).trim();
2113
- if (variables[key] !== undefined) {
2114
- textNode.text += String(variables[key]);
2115
- if (!variables.__consumed__) {
2116
- Object.defineProperty(variables, "__consumed__", {
2117
- value: new Set(),
2118
- enumerable: false,
2119
- configurable: true
2120
- });
2121
- }
2122
- variables.__consumed__.add(key);
2123
- } else {
2124
- // Use the unique 'Unresolved Envelope' format via helper
2125
- textNode.text += getPrefixValue('v', key);
1710
+ i++; // consume PREFIX_V keyword
1711
+ const [tvKey, tvFallback, tvNextI] = readPrefixKeyFallback(tokens, i, "v");
1712
+ i = tvNextI;
1713
+ if (variables[tvKey] !== undefined) {
1714
+ textNode.text += String(variables[tvKey]);
1715
+ if (!variables.__consumed__) {
1716
+ Object.defineProperty(variables, "__consumed__", {
1717
+ value: new Set(),
1718
+ enumerable: false,
1719
+ configurable: true
1720
+ });
2126
1721
  }
1722
+ variables.__consumed__.add(tvKey);
2127
1723
  } else {
2128
- textNode.text += val;
1724
+ textNode.text += tvFallback !== undefined ? tvFallback : getPrefixValue('v', tvKey);
2129
1725
  }
2130
- i++;
2131
1726
  } else {
2132
1727
  break;
2133
1728
  }
@@ -2137,155 +1732,6 @@ function parseText(tokens, i, placeholders = {}, variables = {}, depth = 0, opti
2137
1732
  }
2138
1733
  return [textNode, i];
2139
1734
  }
2140
- /**
2141
- * Parses an At-Block (@_id_@: args; content @_end_@).
2142
- * At-Blocks maintain raw content preservation.
2143
- *
2144
- * @param {Object[]} tokens - Token stream.
2145
- * @param {number} i - Initial index.
2146
- * @param {string|null} filename - Source filename.
2147
- * @param {Object} placeholders - Dynamic public API data.
2148
- * @returns {[Object, number]} The At-Block node and new index.
2149
- */
2150
- function parseAtBlock(tokens, i, filename = null, placeholders = {}, depth = 0) {
2151
- const atBlockNode = makeAtBlockNode();
2152
- atBlockNode.depth = depth;
2153
- const openAtToken = current_token(tokens, i);
2154
- atBlockNode.range.start = openAtToken.range.start;
2155
-
2156
- // consume '@_'
2157
- i++;
2158
- i = skipJunk(tokens, i);
2159
- updateData(tokens, i);
2160
-
2161
- const idToken = current_token(tokens, i);
2162
- if (!idToken || idToken.type === TOKEN_TYPES.EOF) {
2163
- parserError(errorMessage(tokens, i, "AtBlock ID", "@_", "Missing AtBlock Identifier"));
2164
- }
2165
-
2166
- const id = idToken.value;
2167
- if (id.trim() === end_keyword) {
2168
- parserError(errorMessage(tokens, i, id, "", `'${id.trim()}' is a reserved keyword and cannot be used as an identifier.`));
2169
- }
2170
-
2171
- atBlockNode.id = id.trim();
2172
- validateName(atBlockNode.id);
2173
-
2174
- // consume ID
2175
- i++;
2176
- i = skipJunk(tokens, i);
2177
- updateData(tokens, i);
2178
-
2179
- if (!current_token(tokens, i) || (current_token(tokens, i) && current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_AT)) {
2180
- parserError(errorMessage(tokens, i, "_@", "at-block identifier"));
2181
- }
2182
- // consume '_@'
2183
- i++;
2184
- i = skipJunk(tokens, i);
2185
- updateData(tokens, i);
2186
-
2187
- if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.COLON) {
2188
- // consume ':'
2189
- i++;
2190
- i = skipJunk(tokens, i);
2191
-
2192
- // Ensure there is a value after the colon
2193
- const nextToken = current_token(tokens, i);
2194
- if (!nextToken || nextToken.type === TOKEN_TYPES.SEMICOLON || nextToken.type === TOKEN_TYPES.COMMA) {
2195
- parserError(errorMessage(tokens, i, at_value, ":", "Missing value after colon"));
2196
- }
2197
-
2198
- let k = "";
2199
- let v = "";
2200
- let argIndex = 0;
2201
-
2202
- while (i < tokens.length) {
2203
- i = skipJunk(tokens, i);
2204
- const token = current_token(tokens, i);
2205
- if (!token || token.type === TOKEN_TYPES.SEMICOLON) break;
2206
-
2207
- const isQuotedKey = token.type === TOKEN_TYPES.QUOTE && peek(tokens, i, 1) && (peek(tokens, i, 1).type === TOKEN_TYPES.KEY);
2208
-
2209
- if (token.type === TOKEN_TYPES.KEY || isQuotedKey) {
2210
- let [key, keyIndex] = parseKey(tokens, i);
2211
- k = key;
2212
- i = keyIndex;
2213
- i = skipJunk(tokens, i);
2214
- i = parseColon(tokens, i, "at-block argument");
2215
- i = skipJunk(tokens, i);
2216
-
2217
- // Ensure there is a value after the colon
2218
- const nextToken = current_token(tokens, i);
2219
- if (!nextToken || nextToken.type === TOKEN_TYPES.SEMICOLON || nextToken.type === TOKEN_TYPES.COMMA) {
2220
- parserError(errorMessage(tokens, i, at_value, ":", "Missing value after colon"));
2221
- }
2222
-
2223
- if (token.type === TOKEN_TYPES.KEY) {
2224
- validateName(k);
2225
- }
2226
- }
2227
-
2228
- let [value, valueIndex, isQuoted] = parseValue(tokens, i, placeholders, {}, false);
2229
- v = value;
2230
- i = valueIndex;
2231
-
2232
- atBlockNode.args[String(argIndex++)] = v;
2233
- if (k) {
2234
- atBlockNode.args[k] = v;
2235
- }
2236
- k = "";
2237
- v = "";
2238
-
2239
- i = skipJunk(tokens, i);
2240
- if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.COMMA) {
2241
- i = parseComma(tokens, i, "at-block argument");
2242
- } else {
2243
- break;
2244
- }
2245
- }
2246
- }
2247
-
2248
- // Semicolon is ALWAYS required after ID or ARGS
2249
- i = parseSemiColon(tokens, i, "at-block header");
2250
-
2251
- // Body Capture
2252
- i = skipJunk(tokens, i);
2253
- if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.TEXT) {
2254
- atBlockNode.content = current_token(tokens, i).value;
2255
- i++;
2256
- } else {
2257
- parserError(errorMessage(tokens, i, "content", "at-block body"));
2258
- }
2259
-
2260
- // End Marker (@_end_@)
2261
- i = skipJunk(tokens, i);
2262
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.OPEN_AT) {
2263
- parserError(errorMessage(tokens, i, "@_", "at-block content"));
2264
- }
2265
- i++; // consume '@_'
2266
- i = skipJunk(tokens, i);
2267
- const endToken = current_token(tokens, i);
2268
- if (!endToken || (endToken.type !== TOKEN_TYPES.END_KEYWORD && endToken.value.trim() !== end_keyword)) {
2269
- let extraInfo = "";
2270
- if (endToken && endToken.value) {
2271
- const dist = levenshtein(endToken.value.trim().toLowerCase(), "end");
2272
- if (dist > 0 && dist <= 2) {
2273
- extraInfo = ` (Did you mean '@_end_@'?)`;
2274
- }
2275
- }
2276
- parserError(errorMessage(tokens, i, "end", "AtBlock Body", extraInfo));
2277
- }
2278
- i++; // consume 'end'
2279
- i = skipJunk(tokens, i);
2280
- if (!current_token(tokens, i) || current_token(tokens, i).type !== TOKEN_TYPES.CLOSE_AT) {
2281
- parserError(errorMessage(tokens, i, "_@", "end marker"));
2282
- }
2283
- const closeAtToken = current_token(tokens, i);
2284
- i++; // consume '_@'
2285
- atBlockNode.range.end = closeAtToken.range.end;
2286
-
2287
- return [atBlockNode, i];
2288
- }
2289
1735
  // ========================================================================== //
2290
1736
  // Parse Comments //
2291
1737
  // ========================================================================== //
@@ -2341,72 +1787,36 @@ function parseNode(tokens, i, filename = null, placeholders = {}, variables = {}
2341
1787
  return parseBlock(tokens, i, filename, placeholders, variables, depth);
2342
1788
  }
2343
1789
  // ========================================================================== //
2344
- // Inline Statement or Text //
2345
- // ========================================================================== //
2346
- else if (current_token(tokens, i) && current_token(tokens, i).type === TOKEN_TYPES.OPEN_PAREN) {
2347
- let j = i + 1;
2348
- let parenCount = 1;
2349
- let foundArrow = false;
2350
- while (j < tokens.length) {
2351
- const token = tokens[j];
2352
- if (token.type === TOKEN_TYPES.OPEN_PAREN) {
2353
- parenCount++;
2354
- } else if (token.type === TOKEN_TYPES.CLOSE_PAREN) {
2355
- parenCount--;
2356
- }
2357
-
2358
- if (parenCount === 0) {
2359
- const nextIdx = skipJunk(tokens, j + 1);
2360
- if (tokens[nextIdx] && tokens[nextIdx].type === TOKEN_TYPES.THIN_ARROW) {
2361
- foundArrow = true;
2362
- }
2363
- break;
2364
- }
2365
- // Safe-guard: If we hit a [ or @, it's highly unlikely to be an inline statement content
2366
- // unless it's escaped, but lexer already handles [ and @ as structural tokens if not escaped.
2367
- if (token.type === TOKEN_TYPES.OPEN_BRACKET || token.type === TOKEN_TYPES.OPEN_AT) break;
2368
- j++;
2369
- }
2370
-
2371
- if (foundArrow) {
2372
- return parseInline(tokens, i, placeholders, depth);
2373
- }
2374
-
2375
- // Treat as text if not an inline
2376
- const textNode = makeTextNode();
2377
- textNode.text = current_token(tokens, i).value;
2378
- textNode.depth = depth;
2379
- textNode.range = current_token(tokens, i).range;
2380
- return [textNode, i + 1];
2381
- }
2382
- // ========================================================================== //
2383
1790
  // Logic Block //
2384
1791
  // ========================================================================== //
2385
- 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)) {
1792
+ else if (current_token(tokens, i) && (current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD || current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD)) {
2386
1793
  let isStatic = current_token(tokens, i).type === TOKEN_TYPES.STATIC_KEYWORD;
2387
- let isRuntimeKeyword = current_token(tokens, i).type === TOKEN_TYPES.RUNTIME_KEYWORD;
2388
1794
  let startRange = current_token(tokens, i).range;
2389
- let nextI = i;
1795
+ let nextI = skipJunk(tokens, i + 1);
2390
1796
 
2391
- if (isStatic || isRuntimeKeyword) {
2392
- nextI = skipJunk(tokens, i + 1);
2393
- if (!current_token(tokens, nextI) || current_token(tokens, nextI).type !== TOKEN_TYPES.LOGIC) {
2394
- // Treat as normal text if keyword is not followed by a logic block
2395
- return parseText(tokens, i, placeholders, variables, depth);
2396
- }
2397
- i = nextI;
1797
+ if (!current_token(tokens, nextI) || current_token(tokens, nextI).type !== TOKEN_TYPES.LOGIC_OPEN) {
1798
+ // Keyword not followed by ${ — treat as normal text
1799
+ return parseText(tokens, i, placeholders, variables, depth);
2398
1800
  }
2399
1801
 
2400
- const logicToken = current_token(tokens, i);
1802
+ // Skip LOGIC_OPEN, read LOGIC body
1803
+ nextI++;
1804
+ const logicToken = current_token(tokens, nextI);
2401
1805
  const node = makeLogicNode(isStatic ? STATIC_LOGIC : RUNTIME_LOGIC);
2402
- node.code = logicToken.value;
1806
+ node.code = logicToken ? logicToken.value : "";
2403
1807
  node.depth = depth;
2404
1808
  node.range = {
2405
- start: (isStatic || isRuntimeKeyword) ? startRange.start : logicToken.range.start,
2406
- end: logicToken.range.end
1809
+ start: startRange.start,
1810
+ end: logicToken ? logicToken.range.end : startRange.end
2407
1811
  };
1812
+ nextI++;
1813
+
1814
+ // Consume LOGIC_CLOSE if present
1815
+ if (current_token(tokens, nextI) && current_token(tokens, nextI).type === TOKEN_TYPES.LOGIC_CLOSE) {
1816
+ nextI++;
1817
+ }
2408
1818
 
2409
- return [node, i + 1];
1819
+ return [node, nextI];
2410
1820
  }
2411
1821
  // ========================================================================== //
2412
1822
  // Text or Placeholder //
@@ -2421,12 +1831,6 @@ function parseNode(tokens, i, filename = null, placeholders = {}, variables = {}
2421
1831
  current_token(tokens, i).type === TOKEN_TYPES.PREFIX_P)
2422
1832
  ) {
2423
1833
  return parseText(tokens, i, placeholders, variables, depth);
2424
- }
2425
- // ========================================================================== //
2426
- // Atblock //
2427
- // ========================================================================== //
2428
- else if (current_token(tokens, i) && (current_token(tokens, i).type === TOKEN_TYPES.OPEN_AT)) {
2429
- return parseAtBlock(tokens, i, filename, placeholders, depth);
2430
1834
  } else {
2431
1835
  // FALLBACK: Treat any other token as TEXT to avoid infinite loops and allow literal content
2432
1836
  const textNode = makeTextNode();
@@ -2474,7 +1878,7 @@ function parser(tokens, filename = null, placeholders = {}, variables = {}) {
2474
1878
  const val = token.value.trim().toLowerCase();
2475
1879
  if (val === "") return "";
2476
1880
  const dist = levenshtein(val, "end");
2477
- if (dist > 0 && dist <= 2) return ` (Did you mean <$cyan:'[end]'$>?)`;
1881
+ if (dist > 0 && dist <= 2) return ` Did you mean '[end]'?`;
2478
1882
  }
2479
1883
  return "";
2480
1884
  };