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