sommark 4.0.3 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +304 -73
  2. package/cli/cli.mjs +1 -1
  3. package/cli/commands/build.js +3 -1
  4. package/cli/commands/help.js +2 -0
  5. package/cli/commands/init.js +25 -6
  6. package/cli/constants.js +2 -1
  7. package/cli/helpers/transpile.js +5 -2
  8. package/constants/html_props.js +1 -0
  9. package/core/evaluator.js +1061 -0
  10. package/core/formats.js +15 -7
  11. package/core/helpers/config-loader.js +16 -8
  12. package/core/helpers/lib.js +72 -0
  13. package/core/helpers/preprocessor.js +202 -0
  14. package/core/helpers/runtimeOutput.js +28 -0
  15. package/core/helpers/url.js +12 -0
  16. package/core/labels.js +9 -2
  17. package/core/lexer.js +228 -61
  18. package/core/modules.js +338 -60
  19. package/core/parser.js +275 -55
  20. package/core/tokenTypes.js +11 -0
  21. package/core/transpiler.js +352 -66
  22. package/core/validator.js +70 -7
  23. package/formatter/tag.js +31 -7
  24. package/grammar.ebnf +21 -10
  25. package/helpers/fetch-fs.js +37 -0
  26. package/helpers/safeDataParser.js +3 -3
  27. package/helpers/spinner.js +97 -0
  28. package/helpers/utils.js +46 -0
  29. package/helpers/virtual-fs.js +29 -0
  30. package/index.browser.js +87 -0
  31. package/index.js +23 -332
  32. package/index.shared.js +443 -0
  33. package/mappers/languages/html.js +50 -9
  34. package/mappers/languages/json.js +81 -38
  35. package/mappers/languages/jsonc.js +82 -0
  36. package/mappers/languages/markdown.js +88 -48
  37. package/mappers/languages/mdx.js +50 -15
  38. package/mappers/languages/text.js +67 -0
  39. package/mappers/languages/xml.js +6 -6
  40. package/mappers/mapper.js +36 -4
  41. package/mappers/shared/index.js +12 -13
  42. package/package.json +11 -2
  43. package/core/formatter.js +0 -215
package/core/modules.js CHANGED
@@ -1,17 +1,36 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { fileURLToPath } from "node:url";
1
+ import path from "pathe";
2
+ import { fileURLToPath } from "./helpers/url.js";
4
3
  import { runtimeError } from "./errors.js";
5
- import { IMPORT, USE_MODULE, TEXT, COMMENT } from "./labels.js";
4
+ import { IMPORT, USE_MODULE, TEXT, BLOCK, COMMENT, SLOT } from "./labels.js";
5
+
6
+ /**
7
+ * Resolves a module path relative to a base directory.
8
+ */
9
+ const resolveModulePath = (filePath, currentBaseDir) => {
10
+ if (/^https?:\/\//.test(currentBaseDir)) {
11
+ return new URL(filePath, currentBaseDir.endsWith("/") ? currentBaseDir : currentBaseDir + "/").href;
12
+ }
13
+ return path.resolve(currentBaseDir, filePath);
14
+ };
6
15
 
7
16
  /**
8
17
  * Changes a filename or file URL into a full, absolute file path.
9
18
  *
10
19
  * @param {string} filename - The name of the file or its URL.
20
+ * @param {Object} [aliases={}] - Custom path aliases for modules.
11
21
  * @returns {string} - The corrected absolute path.
12
22
  */
13
- const normalizePath = (filename) => {
14
- if (!filename || filename === "anonymous") return process.cwd();
23
+ const normalizePath = (filename, aliases = {}, cwd = "/") => {
24
+ if (!filename || filename === "anonymous") return cwd;
25
+
26
+ // Handle Aliases (like @/components)
27
+ for (const [prefix, replacement] of Object.entries(aliases)) {
28
+ if (filename.startsWith(prefix)) {
29
+ const resolvedPath = path.resolve(cwd, filename.replace(prefix, replacement));
30
+ return resolvedPath;
31
+ }
32
+ }
33
+
15
34
  if (filename.startsWith("file://")) {
16
35
  try {
17
36
  return fileURLToPath(filename);
@@ -19,7 +38,91 @@ const normalizePath = (filename) => {
19
38
  return filename;
20
39
  }
21
40
  }
22
- return path.resolve(process.cwd(), filename);
41
+
42
+ return path.resolve(cwd, filename);
43
+ };
44
+
45
+ const VAR_PATTERN = /SOMMARK_UNRESOLVED_v_(.+?)_SOMMARK/g;
46
+ const VAR_PREFIX = "SOMMARK_UNRESOLVED_v_";
47
+ const VAR_SUFFIX = "_SOMMARK";
48
+
49
+ const resolveAstVariables = (nodes, variables) => {
50
+ if (!nodes) return;
51
+
52
+ for (const node of nodes) {
53
+ if (node.type === TEXT) {
54
+ if (node.text.includes(VAR_PREFIX)) {
55
+ node.text = node.text.replace(VAR_PATTERN, (match, key) => {
56
+ if (variables[key] !== undefined) {
57
+ if (!variables.__consumed__) {
58
+ Object.defineProperty(variables, "__consumed__", {
59
+ value: new Set(),
60
+ writable: true,
61
+ enumerable: false,
62
+ configurable: true
63
+ });
64
+ }
65
+ variables.__consumed__.add(key);
66
+ return String(variables[key]);
67
+ }
68
+ return match;
69
+ });
70
+ }
71
+ } else if (node.type === BLOCK) {
72
+ // Resolve any unresolved variables in block arguments
73
+ for (const [argKey, argVal] of Object.entries(node.args)) {
74
+ if (typeof argVal === "string" && argVal.startsWith(VAR_PREFIX) && argVal.endsWith(VAR_SUFFIX)) {
75
+ const varKey = argVal.slice(VAR_PREFIX.length, -VAR_SUFFIX.length);
76
+ if (variables[varKey] !== undefined) {
77
+ node.args[argKey] = variables[varKey];
78
+ if (!variables.__consumed__) {
79
+ Object.defineProperty(variables, "__consumed__", {
80
+ value: new Set(),
81
+ writable: true,
82
+ enumerable: false,
83
+ configurable: true
84
+ });
85
+ }
86
+ variables.__consumed__.add(varKey);
87
+ }
88
+ }
89
+ }
90
+ if (node.body) {
91
+ resolveAstVariables(node.body, variables);
92
+ }
93
+ }
94
+ }
95
+ };
96
+
97
+ /**
98
+ * Hand-optimized AST cloner.
99
+ * Native structuredClone is extremely slow for basic JSON-like tree data.
100
+ * This helper achieves up to 11x faster performance by cloning only required AST fields.
101
+ */
102
+ const cloneAst = (nodes) => {
103
+ if (!nodes) return [];
104
+ const len = nodes.length;
105
+ const copy = new Array(len);
106
+ for (let i = 0; i < len; i++) {
107
+ const node = nodes[i];
108
+ const nodeCopy = {
109
+ type: node.type,
110
+ range: node.range
111
+ };
112
+ if (node.structure !== undefined) nodeCopy.structure = node.structure;
113
+ if (node.text !== undefined) nodeCopy.text = node.text;
114
+ if (node.id !== undefined) nodeCopy.id = node.id;
115
+ if (node.code !== undefined) nodeCopy.code = node.code;
116
+ if (node.isSelfClosing !== undefined) nodeCopy.isSelfClosing = node.isSelfClosing;
117
+ if (node.args !== undefined) {
118
+ nodeCopy.args = { ...node.args };
119
+ }
120
+ if (node.body !== undefined) {
121
+ nodeCopy.body = cloneAst(node.body);
122
+ }
123
+ copy[i] = nodeCopy;
124
+ }
125
+ return copy;
23
126
  };
24
127
 
25
128
  /**
@@ -33,9 +136,83 @@ const normalizePath = (filename) => {
33
136
  export async function resolveModules(ast, context) {
34
137
  const modules = new Map();
35
138
  const filename = context.filename || "anonymous";
36
- const absFilename = normalizePath(filename);
37
- const baseDir = filename === "anonymous" ? absFilename : path.dirname(absFilename);
38
-
139
+ const importAliases = context.instance.importAliases || {};
140
+ const absFilename = normalizePath(filename, importAliases, context.instance.cwd || "/");
141
+
142
+ // baseDir can be a local path
143
+ const baseDir = context.instance.baseDir || ((filename === "anonymous") ? absFilename : path.dirname(absFilename));
144
+
145
+ // 1. Helper: Trim AST to remove file-boundary whitespace and "ghost" newlines
146
+ const trimAst = (nodes) => {
147
+ if (!nodes) return [];
148
+
149
+ // 1. Filter out internal whitespace-only nodes that are adjacent to non-rendering nodes
150
+ // (Comments, Imports, etc. shouldn't leave "ghost" newlines)
151
+ const nonRenderingTypes = [COMMENT, IMPORT, USE_MODULE];
152
+ let res = nodes.filter((node, idx) => {
153
+ if (node.type !== TEXT || node.text.trim() !== "") return true;
154
+
155
+ const prev = nodes[idx - 1];
156
+ const next = nodes[idx + 1];
157
+ const isAdjacentToNonRendering =
158
+ (prev && nonRenderingTypes.includes(prev.type)) ||
159
+ (next && nonRenderingTypes.includes(next.type));
160
+
161
+ return !isAdjacentToNonRendering;
162
+ });
163
+
164
+ // 2. Final pass: trim leading/trailing newlines from the remaining boundary text nodes
165
+ if (res.length > 0 && res[0].type === TEXT) {
166
+ res[0].text = res[0].text.replace(/^[\r\n]+/, "");
167
+ }
168
+ if (res.length > 0 && res[res.length - 1].type === TEXT) {
169
+ res[res.length - 1].text = res[res.length - 1].text.replace(/[\r\n]+\s*$/, "");
170
+ }
171
+
172
+ // 3. Remove any nodes that became purely empty after trimming
173
+ return res.filter(node => node.type !== TEXT || node.text !== "");
174
+ };
175
+
176
+ // 2. Helper: Inject Slots with Indentation Propagation
177
+ const injectSlots = (nodes, callerBody) => {
178
+ const result = [];
179
+ for (let i = 0; i < nodes.length; i++) {
180
+ const child = nodes[i];
181
+ if (child.type === SLOT) {
182
+ if (callerBody && callerBody.length > 0) {
183
+ // Detect leading indentation from the preceding text node
184
+ let indentation = "";
185
+ const prev = result[result.length - 1];
186
+ if (prev && prev.type === TEXT) {
187
+ const lines = prev.text.split("\n");
188
+ const lastLine = lines[lines.length - 1];
189
+ if (lastLine.trim() === "" && lastLine.length > 0) {
190
+ indentation = lastLine;
191
+ }
192
+ }
193
+
194
+ // Clone and Indent caller body if needed
195
+ const indentedBody = callerBody.map(node => {
196
+ if (node.type === TEXT && indentation) {
197
+ return { ...node, text: node.text.split("\n").map((line, idx) => idx === 0 ? line : indentation + line).join("\n") };
198
+ }
199
+ return { ...node };
200
+ });
201
+
202
+ result.push(...indentedBody);
203
+ } else {
204
+ result.push(...child.body);
205
+ }
206
+ } else {
207
+ if (child.body && Array.isArray(child.body)) {
208
+ child.body = injectSlots(child.body, callerBody);
209
+ }
210
+ result.push(child);
211
+ }
212
+ }
213
+ return result;
214
+ };
215
+
39
216
  let hasContentStarted = false;
40
217
 
41
218
  const processNodes = async (nodes, currentBaseDir, isTopLevel = false) => {
@@ -50,32 +227,45 @@ export async function resolveModules(ast, context) {
50
227
 
51
228
  const alias = Object.keys(node.args).find(k => isNaN(k));
52
229
  let filePath = alias ? node.args[alias] : node.args[0];
53
-
54
- if (typeof filePath === "string") {
55
- filePath = filePath.trim().replace(/^["']|["']$/g, "");
56
- }
230
+ if (typeof filePath === "string") filePath = filePath.trim().replace(/^["']|["']$/g, "");
57
231
 
58
- if (!filePath) {
59
- runtimeError([`<$red:Module Path Error:$> Missing file path for alias <$magenta:${alias}$> at line <$yellow:${node.range.start.line + 1}$>`]);
232
+ // 1a. Handle Aliases
233
+ let resolvedPath = filePath;
234
+ for (const [prefix, replacement] of Object.entries(importAliases)) {
235
+ if (filePath.startsWith(prefix)) {
236
+ resolvedPath = path.resolve(context.instance.cwd || "/", filePath.replace(prefix, replacement));
237
+ break;
238
+ }
60
239
  }
61
240
 
62
- const absolutePath = path.resolve(currentBaseDir, filePath);
63
-
64
- if (!fs.existsSync(absolutePath)) {
241
+ // 1b. Resolve relative to current base (FS)
242
+ const absolutePath = resolveModulePath(resolvedPath, currentBaseDir);
243
+
244
+ // Local Path Resolution with Auto-Extension
245
+ let localPath = absolutePath;
246
+ if (!await context.instance.fs.exists(localPath) && !localPath.endsWith(".smark")) {
247
+ const withSmark = localPath + ".smark";
248
+ if (await context.instance.fs.exists(withSmark)) localPath = withSmark;
249
+ }
250
+ if (!await context.instance.fs.exists(localPath)) {
65
251
  runtimeError([`<$red:Module Path Error:$> File not found: <$magenta:${filePath}$> at line <$yellow:${node.range.start.line + 1}$>`]);
66
252
  }
253
+ let mod = { path: absolutePath, localPath: localPath, type: "smark" };
67
254
 
68
- const ext = path.extname(absolutePath).slice(1);
255
+ const ext = path.extname(mod.localPath).slice(1);
69
256
  if (ext !== "smark") {
70
257
  runtimeError([`<$red:Module Extension Error:$> Unsupported extension .${ext} for module <$magenta:${alias}$>. Only .smark files are supported.`]);
71
258
  }
72
259
 
73
- modules.set(alias, { path: absolutePath, type: ext, used: false, range: node.range });
74
-
75
- // Remove import node from AST
260
+ modules.set(alias, { ...mod, used: false, range: node.range });
76
261
  nodes.splice(i, 1);
262
+ const next = nodes[i];
263
+ if (next && next.type === TEXT && next.text.startsWith("\n")) {
264
+ next.text = next.text.slice(1);
265
+ if (next.text === "") nodes.splice(i, 1);
266
+ }
77
267
  i--;
78
- }
268
+ }
79
269
  // 2. Handle Usage Node: [$use-module = alias]
80
270
  else if (node.type === USE_MODULE) {
81
271
  hasContentStarted = true;
@@ -86,57 +276,145 @@ export async function resolveModules(ast, context) {
86
276
 
87
277
  const mod = modules.get(alias);
88
278
  mod.used = true;
89
-
90
- if (mod.type === "smark") {
91
- const stack = context.importStack || [];
92
- if (stack.includes(mod.path)) {
93
- const chain = [...stack, mod.path].map(p => path.basename(p)).join(" -> ");
94
- runtimeError([
95
- `{line}<$red:Circular Dependency Detected$>:`,
96
- `<$yellow:The following import chain was found:$>`,
97
- `<$magenta:${chain}$>{line}`
98
- ]);
99
- }
100
279
 
101
- // Recursive Parse for Smark files
102
- const content = fs.readFileSync(mod.path, "utf-8");
280
+ const stack = context.importStack || [];
281
+ const maxDepth = context.instance.security?.maxDepth ?? 5;
282
+ if (stack.length >= maxDepth) {
283
+ runtimeError([`<$red:Security Error:$> Recursion Guard: Maximum Smark compilation depth exceeded (limit is ${maxDepth}).`]);
284
+ }
285
+ if (stack.includes(mod.path)) {
286
+ runtimeError([`<$red:Circular Dependency Detected$>: ${mod.path}`]);
287
+ }
288
+
289
+ const cached = context.instance.moduleCache.get(mod.localPath);
290
+ let expandedNodes;
291
+ if (cached) {
292
+ expandedNodes = trimAst(cloneAst(cached));
293
+ } else {
294
+ const content = await context.instance.fs.readFile(mod.localPath, "utf-8");
103
295
  const SomMark = context.instance.constructor;
104
-
105
296
  const subSmark = new SomMark({
106
297
  src: content,
107
298
  format: context.format,
108
299
  filename: mod.path,
300
+ baseDir: path.dirname(mod.localPath),
109
301
  mapperFile: context.instance.mapperFile,
110
- removeComments: context.instance.removeComments,
111
302
  placeholders: context.instance.placeholders,
112
- importStack: [...stack, absFilename]
303
+ variables: {},
304
+ importAliases: context.instance.importAliases,
305
+ customProps: context.instance.customProps,
306
+ fallbackTarget: context.instance.fallbackTarget,
307
+ removeComments: context.instance.removeComments,
308
+ security: context.instance.security,
309
+ showSpinner: context.instance.showSpinner,
310
+ importStack: [...stack, absFilename],
311
+ moduleIdentityToken: context.instance.moduleIdentityToken,
312
+ moduleCache: context.instance.moduleCache
113
313
  });
114
-
314
+
115
315
  const subAst = await subSmark.parse();
116
-
117
- // Wrap the imported code in a virtual block to keep its identity.
118
- const wrapperNode = {
119
- type: "Block",
120
- id: context.instance.moduleIdentityToken,
121
- args: { filename: mod.path },
122
- body: subAst,
123
- depth: node.depth,
124
- range: node.range
125
- };
126
-
127
- // Splice the wrapper into the current body
128
- nodes.splice(i, 1, wrapperNode);
129
- i += 0; // The wrapper is a single node now
316
+ context.instance.moduleCache.set(mod.localPath, subAst);
317
+ expandedNodes = trimAst(subAst);
318
+ }
319
+
320
+ const boundaryNode = {
321
+ type: BLOCK,
322
+ id: context.instance.moduleIdentityToken,
323
+ args: { filename: mod.path },
324
+ body: expandedNodes
325
+ };
326
+ nodes.splice(i, 1, boundaryNode);
327
+ const next = nodes[i + 1];
328
+ if (next && next.type === TEXT && next.text.startsWith("\n")) {
329
+ next.text = next.text.slice(1);
330
+ if (next.text === "") nodes.splice(i + 1, 1);
331
+ }
332
+ }
333
+ // 3. Handle Component Usage: [Alias] ... [end]
334
+ else if (node.type === BLOCK && modules.has(node.id)) {
335
+ hasContentStarted = true;
336
+ const mod = modules.get(node.id);
337
+ mod.used = true;
338
+ const stack = context.importStack || [];
339
+ const maxDepth = context.instance.security?.maxDepth ?? 5;
340
+ if (stack.length >= maxDepth) {
341
+ runtimeError([`<$red:Security Error:$> Recursion Guard: Maximum Smark compilation depth exceeded (limit is ${maxDepth}).`]);
342
+ }
343
+ if (stack.includes(mod.path)) {
344
+ runtimeError([`<$red:Circular Dependency Detected$>: ${mod.path}`]);
345
+ }
346
+
347
+ const cached = context.instance.moduleCache.get(mod.localPath);
348
+ let subAst;
349
+ if (cached) {
350
+ subAst = cloneAst(cached);
130
351
  } else {
131
- runtimeError([`<$red:Module Extension Error:$> Unsupported extension .${mod.type} for module <$magenta:${alias}$>. Only .smark files are supported.`]);
352
+ const content = await context.instance.fs.readFile(mod.localPath, "utf-8");
353
+ const SomMark = context.instance.constructor;
354
+ const subSmark = new SomMark({
355
+ src: content,
356
+ format: context.format,
357
+ filename: mod.path,
358
+ baseDir: path.dirname(mod.localPath),
359
+ mapperFile: context.instance.mapperFile,
360
+ placeholders: context.instance.placeholders,
361
+ variables: {}, // Parse without variables to keep the cached AST pure
362
+ importAliases: context.instance.importAliases,
363
+ customProps: context.instance.customProps,
364
+ fallbackTarget: context.instance.fallbackTarget,
365
+ removeComments: context.instance.removeComments,
366
+ security: context.instance.security,
367
+ showSpinner: context.instance.showSpinner,
368
+ importStack: [...stack, absFilename],
369
+ moduleCache: context.instance.moduleCache
370
+ });
371
+
372
+ subAst = await subSmark.parse();
373
+ context.instance.moduleCache.set(mod.localPath, subAst);
374
+ subAst = cloneAst(subAst);
132
375
  }
133
- }
134
- // 3. Recurse into children
376
+
377
+ // Dynamically resolve variable placeholders inside the cloned AST
378
+ resolveAstVariables(subAst, node.args);
379
+
380
+ await processNodes(node.body, currentBaseDir, false);
381
+ const expandedNodes = injectSlots(trimAst(subAst), trimAst(node.body));
382
+ const rootTag = expandedNodes.find(n => n.type === BLOCK);
383
+ if (rootTag) {
384
+ const consumed = node.args.__consumed__ || new Set();
385
+
386
+ const publicArgs = Object.fromEntries(
387
+ Object.entries(node.args).filter(([key]) => {
388
+ if (key === "__consumed__") return false;
389
+ if (consumed.has(key)) return false; // THE FIX: Filter if hit by v{}
390
+ return true;
391
+ })
392
+ );
393
+ rootTag.args = { ...rootTag.args, ...publicArgs };
394
+ }
395
+
396
+ const boundaryNode = {
397
+ type: BLOCK,
398
+ id: context.instance.moduleIdentityToken,
399
+ args: { filename: mod.path },
400
+ body: expandedNodes
401
+ };
402
+ nodes.splice(i, 1, boundaryNode);
403
+ }
404
+ // 4. Handle Regular Blocks: Process body recursively for nested components and trim whitespace
405
+ else if (node.type === BLOCK) {
406
+ hasContentStarted = true;
407
+ if (node.body && Array.isArray(node.body)) {
408
+ node.body = trimAst(node.body);
409
+ await processNodes(node.body, currentBaseDir, false);
410
+ }
411
+ }
412
+
413
+ // 4. Recurse into children (Standard Blocks)
135
414
  else {
136
415
  if (node.type === TEXT && node.text.trim() === "") {
137
- // Ignore structural whitespace between imports
416
+ // Structural whitespace
138
417
  } else if (node.type !== COMMENT) {
139
- // Any meaningful node that isn't an IMPORT or COMMENT is considered "Content"
140
418
  hasContentStarted = true;
141
419
  }
142
420