sommark 4.0.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/validator.js CHANGED
@@ -18,18 +18,80 @@ import { transpilerError } from "./errors.js";
18
18
  const runValidations = (node, target, instance) => {
19
19
  if (!target || !target.options) return;
20
20
  const rules = target.options.rules || {};
21
- const id = (target.id) ? (Array.isArray(target.id) ? target.id.join(" | ") : target.id) : "Unknown";
22
- const context = instance ? { src: instance.src, range: node.range, filename: instance.filename } : null;
23
-
24
- // -- Structural Integrity (Self-Closing) ------------------------------ //
25
- if (rules.is_self_closing && (node.type === "Block" && (node.body && node.body.length > 0))) {
26
- transpilerError(
27
- [
28
- "<$red:Validation Error:$> ",
29
- `<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is self-closing and cannot have children (body).$>`
30
- ],
31
- context
32
- );
21
+ const id = (target.id) ? (Array.isArray(target.id) ? target.id.join(" | ") : target.id) : (node.id || "Unknown");
22
+ const errorRange = node.range ? {
23
+ start: node.range.start,
24
+ end: {
25
+ line: node.range.start.line,
26
+ character: node.range.start.character + (node.id || "").length + 2
27
+ }
28
+ } : null;
29
+ const context = instance ? { src: instance.src, range: errorRange, filename: instance.filename } : null;
30
+
31
+ // -- Structural Integrity (Empty Body / Self-Closing) ----------------- //
32
+ const isEmptyBodyTarget = rules.is_empty_body || rules.is_self_closing;
33
+
34
+ // -- Node Type Validation --------------------------------------------- //
35
+ if (target.options.type) {
36
+ const allowedTypes = Array.isArray(target.options.type) ? target.options.type : [target.options.type];
37
+ const hasAny = allowedTypes.includes("any");
38
+ if (!hasAny && !allowedTypes.includes(node.structure)) {
39
+ const isReserved = ["import", "$use-module", "slot", "for-each"].includes(id.toLowerCase());
40
+ const msg = isReserved
41
+ ? `<$yellow:Reserved keyword$> <$blue:'${id}'$> <$yellow:is strictly defined as a [${allowedTypes.join(", ")}] structure node, but was used as a [${node.structure}] structure node.$>`
42
+ : `<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is defined as type(s) [${allowedTypes.join(", ")}], but was used as a [${node.structure}] structure node.$>`;
43
+
44
+ transpilerError(
45
+ [
46
+ "{N}",
47
+ msg
48
+ ],
49
+ context
50
+ );
51
+ }
52
+ }
53
+
54
+ if (isEmptyBodyTarget && node.type === "Block" && !node.isSelfClosing && node.body) {
55
+ const hasContent = node.body.some(child => {
56
+ if (child.type === "Text") {
57
+ return (child.text || "").trim().length > 0;
58
+ }
59
+ return true; // Any other node type (Block, Inline, etc.) counts as content
60
+ });
61
+
62
+ if (hasContent) {
63
+ transpilerError(
64
+ [
65
+ "{N}",
66
+ "<$red:[Validation Error]:$>{N}",
67
+ `<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is defined as an empty-body component and cannot have children.$>`
68
+ ],
69
+ context
70
+ );
71
+ }
72
+ }
73
+
74
+ // -- Arguments Validation (Required Args) ----------------------------- //
75
+ const isStructural = node.type === "Block" || node.type === "AtBlock";
76
+ if (isStructural && rules.required_args && Array.isArray(rules.required_args)) {
77
+ const missingArgs = rules.required_args.filter(arg => {
78
+ // Check if the argument exists in named args or as a positional arg (if arg is a number)
79
+ if (typeof arg === "number") {
80
+ return node.args[arg] === undefined;
81
+ }
82
+ return node.args[arg] === undefined;
83
+ });
84
+
85
+ if (missingArgs.length > 0) {
86
+ transpilerError(
87
+ [
88
+ "{N}",
89
+ `<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is missing required arguments:$> <$red:${missingArgs.join(", ")}$>{N}`,
90
+ `<$blue:Please ensure these arguments are provided in the template usage.$>`
91
+ ],
92
+ context
93
+ );
94
+ }
33
95
  }
34
96
  };
35
97
 
@@ -60,7 +122,17 @@ export function validateAST(ast, mapperFile, instance) {
60
122
 
61
123
  // 1. Identify Target
62
124
  if (node.id) {
63
- const target = mapperFile.get(node.id) || (mapperFile.getUnknownTag ? mapperFile.getUnknownTag(node) : null);
125
+ let target = null;
126
+ const lowerId = node.id.toLowerCase();
127
+ if (["import", "$use-module", "slot", "for-each"].includes(lowerId)) {
128
+ target = {
129
+ id: lowerId,
130
+ options: { type: "Block" }
131
+ };
132
+ } else {
133
+ target = mapperFile.get(node.id) || (mapperFile.getUnknownTag ? mapperFile.getUnknownTag(node) : null);
134
+ }
135
+
64
136
  if (target) {
65
137
  runValidations(node, target, instance);
66
138
  }
package/formatter/tag.js CHANGED
@@ -69,6 +69,8 @@ class TagBuilder {
69
69
  const id = this.tagName.toLowerCase();
70
70
  const isCodeStyleOrScript = ["style", "script"].includes(id);
71
71
  let inline_style = "";
72
+ const useClassFallback = options.fallbackTarget === "class";
73
+ const classSet = new Set();
72
74
 
73
75
  // 1. Initial CSS Variable/Style processing
74
76
  if (!isCodeStyleOrScript && args.style) {
@@ -83,12 +85,20 @@ class TagBuilder {
83
85
  }
84
86
  }
85
87
 
86
- // 2. Attribute Dispatching
88
+ // 2. Pre-collect native classes if using class fallback
89
+ if (useClassFallback) {
90
+ if (args.class) {
91
+ String(args.class).split(/\s+/).filter(Boolean).forEach(c => classSet.add(c));
92
+ }
93
+ }
94
+
95
+ // 3. Attribute Dispatching
87
96
  const keys = Object.keys(args).filter(arg => isNaN(parseInt(arg)));
88
97
  keys.forEach(key => {
89
98
  if (!isNaN(parseInt(key))) return; // Skip numeric positional arguments
90
99
  if (key === "style") return;
91
100
  if (isCodeStyleOrScript && key === "scoped") return;
101
+ if (useClassFallback && key === "class") return;
92
102
 
93
103
  const isDimensionAttributeSupported = ["img", "video", "svg", "canvas", "iframe", "object", "embed"].includes(id);
94
104
  const isWidthOrHeight = key === "width" || key === "height";
@@ -99,21 +109,35 @@ class TagBuilder {
99
109
 
100
110
  const k = isEvent ? key.toLowerCase() : (isNative || isCustom) ? key : kebabize(key);
101
111
 
102
- if (isCodeStyleOrScript) {
103
- // Specialized tags: only render standard attributes, no styling fallback
104
- this.#attr.push(`${k}="${escapeHTML(String(args[key]))}"`);
112
+ if (isCodeStyleOrScript || options.fallbackTarget === false) {
113
+ // Specialized tags or fallback disabled: render standard attributes, no styling fallback
114
+ const val = typeof args[key] === "object" ? JSON.stringify(args[key]) : args[key];
115
+ this.#attr.push(`${k}="${escapeHTML(String(val))}"`);
105
116
  } else {
106
- // Standard elements: process smart styling fallbacks for non-native props
117
+ // Standard elements: process smart fallbacks
107
118
  if (isEvent || ((isNative || isCustom) && (!isWidthOrHeight || isDimensionAttributeSupported)) || isDataOrAria) {
108
119
  const val = typeof args[key] === "object" ? JSON.stringify(args[key]) : args[key];
109
120
  this.#attr.push(`${k}="${escapeHTML(String(val))}"`);
110
121
  } else {
111
- const val = typeof args[key] === "object" ? JSON.stringify(args[key]) : args[key];
112
- inline_style += `${k}:${val};`;
122
+ if (useClassFallback) {
123
+ const val = args[key];
124
+ if (val === true || val === "true") {
125
+ classSet.add(k);
126
+ } else if (val !== false && val !== "false" && val !== null && val !== undefined) {
127
+ classSet.add(`${k}-${val}`);
128
+ }
129
+ } else {
130
+ const val = typeof args[key] === "object" ? JSON.stringify(args[key]) : args[key];
131
+ inline_style += `${k}:${val};`;
132
+ }
113
133
  }
114
134
  }
115
135
  });
116
136
 
137
+ if (useClassFallback && classSet.size > 0) {
138
+ this.#attr.push(`class="${escapeHTML([...classSet].join(" "))}"`);
139
+ }
140
+
117
141
  if (inline_style) {
118
142
  // V4 DYNAMIC CSS: Automatically wrap CSS variables in var()
119
143
  const processedStyle = inline_style.replace(/(^|[^\w\-_$])(--[\w\-_$]+)(?![\w\-_$]|:)/g, "$1var($2)");
package/grammar.ebnf CHANGED
@@ -18,18 +18,28 @@ EscapeChar = "\", ? any character except WhiteSpace ?;
18
18
 
19
19
  (* "Junk" refers to whitespace and comments which the parser skips in headers *)
20
20
  Comment = "#", { ? any character except "\n" ? }, "\n";
21
- Junk = { WhiteSpace | Comment };
21
+ CommentBlock = "###", { ? any character ? }, "###";
22
+ Junk = { WhiteSpace | Comment | CommentBlock };
22
23
 
23
24
  (* Prefix Layers for Dynamic Data *)
24
25
  PrefixJS = "js{", { ? any character ? }, "}";
25
26
  PrefixP = "p{", { ? any character ? }, "}";
26
- PrefixLayer = PrefixJS | PrefixP;
27
+ PrefixV = "v{", { ? any character ? }, "}";
28
+ PrefixLayer = PrefixJS | PrefixP | PrefixV;
29
+
30
+ (* Static / Runtime Logic Blocks *)
31
+ LogicBlock = ( "static" | "runtime" ), [ " " ], "${", { ? any character ? }, "}$";
27
32
 
28
33
  (* ========================================== *)
29
34
  (* Block Syntax (Containers) *)
30
35
  (* ========================================== *)
31
36
 
32
- Block = BlockOpen, BlockBody, BlockEnd;
37
+ Block = StandardBlock | SelfClosingBlock;
38
+
39
+ StandardBlock = BlockOpen, BlockBody, BlockEnd;
40
+
41
+ (* Self-Closing Blocks end with an exclamation mark (!) *)
42
+ SelfClosingBlock = "[", Junk, BlockID, Junk, [ "=", Junk, BlockArgs, Junk ], "!", Junk, "]";
33
43
 
34
44
  (* Flexible Header with Junk-Skipping. Blocks allow colons in ID. *)
35
45
  BlockOpen = "[", Junk, BlockID, Junk, [ "=", Junk, BlockArgs, Junk ], "]";
@@ -41,7 +51,7 @@ BlockArgs = BlockArg, { Junk, ",", Junk, BlockArg };
41
51
  BlockArg = [ BlockKey, Junk, ":", Junk ], Value;
42
52
 
43
53
  BlockKey = BlockID | QuotedString;
44
- Value = QuotedString | PrefixLayer | RawValue;
54
+ Value = QuotedString | PrefixLayer | LogicBlock | RawValue;
45
55
 
46
56
  QuotedString = ("'" | '"'), { ? any character ? | EscapeChar }, ("'" | '"');
47
57
  RawValue = { ? any char except ",", Junk, "]" ? | EscapeChar };
@@ -50,15 +60,16 @@ RawValue = { ? any char except ",", Junk, "]" ? | EscapeChar };
50
60
  (* Inline Statement Syntax *)
51
61
  (* ========================================== *)
52
62
 
53
- (* Inlines use SimpleID (NO COLONS allowed in ID) *)
54
- InlineStatement = "(", InlineContent, ")", Junk, "->", Junk, "(", SimpleID, [ Junk, ":", Junk, InlineArgs ], ")";
63
+ (* Inlines use SimpleID (NO COLONS allowed in ID unless key-value) *)
64
+ InlineStatement = "(", InlineContent, ")", Junk, "->", Junk, "(", SimpleID, [ Junk, ( "=" | ":" ), Junk, InlineArgs ], ")";
55
65
 
56
66
  (* Content allows balanced parentheses and placeholders *)
57
- InlineContent = { ? any char except ")" ? | BalancedParen | EscapeChar | PrefixP };
67
+ InlineContent = { ? any char except ")" ? | BalancedParen | EscapeChar | PrefixP | PrefixV };
58
68
  BalancedParen = "(", InlineContent, ")";
59
69
 
60
- (* Inline Arguments: Positional Only *)
61
- InlineArgs = Value, { Junk, ",", Junk, Value };
70
+ (* Inline Arguments: Positional and Named support *)
71
+ InlineArgs = InlineArg, { Junk, ",", Junk, InlineArg };
72
+ InlineArg = [ SimpleID, Junk, ":", Junk ], Value;
62
73
 
63
74
  (* ========================================== *)
64
75
  (* At-Block Syntax (Raw Containers) *)
@@ -83,4 +94,4 @@ AtBody = { ? any character ? | EscapeChar };
83
94
  Document = { Block | InlineStatement | AtBlock | TextContent | Comment | WhiteSpace };
84
95
 
85
96
  (* Text Content: Plain text with fallback behavior for symbols *)
86
- TextContent = { ? any char except "[", "@_", "(", "#" ? | EscapeChar | PrefixP };
97
+ TextContent = { ? any char except "[", "@_", "(", "#" ? | EscapeChar | PrefixP | PrefixV | LogicBlock };
@@ -28,7 +28,7 @@ export function safeDataParse(str) {
28
28
  if (char === '{') return parseObject();
29
29
  if (char === '[') return parseArray();
30
30
  if (char === '"' || char === "'") return parseString();
31
-
31
+
32
32
  // Primitives or Unquoted identifiers
33
33
  return parsePrimitiveOrIdentifier();
34
34
  }
@@ -97,12 +97,12 @@ export function safeDataParse(str) {
97
97
  index++;
98
98
  }
99
99
  const token = s.slice(start, index);
100
-
100
+
101
101
  if (token === "true") return true;
102
102
  if (token === "false") return false;
103
103
  if (token === "null") return null;
104
104
  if (!isNaN(Number(token))) return Number(token);
105
-
105
+
106
106
  return token; // Fallback to string if it looks like an identifier
107
107
  }
108
108
 
@@ -0,0 +1,91 @@
1
+ // Premium, dependency-free interactive terminal spinner for compilation feedback
2
+ const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3
+ let spinnerIndex = 0;
4
+ let activeSpinner = null;
5
+ let spinnerDepth = 0;
6
+
7
+ let originalStdoutWrite = null;
8
+ let originalStderrWrite = null;
9
+ let redrawTimeout = null;
10
+
11
+ export function startSpinner() {
12
+ if (process.stdout.isTTY && !activeSpinner) {
13
+ // Hide terminal cursor for a clean premium visual feel
14
+ process.stdout.write("\x1b[?25l");
15
+
16
+ // Print the first frame immediately
17
+ const frame = spinnerFrames[spinnerIndex];
18
+ process.stdout.write(`\r\x1b[38;5;39m${frame}\x1b[0m \x1b[1mSomMark:\x1b[0m \x1b[38;5;208mCompiling template...\x1b[0m`);
19
+ spinnerIndex = (spinnerIndex + 1) % spinnerFrames.length;
20
+
21
+ // Intercept stdout and stderr writes to keep the spinner on the very bottom line
22
+ originalStdoutWrite = process.stdout.write;
23
+ process.stdout.write = function(chunk, encoding, callback) {
24
+ originalStdoutWrite.call(process.stdout, "\r\x1b[K");
25
+ const res = originalStdoutWrite.call(process.stdout, chunk, encoding, callback);
26
+ if (activeSpinner) {
27
+ if (redrawTimeout) clearTimeout(redrawTimeout);
28
+ redrawTimeout = setTimeout(() => {
29
+ if (activeSpinner && originalStdoutWrite) {
30
+ const f = spinnerFrames[spinnerIndex];
31
+ originalStdoutWrite.call(process.stdout, `\r\x1b[38;5;39m${f}\x1b[0m \x1b[1mSomMark:\x1b[0m \x1b[38;5;208mCompiling template...\x1b[0m`);
32
+ }
33
+ }, 0);
34
+ }
35
+ return res;
36
+ };
37
+
38
+ originalStderrWrite = process.stderr.write;
39
+ process.stderr.write = function(chunk, encoding, callback) {
40
+ if (originalStdoutWrite) {
41
+ originalStdoutWrite.call(process.stdout, "\r\x1b[K");
42
+ }
43
+ const res = originalStderrWrite.call(process.stderr, chunk, encoding, callback);
44
+ if (activeSpinner) {
45
+ if (redrawTimeout) clearTimeout(redrawTimeout);
46
+ redrawTimeout = setTimeout(() => {
47
+ if (activeSpinner && originalStdoutWrite) {
48
+ const f = spinnerFrames[spinnerIndex];
49
+ originalStdoutWrite.call(process.stdout, `\r\x1b[38;5;39m${f}\x1b[0m \x1b[1mSomMark:\x1b[0m \x1b[38;5;208mCompiling template...\x1b[0m`);
50
+ }
51
+ }, 0);
52
+ }
53
+ return res;
54
+ };
55
+
56
+ activeSpinner = setInterval(() => {
57
+ const frame = spinnerFrames[spinnerIndex];
58
+ if (originalStdoutWrite) {
59
+ originalStdoutWrite.call(process.stdout, `\r\x1b[38;5;39m${frame}\x1b[0m \x1b[1mSomMark:\x1b[0m \x1b[38;5;208mCompiling template...\x1b[0m`);
60
+ } else {
61
+ process.stdout.write(`\r\x1b[38;5;39m${frame}\x1b[0m \x1b[1mSomMark:\x1b[0m \x1b[38;5;208mCompiling template...\x1b[0m`);
62
+ }
63
+ spinnerIndex = (spinnerIndex + 1) % spinnerFrames.length;
64
+ }, 80);
65
+ }
66
+ spinnerDepth++;
67
+ }
68
+
69
+ export function stopSpinner() {
70
+ spinnerDepth--;
71
+ if (spinnerDepth <= 0) {
72
+ spinnerDepth = 0;
73
+ if (activeSpinner) {
74
+ clearInterval(activeSpinner);
75
+ activeSpinner = null;
76
+
77
+ // Restore original writes
78
+ if (originalStdoutWrite) {
79
+ process.stdout.write = originalStdoutWrite;
80
+ originalStdoutWrite = null;
81
+ }
82
+ if (originalStderrWrite) {
83
+ process.stderr.write = originalStderrWrite;
84
+ originalStderrWrite = null;
85
+ }
86
+
87
+ // Clear the spinner line and restore the terminal cursor
88
+ process.stdout.write("\r\x1b[K\x1b[?25h");
89
+ }
90
+ }
91
+ }
package/helpers/utils.js CHANGED
@@ -168,3 +168,49 @@ export function levenshtein(a, b) {
168
168
  }
169
169
  return matrix[b.length][a.length];
170
170
  }
171
+
172
+ // -- Unresolved Placeholder Helpers ---------------------------------------- //
173
+
174
+ export const UNRESOLVED_PREFIX = "SOMMARK_UNRESOLVED";
175
+ export const UNRESOLVED_SUFFIX = "SOMMARK";
176
+
177
+ /**
178
+ * Official method to get the unique envelope for an unresolved prefix value.
179
+ * @param {string} prefix - The layer ('p' or 'v').
180
+ * @param {string} expectedValue - The placeholder key.
181
+ * @returns {string} - The unique envelope string.
182
+ */
183
+ export function getPrefixValue(prefix, expectedValue) {
184
+ if (!prefix || (prefix !== "p" && prefix !== "v")) {
185
+ sommarkError([
186
+ `<$red:getPrefixValue Error:$> {N}`,
187
+ `<$yellow:prefix must be 'p' or 'v'. Received:$> <$cyan:'${prefix}'$>`
188
+ ]);
189
+ }
190
+
191
+ if (!expectedValue || typeof expectedValue !== "string" || expectedValue.trim() === "") {
192
+ sommarkError([
193
+ `<$red:getPrefixValue Error:$> {N}`,
194
+ `<$yellow:expectedValue must be a non-empty string. Received:$> <$cyan:'${expectedValue}'$>`
195
+ ]);
196
+ }
197
+
198
+ return `${UNRESOLVED_PREFIX}_${prefix}_${expectedValue}_${UNRESOLVED_SUFFIX}`;
199
+ }
200
+
201
+ /**
202
+ * Performs a final pass on a string to clean up any remaining unresolved envelopes.
203
+ * @param {string} content - The content to clean.
204
+ * @param {string|null} [fallback] - The format to use for missing keys (e.g., "$layer{$key}" or "").
205
+ * @returns {string} - The cleaned content.
206
+ */
207
+ export function cleanUnresolved(content, fallback = null) {
208
+ const pattern = new RegExp(`${UNRESOLVED_PREFIX}_(p|v)_(.+?)_${UNRESOLVED_SUFFIX}`, "g");
209
+ return content.replace(pattern, (match, layer, key) => {
210
+ if (fallback !== null) {
211
+ return fallback.replace("$layer", layer).replace("$key", key);
212
+ }
213
+ // Default: Keep as is or restore classic
214
+ return `${layer}{${key}}`;
215
+ });
216
+ }