sommark 5.0.5 → 5.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.
@@ -1029,6 +1029,7 @@ function makeBlockNode() {
1029
1029
  structure: "Block",
1030
1030
  id: "",
1031
1031
  props: {},
1032
+ directives: {},
1032
1033
  body: [],
1033
1034
  depth: 0,
1034
1035
  range: {
@@ -1514,9 +1515,11 @@ function parseBlock(tokens, i, filename = null, placeholders = {}, variables = {
1514
1515
  i = valueIndex;
1515
1516
 
1516
1517
  // Store Argument
1517
- blockNode.props[String(argIndex++)] = v;
1518
- if (k) {
1519
- blockNode.props[k] = v;
1518
+ if (k && k.startsWith("smark-")) {
1519
+ blockNode.directives[k.slice(6)] = v; // strip "smark-" prefix
1520
+ } else {
1521
+ blockNode.props[String(argIndex++)] = v;
1522
+ if (k) blockNode.props[k] = v;
1520
1523
  }
1521
1524
  k = "";
1522
1525
  v = "";
package/esbuild.js ADDED
@@ -0,0 +1,64 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+
5
+ /**
6
+ * esbuild plugin for SomMark.
7
+ *
8
+ * Handles QuickJS WASM assets that esbuild cannot process automatically.
9
+ * QuickJS loads its .wasm files via `new URL("*.wasm", import.meta.url)`
10
+ * inside pre-built package files. esbuild's file loader does not intercept
11
+ * these patterns, so this plugin rewrites the URLs and copies the .wasm
12
+ * files to the output directory.
13
+ *
14
+ * @example
15
+ * // esbuild.config.mjs
16
+ * import { sommarkEsbuild } from "sommark/esbuild";
17
+ * await esbuild.build({ plugins: [sommarkEsbuild()] });
18
+ */
19
+ export function sommarkEsbuild() {
20
+ const collected = new Map();
21
+
22
+ return {
23
+ name: "sommark",
24
+ setup(build) {
25
+ build.onStart(() => collected.clear());
26
+
27
+ build.onLoad({ filter: /emscripten-module(\.browser)?\.m?js$/ }, (args) => {
28
+ const source = readFileSync(args.path, "utf8");
29
+ const dir = dirname(args.path);
30
+
31
+ const pattern = /new URL\(['"]([^'"]+\.wasm)['"]\s*,\s*import\.meta\.url\)/g;
32
+ let result = source;
33
+ let match;
34
+
35
+ while ((match = pattern.exec(source)) !== null) {
36
+ const [full, wasmFile] = match;
37
+ const wasmPath = join(dir, wasmFile);
38
+ if (!existsSync(wasmPath)) continue;
39
+
40
+ let outputName;
41
+ if (collected.has(wasmPath)) {
42
+ outputName = collected.get(wasmPath).name;
43
+ } else {
44
+ const content = readFileSync(wasmPath);
45
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 8);
46
+ outputName = `emscripten-module-${hash}.wasm`;
47
+ collected.set(wasmPath, { name: outputName, content });
48
+ }
49
+
50
+ result = result.replace(full, `new URL("./${outputName}", import.meta.url)`);
51
+ }
52
+
53
+ return result !== source ? { contents: result, loader: "js" } : undefined;
54
+ });
55
+
56
+ build.onEnd(({ errors }) => {
57
+ if (errors.length > 0) return;
58
+ for (const { name, content } of collected.values()) {
59
+ writeFileSync(join(build.initialOptions.outdir, name), content);
60
+ }
61
+ });
62
+ },
63
+ };
64
+ }
package/index.browser.js CHANGED
@@ -1,8 +1,11 @@
1
- import SomMark, { setDefaultFs, setDefaultCwd } from "./index.shared.js";
1
+ import SomMark, { setDefaultFs, setDefaultCwd, setDefaultEnv, setDefaultAsyncLocalStorage } from "./index.shared.js";
2
+ import { AsyncLocalStorage } from "./async-hooks.js";
2
3
  export * from "./index.shared.js";
3
4
 
4
5
  setDefaultFs(null);
5
6
  setDefaultCwd("/");
7
+ setDefaultEnv(null);
8
+ setDefaultAsyncLocalStorage(AsyncLocalStorage);
6
9
 
7
10
  /**
8
11
  * Resolves a relative path into a full URL using the current document location.
package/index.js CHANGED
@@ -1,6 +1,10 @@
1
- import SomMark, { setDefaultFs, setDefaultCwd, setDefaultFindAndLoadConfig, setDefaultResolvePath } from "./index.shared.js";
1
+ import SomMark, { setDefaultFs, setDefaultCwd, setDefaultFindAndLoadConfig, setDefaultResolvePath, setDefaultEnv, setDefaultAsyncLocalStorage } from "./index.shared.js";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { resolve } from "pathe";
2
4
  export * from "./index.shared.js";
3
5
 
6
+ setDefaultAsyncLocalStorage(AsyncLocalStorage);
7
+
4
8
  // Node-specific filesystem import
5
9
  let nodeFs = null;
6
10
  if (typeof process !== "undefined" && process.versions?.node) {
@@ -9,6 +13,13 @@ if (typeof process !== "undefined" && process.versions?.node) {
9
13
  // Add async interface so modules.js can use await fs.exists / await fs.readFile
10
14
  nodeFs.exists = (p) => nodeFs.promises.access(p).then(() => true).catch(() => false);
11
15
  nodeFs.readFile = (p, enc) => nodeFs.promises.readFile(p, enc);
16
+ nodeFs.stat = (p) => nodeFs.promises.stat(p);
17
+ nodeFs.__isNodeFs = true;
18
+ nodeFs.glob = async (pattern, opts) => {
19
+ const results = [];
20
+ for await (const f of nodeFs.promises.glob(pattern, opts)) results.push(f);
21
+ return results;
22
+ };
12
23
  } catch (e) {}
13
24
  }
14
25
  setDefaultFs(nodeFs);
@@ -32,4 +43,94 @@ if (typeof process !== "undefined" && process.versions?.node) {
32
43
  }
33
44
  setDefaultFindAndLoadConfig(findAndLoadConfigFn);
34
45
 
46
+ // Load .env file from cwd and merge with process.env (process.env wins)
47
+ {
48
+ let mergedEnv = { ...process.env };
49
+ if (nodeFs) {
50
+ try {
51
+ const dotenvPath = nodeFs.promises
52
+ ? await nodeFs.promises.readFile(process.cwd() + "/.env", "utf8")
53
+ : null;
54
+ if (dotenvPath) {
55
+ for (const line of dotenvPath.split("\n")) {
56
+ const trimmed = line.trim();
57
+ if (!trimmed || trimmed.startsWith("#")) continue;
58
+ const eq = trimmed.indexOf("=");
59
+ if (eq === -1) continue;
60
+ const key = trimmed.slice(0, eq).trim();
61
+ let val = trimmed.slice(eq + 1).trim();
62
+ if ((val.startsWith('"') && val.endsWith('"')) ||
63
+ (val.startsWith("'") && val.endsWith("'"))) {
64
+ val = val.slice(1, -1);
65
+ }
66
+ if (!(key in mergedEnv)) mergedEnv[key] = val;
67
+ }
68
+ }
69
+ } catch { /* .env file is optional */ }
70
+ }
71
+ setDefaultEnv(mergedEnv);
72
+ }
73
+
74
+ export const fileHandler = {
75
+ async read(filePath) {
76
+ if (!nodeFs) {
77
+ throw new Error(
78
+ "[SomMark] fileHandler is not available in browser mode.\n" +
79
+ "File access is a server-side concept."
80
+ );
81
+ }
82
+ const cwd = process.cwd();
83
+ const abs = resolve(cwd, filePath);
84
+ if (!abs.startsWith(cwd)) {
85
+ throw new Error(
86
+ `[SomMark] fileHandler.read: path traversal outside project root is not allowed.\n` +
87
+ `Attempted path: ${abs}`
88
+ );
89
+ }
90
+ return nodeFs.readFile(abs, "utf-8");
91
+ },
92
+ async exists(filePath) {
93
+ if (!nodeFs) return false;
94
+ const cwd = process.cwd();
95
+ const abs = resolve(cwd, filePath);
96
+ if (!abs.startsWith(cwd)) return false;
97
+ return nodeFs.exists(abs);
98
+ },
99
+ async glob(pattern) {
100
+ if (!nodeFs) throw new Error("[SomMark] fileHandler.glob is not available in browser mode.\nFile access is a server-side concept.");
101
+ if (!nodeFs.glob) throw new Error("[SomMark] fileHandler.glob requires Node.js 22 or later.");
102
+ const cwd = process.cwd();
103
+ return nodeFs.glob(pattern, { cwd });
104
+ },
105
+ async stat(filePath) {
106
+ if (!nodeFs) {
107
+ throw new Error(
108
+ "[SomMark] fileHandler.stat is not available in browser mode.\n" +
109
+ "File access is a server-side concept."
110
+ );
111
+ }
112
+ const cwd = process.cwd();
113
+ const abs = resolve(cwd, filePath);
114
+ if (!abs.startsWith(cwd)) {
115
+ throw new Error(
116
+ `[SomMark] fileHandler.stat: path traversal outside project root is not allowed.\n` +
117
+ `Attempted path: ${abs}`
118
+ );
119
+ }
120
+ try {
121
+ const s = await nodeFs.stat(abs);
122
+ return {
123
+ size: s.size,
124
+ mtime: s.mtimeMs,
125
+ ctime: s.ctimeMs,
126
+ atime: s.atimeMs,
127
+ isFile: s.isFile(),
128
+ isDirectory: s.isDirectory(),
129
+ };
130
+ } catch {
131
+ return null;
132
+ }
133
+ },
134
+ };
135
+
35
136
  export default SomMark;
package/index.shared.js CHANGED
@@ -42,6 +42,14 @@ export function setDefaultFs(fs) {
42
42
  Evaluator.setDefaultFs(fs);
43
43
  }
44
44
 
45
+ export function setDefaultEnv(env) {
46
+ Evaluator.setDefaultEnv(env);
47
+ }
48
+
49
+ export function setDefaultAsyncLocalStorage(cls) {
50
+ Evaluator.setDefaultAsyncLocalStorage(cls);
51
+ }
52
+
45
53
  export function setDefaultResolvePath(fn) {
46
54
  defaultResolvePath = fn;
47
55
  }
@@ -74,7 +82,7 @@ class SomMark {
74
82
  * @param {string} [options.baseDir=null] - The base directory for resolving relative paths.
75
83
  */
76
84
  constructor(options = {}) {
77
- const { src, ast = null, format, mapperFile = null, filename = "anonymous", removeComments = true, placeholders = {}, customProps = [], fallbackTarget = true, outputValidator = null, importAliases = {}, importStack = [], baseDir = null, moduleCache = null, showSpinner = true, security = {}, dualOutput = false, moduleIdentityToken = null } = options;
85
+ const { src, ast = null, format, mapperFile = null, filename = "anonymous", removeComments = true, placeholders = {}, customProps = [], fallbackTarget = true, outputValidator = null, importAliases = {}, importStack = [], baseDir = null, moduleCache = null, showSpinner = true, security = {}, dualOutput = false, webOutputs = false, moduleIdentityToken = null } = options;
78
86
  this.rawSettings = options;
79
87
  this.src = src;
80
88
  this.ast = ast;
@@ -85,6 +93,7 @@ class SomMark {
85
93
  this.placeholders = placeholders;
86
94
  this.customProps = customProps;
87
95
  this.dualOutput = dualOutput;
96
+ this.webOutputs = webOutputs;
88
97
  this.cwd = options.baseDir || (options.files ? "/" : defaultCwd);
89
98
  this.fs = options.fs
90
99
  || (options.files ? new VirtualFS(options.files) : null)
@@ -113,7 +122,8 @@ class SomMark {
113
122
  allowFetch: security?.allowFetch !== false,
114
123
  allowHttp: security?.allowHttp === true,
115
124
  allowedOrigins: Array.isArray(security?.allowedOrigins) ? security.allowedOrigins.map(o => o.toLowerCase()) : null,
116
- allowedExtensions: Array.isArray(security?.allowedExtensions) ? security.allowedExtensions.map(e => e.toLowerCase()) : null
125
+ allowedExtensions: Array.isArray(security?.allowedExtensions) ? security.allowedExtensions.map(e => e.toLowerCase()) : null,
126
+ env: Array.isArray(security?.env) ? security.env : []
117
127
  };
118
128
  this.warnings = [];
119
129
  this._prepared = false;
@@ -300,6 +310,7 @@ class SomMark {
300
310
  security: this.security,
301
311
  settings: this.rawSettings,
302
312
  dualOutput: this.dualOutput,
313
+ webOutputs: this.webOutputs,
303
314
  instance: this
304
315
  });
305
316
 
@@ -29,28 +29,18 @@ const MARKDOWN = Mapper.define({
29
29
  },
30
30
 
31
31
  /**
32
- * Provides a fallback for unknown tags by using the HTML mapper instead.
33
- */
32
+ * Provides a fallback for unknown tags by rendering them as HTML elements.
33
+ * Passes child nodes to the transpiler, which handles all node types (such as ForEach).
34
+ **/
34
35
  getUnknownTag(node) {
35
- const id = node.id.toLowerCase();
36
-
36
+ const id = node.id;
37
37
  return {
38
- render: async ({ props, ast, isSelfClosing, renderChild }) => {
38
+ options: { trimAndWrapBlocks: true },
39
+ render: ({ props, content, isSelfClosing }) => {
39
40
  const element = this.tag(id).smartAttributes(props, this.customProps, this.options);
40
41
  if (isSelfClosing || VOID_ELEMENTS.has(id)) return element.selfClose();
41
-
42
- let rawContent = "";
43
- for (const child of (ast.body || [])) {
44
- if (child.type === TEXT) rawContent += this.text(child.text);
45
- else if (child.type === BLOCK) rawContent += await renderChild(child);
46
- }
47
- rawContent = rawContent.trim();
48
-
49
- const meaningful = (ast.body || []).filter(c => c.type !== TEXT || c.text.trim());
50
- const finalContent = meaningful.length <= 1 ? rawContent : `\n${rawContent}\n`;
51
- return element.body(finalContent);
52
- },
53
- options: { handleAst: true }
42
+ return element.body(content);
43
+ }
54
44
  };
55
45
  }
56
46
  });
@@ -62,10 +52,14 @@ registerSharedOutputs(MARKDOWN);
62
52
  /**
63
53
  * Quote - Renders blockquote content or GFM alerts.
64
54
  */
65
- MARKDOWN.register("quote", ({ props, content }) => {
66
- const type = safeArg({ props, index: 0, key: "type", fallBack: "" });
67
- return md.quote(content, type);
68
- }, { resolve: true });
55
+ MARKDOWN.register(
56
+ "quote",
57
+ ({ props, content }) => {
58
+ const type = safeArg({ props, index: 0, key: "type", fallBack: "" });
59
+ return md.quote(content, type);
60
+ },
61
+ { resolve: true }
62
+ );
69
63
 
70
64
  /**
71
65
  * Headings - Renders H1-H6 block headings.
@@ -145,12 +139,12 @@ MARKDOWN.register(
145
139
  "link",
146
140
  ({ props, content, isSelfClosing }) => {
147
141
  if (isSelfClosing) {
148
- const text = safeArg({ props, index: 0, key: "text", fallBack: "" });
149
- const src = safeArg({ props, index: 1, key: "src", fallBack: "" });
142
+ const text = safeArg({ props, index: 0, key: "text", fallBack: "" });
143
+ const src = safeArg({ props, index: 1, key: "src", fallBack: "" });
150
144
  const title = safeArg({ props, index: 2, key: "title", fallBack: "" });
151
145
  return md.url("link", text, src, title);
152
146
  }
153
- const src = safeArg({ props, index: 0, key: "src", fallBack: "" });
147
+ const src = safeArg({ props, index: 0, key: "src", fallBack: "" });
154
148
  const title = safeArg({ props, index: 1, key: "title", fallBack: "" });
155
149
  return md.url("link", content, src, title);
156
150
  },
@@ -188,10 +182,14 @@ MARKDOWN.register(
188
182
  * Escape - Escapes special Markdown characters.
189
183
  * Self-closing: [escape = "text" !] or [escape = text: "text" !]
190
184
  */
191
- MARKDOWN.register(["escape", "e"], function ({ props, content, isSelfClosing }) {
192
- const text = isSelfClosing ? safeArg({ props, index: 0, key: "text", fallBack: "" }) : content;
193
- return this.md.escape(text);
194
- }, { resolve: true });
185
+ MARKDOWN.register(
186
+ ["escape", "e"],
187
+ function ({ props, content, isSelfClosing }) {
188
+ const text = isSelfClosing ? safeArg({ props, index: 0, key: "text", fallBack: "" }) : content;
189
+ return this.md.escape(text);
190
+ },
191
+ { resolve: true }
192
+ );
195
193
 
196
194
  const ROW_SEP = "\x1E";
197
195
  const CELL_SEP = "\x1F";
@@ -207,12 +205,16 @@ MARKDOWN.register(
207
205
  const headers = [];
208
206
  const rows = [];
209
207
 
210
- const extractRows = async (sectionNode) => {
208
+ const extractRows = async sectionNode => {
211
209
  const sectionRows = [];
212
- for (const child of (sectionNode.body || [])) {
210
+ for (const child of sectionNode.body || []) {
213
211
  if (child.type === BLOCK && child.id?.toLowerCase() === "row") {
214
212
  const rendered = await renderChild(child, { inTable: true });
215
- const cells = rendered.split(ROW_SEP)[0]?.split(CELL_SEP).filter(c => c !== "") ?? [];
213
+ const cells =
214
+ rendered
215
+ .split(ROW_SEP)[0]
216
+ ?.split(CELL_SEP)
217
+ .filter(c => c !== "") ?? [];
216
218
  if (cells.length > 0) sectionRows.push(cells);
217
219
  } else if (child.type === FOR_EACH) {
218
220
  const rendered = await renderChild(child, { inTable: true });
@@ -246,25 +248,29 @@ MARKDOWN.register(
246
248
  */
247
249
  MARKDOWN.register(["header", "body"], ({ content }) => content);
248
250
 
249
- MARKDOWN.register("row", async function ({ ast, renderChild, inTable }) {
250
- if (!inTable) {
251
- let result = "";
252
- for (const child of ast.body) {
253
- if (child.type === TEXT) result += this.text(child.text);
254
- else if (child.type === BLOCK) result += await renderChild(child);
251
+ MARKDOWN.register(
252
+ "row",
253
+ async function ({ ast, renderChild, inTable }) {
254
+ if (!inTable) {
255
+ let result = "";
256
+ for (const child of ast.body) {
257
+ if (child.type === TEXT) result += this.text(child.text);
258
+ else if (child.type === BLOCK) result += await renderChild(child);
259
+ }
260
+ return result;
255
261
  }
256
- return result;
257
- }
258
- let cells = "";
259
- for (const child of ast.body) {
260
- if (child.type !== BLOCK) continue;
261
- const id = child.id?.toLowerCase();
262
- if (id === "cell" || id === "th" || id === "td") {
263
- cells += await renderChild(child, { inTable: true });
262
+ let cells = "";
263
+ for (const child of ast.body) {
264
+ if (child.type !== BLOCK) continue;
265
+ const id = child.id?.toLowerCase();
266
+ if (id === "cell" || id === "th" || id === "td") {
267
+ cells += await renderChild(child, { inTable: true });
268
+ }
264
269
  }
265
- }
266
- return cells + ROW_SEP;
267
- }, { handleAst: true });
270
+ return cells + ROW_SEP;
271
+ },
272
+ { handleAst: true }
273
+ );
268
274
 
269
275
  MARKDOWN.register(["cell", "th", "td"], ({ content, inTable }) => {
270
276
  return inTable ? content.trim() + CELL_SEP : content;
@@ -274,34 +280,42 @@ MARKDOWN.register(["cell", "th", "td"], ({ content, inTable }) => {
274
280
  * Lists - Authoritative Native AST List resolution.
275
281
  * Supports Ordered (Number) and Unordered (Dotlex) lists with deep nesting.
276
282
  */
277
- MARKDOWN.register(["list", "List"], async function ({ ast, props, renderChild }) {
278
- const indicator = safeArg({ props, index: 0, fallBack: "dot" });
279
- const isOrdered = indicator === "number" || indicator === "ol";
280
- const marker = isOrdered ? "" : (indicator === "dot" ? "-" : indicator);
281
- const items = [];
283
+ MARKDOWN.register(
284
+ ["list", "List"],
285
+ async function ({ ast, props, renderChild }) {
286
+ const indicator = safeArg({ props, index: 0, fallBack: "dot" });
287
+ const isOrdered = indicator === "number" || indicator === "ol";
288
+ const marker = isOrdered ? "" : indicator === "dot" ? "-" : indicator;
289
+ const items = [];
282
290
 
283
- for (const node of ast.body) {
284
- if (node.type !== BLOCK) continue;
285
- const id = node.id?.toLowerCase();
286
- if (id === "item") {
287
- items.push((await renderChild(node)).trim());
291
+ for (const node of ast.body) {
292
+ if (node.type !== BLOCK) continue;
293
+ const id = node.id?.toLowerCase();
294
+ if (id === "item") {
295
+ items.push((await renderChild(node)).trim());
296
+ }
288
297
  }
289
- }
290
298
 
291
- return isOrdered ? md.orderedList(items, 0) : md.unorderedList(items, 0, marker);
292
- }, { handleAst: true, trimAndWrapBlocks: false });
299
+ return isOrdered ? md.orderedList(items, 0) : md.unorderedList(items, 0, marker);
300
+ },
301
+ { handleAst: true, trimAndWrapBlocks: false }
302
+ );
293
303
 
294
304
  /**
295
305
  * List Helpers - Internal tags for list structural organization.
296
306
  */
297
- MARKDOWN.register(["item", "Item"], async function ({ ast, renderChild }) {
298
- let result = "";
299
- for (const child of ast.body) {
300
- if (child.type === TEXT) result += this.text(child.text);
301
- else if (child.type === BLOCK) result += await renderChild(child);
302
- }
303
- return result.trim();
304
- }, { handleAst: true, trimAndWrapBlocks: false });
307
+ MARKDOWN.register(
308
+ ["item", "Item"],
309
+ async function ({ ast, renderChild }) {
310
+ let result = "";
311
+ for (const child of ast.body) {
312
+ if (child.type === TEXT) result += this.text(child.text);
313
+ else if (child.type === BLOCK) result += await renderChild(child);
314
+ }
315
+ return result.trim();
316
+ },
317
+ { handleAst: true, trimAndWrapBlocks: false }
318
+ );
305
319
 
306
320
  /**
307
321
  * Todo - Renders task list items with status markers.
@@ -311,17 +325,21 @@ MARKDOWN.register(["item", "Item"], async function ({ ast, renderChild }) {
311
325
  * [todo = "Add feature", "x" !] positional self-closing (task, status)
312
326
  * [todo = "x"]Add feature[end] status in prop, task in body
313
327
  */
314
- MARKDOWN.register("todo", ({ props, content, isSelfClosing }) => {
315
- let status, task;
328
+ MARKDOWN.register(
329
+ "todo",
330
+ ({ props, content, isSelfClosing }) => {
331
+ let status, task;
316
332
 
317
- if (isSelfClosing) {
318
- task = safeArg({ props, index: 0, key: "task", fallBack: "" });
319
- status = safeArg({ props, index: 1, key: "status", fallBack: "" });
320
- } else {
321
- status = safeArg({ props, index: 0, fallBack: "" });
322
- task = content;
323
- }
333
+ if (isSelfClosing) {
334
+ task = safeArg({ props, index: 0, key: "task", fallBack: "" });
335
+ status = safeArg({ props, index: 1, key: "status", fallBack: "" });
336
+ } else {
337
+ status = safeArg({ props, index: 0, fallBack: "" });
338
+ task = content;
339
+ }
324
340
 
325
- return md.todo(status, task);
326
- }, { trimAndWrapBlocks: false });
341
+ return md.todo(status, task);
342
+ },
343
+ { trimAndWrapBlocks: false }
344
+ );
327
345
  export default MARKDOWN;