sommark 4.0.3 → 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/README.md +274 -73
- package/cli/cli.mjs +1 -1
- package/cli/commands/build.js +3 -1
- package/cli/commands/help.js +2 -0
- package/cli/commands/init.js +25 -6
- package/cli/constants.js +2 -1
- package/cli/helpers/transpile.js +5 -2
- package/constants/html_props.js +1 -0
- package/core/evaluator.js +785 -0
- package/core/formats.js +15 -7
- package/core/helpers/config-loader.js +8 -0
- package/core/helpers/lib.js +75 -0
- package/core/helpers/preprocessor.js +185 -0
- package/core/helpers/runtimeOutput.js +28 -0
- package/core/labels.js +9 -2
- package/core/lexer.js +228 -61
- package/core/modules.js +331 -55
- package/core/parser.js +275 -55
- package/core/tokenTypes.js +11 -0
- package/core/transpiler.js +341 -59
- package/core/validator.js +70 -7
- package/formatter/tag.js +31 -7
- package/grammar.ebnf +21 -10
- package/helpers/safeDataParser.js +3 -3
- package/helpers/spinner.js +91 -0
- package/helpers/utils.js +46 -0
- package/index.js +125 -38
- package/mappers/languages/html.js +50 -9
- package/mappers/languages/json.js +81 -38
- package/mappers/languages/jsonc.js +82 -0
- package/mappers/languages/markdown.js +88 -48
- package/mappers/languages/mdx.js +50 -15
- package/mappers/languages/text.js +67 -0
- package/mappers/languages/xml.js +6 -6
- package/mappers/mapper.js +36 -4
- package/mappers/shared/index.js +12 -13
- package/package.json +6 -1
- package/core/formatter.js +0 -215
package/mappers/languages/mdx.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Mapper from "../mapper.js";
|
|
2
|
-
import MARKDOWN
|
|
2
|
+
import MARKDOWN from "./markdown.js";
|
|
3
3
|
import { VOID_ELEMENTS } from "../../constants/void_elements.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -28,14 +28,14 @@ const MDX = Mapper.define({
|
|
|
28
28
|
|
|
29
29
|
return {
|
|
30
30
|
render: (ctx) => {
|
|
31
|
-
const { args, content } = ctx;
|
|
31
|
+
const { args, content, isSelfClosing } = ctx;
|
|
32
32
|
const element = this.tag(tagName).jsxProps(args);
|
|
33
|
-
return isVoid ? element.selfClose() : element.body(content);
|
|
33
|
+
return (isSelfClosing || isVoid) ? element.selfClose() : element.body(content);
|
|
34
34
|
},
|
|
35
35
|
options: {
|
|
36
36
|
type: isVoid ? "Block" : (isCodeStyleOrScript ? ["Block", "AtBlock"] : ["Block", "Inline", "AtBlock"]),
|
|
37
37
|
escape: !isCodeStyleOrScript,
|
|
38
|
-
rules: {
|
|
38
|
+
rules: { is_empty_body: isVoid }
|
|
39
39
|
}
|
|
40
40
|
};
|
|
41
41
|
},
|
|
@@ -48,14 +48,22 @@ const MDX = Mapper.define({
|
|
|
48
48
|
* Formats a plain text node with Markdown escaping.
|
|
49
49
|
*/
|
|
50
50
|
text(text, options) {
|
|
51
|
-
|
|
51
|
+
let out = text;
|
|
52
|
+
if (options?.escape !== false) {
|
|
53
|
+
out = this.escapeHTML(out);
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
52
56
|
},
|
|
53
57
|
|
|
54
58
|
/**
|
|
55
59
|
* Formats inline content before rendering, respecting explicit escape flags.
|
|
56
60
|
*/
|
|
57
61
|
inlineText(text, options) {
|
|
58
|
-
|
|
62
|
+
let out = text;
|
|
63
|
+
if (options?.escape !== false) {
|
|
64
|
+
out = this.escapeHTML(out);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
59
67
|
},
|
|
60
68
|
|
|
61
69
|
/**
|
|
@@ -66,9 +74,6 @@ const MDX = Mapper.define({
|
|
|
66
74
|
if (options?.escape !== false) {
|
|
67
75
|
out = this.escapeHTML(out);
|
|
68
76
|
}
|
|
69
|
-
if (out.includes('\n')) {
|
|
70
|
-
out = '\n' + out + '\n';
|
|
71
|
-
}
|
|
72
77
|
return out;
|
|
73
78
|
}
|
|
74
79
|
});
|
|
@@ -76,13 +81,17 @@ const MDX = Mapper.define({
|
|
|
76
81
|
const { tag } = MDX;
|
|
77
82
|
|
|
78
83
|
MDX.inherit(MARKDOWN);
|
|
79
|
-
MDX.md = MARKDOWN.md;
|
|
84
|
+
MDX.md = MARKDOWN.md;
|
|
80
85
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
["h1", "h2", "h3", "h4", "h5", "h6"].forEach(h => {
|
|
87
|
+
MDX.register(h, function ({ args, content }) {
|
|
88
|
+
const format = this.safeArg({ args, key: "format", fallBack: "" });
|
|
89
|
+
if (format === "md" || format === "markdown") {
|
|
90
|
+
return this.md.heading(content, h.slice(1) || 1);
|
|
91
|
+
}
|
|
92
|
+
delete args.format;
|
|
93
|
+
return tag(h).jsxProps(args).body(content);
|
|
94
|
+
});
|
|
86
95
|
});
|
|
87
96
|
|
|
88
97
|
/**
|
|
@@ -92,4 +101,30 @@ MDX.register("mdx", ({ content }) => {
|
|
|
92
101
|
return content;
|
|
93
102
|
}, { escape: false, type: "AtBlock" });
|
|
94
103
|
|
|
104
|
+
// Inline CSS tag (Moved from shared)
|
|
105
|
+
MDX.register("css", ({ args, content }) => {
|
|
106
|
+
// Compile style from named arguments (keys that are not numeric digits)
|
|
107
|
+
const namedStyle = Object.keys(args)
|
|
108
|
+
.filter(k => isNaN(parseInt(k)))
|
|
109
|
+
.map(k => `${k}:${args[k]}`)
|
|
110
|
+
.join(";");
|
|
111
|
+
|
|
112
|
+
// Fetch positional style string (index 0) or "style" key if present
|
|
113
|
+
let positionalStyle = MDX.safeArg({ args, index: 0, key: "style", fallBack: "" });
|
|
114
|
+
|
|
115
|
+
// Filter out positional styles that are just duplicates of named arguments
|
|
116
|
+
const hasDuplicateNamed = Object.keys(args)
|
|
117
|
+
.filter(k => isNaN(parseInt(k)))
|
|
118
|
+
.some(k => args[k] === positionalStyle);
|
|
119
|
+
|
|
120
|
+
if (hasDuplicateNamed) {
|
|
121
|
+
positionalStyle = "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Combine both together
|
|
125
|
+
let style = [positionalStyle, namedStyle].filter(s => s.trim()).join(";");
|
|
126
|
+
|
|
127
|
+
return MDX.tag("span").jsxProps({ style }).body(content);
|
|
128
|
+
}, { type: "Inline" });
|
|
129
|
+
|
|
95
130
|
export default MDX;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import Mapper from "../mapper.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Text Mapper used for plain-text extraction.
|
|
5
|
+
*/
|
|
6
|
+
const TEXT = Mapper.define({
|
|
7
|
+
options: {
|
|
8
|
+
trimAndWrapBlocks: false
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Comments are discarded in plain-text output.
|
|
13
|
+
*/
|
|
14
|
+
comment() {
|
|
15
|
+
return "";
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Comment blocks are discarded in plain-text output.
|
|
20
|
+
*/
|
|
21
|
+
commentBlock() {
|
|
22
|
+
return "";
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Runtime logic is discarded in plain-text output.
|
|
27
|
+
*/
|
|
28
|
+
runtimeLogic() {
|
|
29
|
+
return "";
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Returns plain text literally.
|
|
34
|
+
*/
|
|
35
|
+
text(text) {
|
|
36
|
+
return text;
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Returns inline text literally.
|
|
41
|
+
*/
|
|
42
|
+
inlineText(text) {
|
|
43
|
+
return text;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns at-block body text literally.
|
|
48
|
+
*/
|
|
49
|
+
atBlockBody(text) {
|
|
50
|
+
return text;
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fallback for all tags - extracts inner content.
|
|
55
|
+
*/
|
|
56
|
+
getUnknownTag(node) {
|
|
57
|
+
const isBlock = node.type === "Block" || node.type === "ForEach";
|
|
58
|
+
return {
|
|
59
|
+
render: ({ content }) => content,
|
|
60
|
+
options: {
|
|
61
|
+
type: isBlock ? "Block" : (node.type === "AtBlock" ? "AtBlock" : "Inline")
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export default TEXT;
|
package/mappers/languages/xml.js
CHANGED
|
@@ -10,7 +10,7 @@ import { registerSharedOutputs } from "../shared/index.js";
|
|
|
10
10
|
* @param {string} content - The rendered inner content of the tag.
|
|
11
11
|
* @returns {string} The fully rendered XML tag string.
|
|
12
12
|
*/
|
|
13
|
-
const renderXmlTag = function (id, args, content) {
|
|
13
|
+
const renderXmlTag = function (id, args, content, isSelfClosing) {
|
|
14
14
|
// XML is case-sensitive, so we use the exact id provided
|
|
15
15
|
const element = this.tag(id);
|
|
16
16
|
|
|
@@ -27,7 +27,7 @@ const renderXmlTag = function (id, args, content) {
|
|
|
27
27
|
|
|
28
28
|
const hasBody = typeof content === "string" && content.trim().length > 0;
|
|
29
29
|
|
|
30
|
-
if (!hasBody) {
|
|
30
|
+
if (isSelfClosing || !hasBody) {
|
|
31
31
|
return element.selfClose();
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -55,7 +55,7 @@ const XML = Mapper.define({
|
|
|
55
55
|
getUnknownTag(node) {
|
|
56
56
|
const id = node.id;
|
|
57
57
|
return {
|
|
58
|
-
render: ({ args, content }) => renderXmlTag.call(this, id, args, content),
|
|
58
|
+
render: ({ args, content, isSelfClosing }) => renderXmlTag.call(this, id, args, content, isSelfClosing),
|
|
59
59
|
options: {
|
|
60
60
|
type: "any"
|
|
61
61
|
}
|
|
@@ -71,13 +71,13 @@ XML.register("xml", ({ args }) => {
|
|
|
71
71
|
const version = args.version || "1.0";
|
|
72
72
|
const encoding = args.encoding || "UTF-8";
|
|
73
73
|
return `<?xml version="${version}" encoding="${encoding}"?>`;
|
|
74
|
-
}, { type: "Block", rules: {
|
|
74
|
+
}, { type: "Block", rules: { is_empty_body: true } });
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* Registers the DOCTYPE declaration.
|
|
78
78
|
* Usage: [doctype = root: "note", system: "note.dtd"]
|
|
79
79
|
*/
|
|
80
|
-
XML.register("doctype", ({ args }) => {
|
|
80
|
+
XML.register(["DOCTYPE", "doctype"], ({ args }) => {
|
|
81
81
|
const root = args.root || "root";
|
|
82
82
|
const system = args.system;
|
|
83
83
|
const pub = args.public || args.fpi;
|
|
@@ -88,7 +88,7 @@ XML.register("doctype", ({ args }) => {
|
|
|
88
88
|
return `<!DOCTYPE ${root} SYSTEM "${system}">`;
|
|
89
89
|
}
|
|
90
90
|
return `<!DOCTYPE ${root}>`;
|
|
91
|
-
}, { type: "Block", rules: {
|
|
91
|
+
}, { type: "Block", rules: { is_empty_body: true } });
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
94
|
* Registers the XML stylesheet processing instruction.
|
package/mappers/mapper.js
CHANGED
|
@@ -32,7 +32,13 @@ class Mapper {
|
|
|
32
32
|
* Registers a new tag rule. It needs a name and a function that says how to format it.
|
|
33
33
|
*
|
|
34
34
|
* @param {string|Array<string>} id - The name of the tag (like 'Person' or ['p', 'para']).
|
|
35
|
-
* @param {Function} renderOutput - The function that formats this tag.
|
|
35
|
+
* @param {Function} renderOutput - The function that formats this tag. It receives:
|
|
36
|
+
* - `args`: Tag attributes.
|
|
37
|
+
* - `content`: Formatted inner content.
|
|
38
|
+
* - `textContent`: Raw inner text.
|
|
39
|
+
* - `nodeType`: Type of node (Block, Inline, etc.).
|
|
40
|
+
* - `isSelfClosing`: (Blocks only) True if marked with !.
|
|
41
|
+
* - `ast`: The full AST node.
|
|
36
42
|
* @param {Object} [options={ escape: true }] - Settings for this tag.
|
|
37
43
|
* @param {boolean} [options.escape=true] - If true, the content will be made safe for HTML automatically.
|
|
38
44
|
*/
|
|
@@ -48,12 +54,18 @@ class Mapper {
|
|
|
48
54
|
if (typeof renderOutput !== "function") {
|
|
49
55
|
throw new TypeError("argument 'renderOutput' expected to be a function");
|
|
50
56
|
}
|
|
51
|
-
|
|
57
|
+
|
|
52
58
|
const render = renderOutput;
|
|
53
59
|
|
|
54
|
-
//
|
|
60
|
+
// -- RESERVED KEYWORD PROTECTION --
|
|
61
|
+
// We protect core engine keywords that would cause syntax errors if used as tag names.
|
|
62
|
+
const RESERVED = new Set(["end", "import", "slot", "$use-module", "for-each"]);
|
|
55
63
|
const ids = Array.isArray(id) ? id : [id];
|
|
64
|
+
|
|
56
65
|
for (const singleId of ids) {
|
|
66
|
+
if (RESERVED.has(singleId.toLowerCase())) {
|
|
67
|
+
sommarkError(`<$red:Reserved Keyword Error:$> Cannot register mapper for <$yellow:${singleId}$>. This is a protected SomMark engine keyword.`);
|
|
68
|
+
}
|
|
57
69
|
this.removeOutput(singleId);
|
|
58
70
|
}
|
|
59
71
|
|
|
@@ -85,6 +97,10 @@ class Mapper {
|
|
|
85
97
|
* @param {string} id - The output identifier to remove.
|
|
86
98
|
*/
|
|
87
99
|
removeOutput(id) {
|
|
100
|
+
if (typeof id !== "string") {
|
|
101
|
+
throw new TypeError("argument 'id' expected to be a string");
|
|
102
|
+
}
|
|
103
|
+
|
|
88
104
|
this.outputs = this.outputs
|
|
89
105
|
.map(output => {
|
|
90
106
|
if (Array.isArray(output.id)) {
|
|
@@ -120,6 +136,15 @@ class Mapper {
|
|
|
120
136
|
return "";
|
|
121
137
|
}
|
|
122
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Placeholder for block comment rendering. Should be overridden by specific mappers.
|
|
141
|
+
* @param {string} text - The raw comment text.
|
|
142
|
+
* @returns {string} - The formatted block comment string.
|
|
143
|
+
*/
|
|
144
|
+
commentBlock(text, indent = "") {
|
|
145
|
+
return this.comment(text);
|
|
146
|
+
}
|
|
147
|
+
|
|
123
148
|
/**
|
|
124
149
|
* Formats a plain text node.
|
|
125
150
|
* @param {string} text - The raw text content.
|
|
@@ -150,6 +175,13 @@ class Mapper {
|
|
|
150
175
|
return text;
|
|
151
176
|
}
|
|
152
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Formats runtime logic blocks natively.
|
|
180
|
+
* By default, this returns an empty string to discard logic for formats like JSON/MDX.
|
|
181
|
+
*/
|
|
182
|
+
runtimeLogic(code, isGlobal) {
|
|
183
|
+
return "";
|
|
184
|
+
}
|
|
153
185
|
|
|
154
186
|
/**
|
|
155
187
|
* Handles unknown tags. Should be overridden by specific mappers to provide fallback behavior.
|
|
@@ -245,7 +277,7 @@ class Mapper {
|
|
|
245
277
|
}));
|
|
246
278
|
|
|
247
279
|
newMapper.customProps = new Set(this.customProps);
|
|
248
|
-
|
|
280
|
+
|
|
249
281
|
return newMapper;
|
|
250
282
|
}
|
|
251
283
|
|
package/mappers/shared/index.js
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
|
+
import { runtimeError } from "../../core/errors.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
* Registers
|
|
4
|
+
* Registers universal utility tags shared across all SomMark mappers.
|
|
5
|
+
* These tags are considered "Format Agnostic."
|
|
6
|
+
*
|
|
3
7
|
* @param {Mapper} mapper - The mapper instance to register tags on.
|
|
4
8
|
*/
|
|
5
9
|
export function registerSharedOutputs(mapper) {
|
|
6
10
|
// 1. 'raw' - AtBlock that return the raw, unparsed content.
|
|
7
|
-
mapper.register("raw", ({ content }) =>
|
|
11
|
+
mapper.register("raw", ({ content, nodeType }) => {
|
|
12
|
+
if (nodeType === "Block") {
|
|
13
|
+
return String(content);
|
|
14
|
+
}
|
|
15
|
+
return content;
|
|
16
|
+
}, {
|
|
8
17
|
type: "AtBlock",
|
|
9
|
-
escape: false
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
// 2. 'css' - An Inline tag that applies inline styles to its content.
|
|
13
|
-
// Usage: (text)->(css: "color: red")
|
|
14
|
-
mapper.register("css", ({ args, content }) => {
|
|
15
|
-
let style = mapper.safeArg({ args, index: 0, key: "style", fallBack: "" });
|
|
16
|
-
style = style.split(";").map(s => s.trim().split(":").map(s => s.trim()).join(":")).join(";");
|
|
17
|
-
return mapper.tag("span").attributes({ style }).body(content);
|
|
18
|
-
}, {
|
|
19
|
-
type: "Inline"
|
|
18
|
+
escape: false
|
|
20
19
|
});
|
|
21
20
|
|
|
22
21
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sommark",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "SomMark is a declarative, extensible markup language for structured content that can be converted to HTML, Markdown, MDX, JSON, XML, and more.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -61,10 +61,15 @@
|
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@mdx-js/mdx": "^3.1.1",
|
|
63
63
|
"fast-xml-parser": "^5.5.11",
|
|
64
|
+
"isomorphic-dompurify": "^3.14.0",
|
|
64
65
|
"jsdom": "^29.0.2",
|
|
65
66
|
"remark": "^15.0.1",
|
|
66
67
|
"remark-gfm": "^4.0.1",
|
|
67
68
|
"remark-parse": "^11.0.0",
|
|
68
69
|
"vitest": "^4.0.16"
|
|
70
|
+
},
|
|
71
|
+
"dependencies": {
|
|
72
|
+
"@sebastianwessel/quickjs": "^1.1.1",
|
|
73
|
+
"acorn": "^8.15.0"
|
|
69
74
|
}
|
|
70
75
|
}
|
package/core/formatter.js
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
import { IMPORT, USE_MODULE, TEXT, INLINE, BLOCK, ATBLOCK, COMMENT } from "./labels.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Turns an AST back into a clean SomMark source string.
|
|
5
|
-
* This is useful for "pretty-printing" or saving changes back to a file.
|
|
6
|
-
*
|
|
7
|
-
* @param {Object[]|Object} ast - The AST or single node to turn into text.
|
|
8
|
-
* @param {Object} [options] - Optional settings for formatting.
|
|
9
|
-
* @returns {string} - The final SomMark source code.
|
|
10
|
-
*/
|
|
11
|
-
export function formatAST(ast, options = {}) {
|
|
12
|
-
const indentStr = options.indentString || "\t";
|
|
13
|
-
|
|
14
|
-
// -- Escaping Helpers ----------------------------------------------------- //
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Escapes special characters in argument values so they don't break the syntax.
|
|
18
|
-
*
|
|
19
|
-
* @param {any} val - The value to escape.
|
|
20
|
-
* @param {string} type - The type of tag (e.g., Block or Inline).
|
|
21
|
-
* @returns {string} - The safely escaped text.
|
|
22
|
-
*/
|
|
23
|
-
const escapeArg = (val, type) => {
|
|
24
|
-
let escaped = String(val).replace(/\\/g, "\\\\").replace(/,/g, "\\,");
|
|
25
|
-
if (type === BLOCK || type === ATBLOCK) escaped = escaped.replace(/:/g, "\\:");
|
|
26
|
-
if (type === ATBLOCK) escaped = escaped.replace(/;/g, "\\;");
|
|
27
|
-
if (type === BLOCK && escaped.startsWith("=")) escaped = escaped.replace(/^=/, "\\=");
|
|
28
|
-
return escaped;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Escapes characters in the left side of an inline statement (the text in parentheses).
|
|
33
|
-
*
|
|
34
|
-
* @param {any} val - The text inside parentheses.
|
|
35
|
-
* @returns {string} - The safely escaped text.
|
|
36
|
-
*/
|
|
37
|
-
const escapeInlineValue = (val) => String(val).replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Escapes special characters in plain text so they aren't mistaken for SomMark tags.
|
|
41
|
-
*
|
|
42
|
-
* @param {string} str - The raw text to escape.
|
|
43
|
-
* @returns {string} - The safe text.
|
|
44
|
-
*/
|
|
45
|
-
const escapeText = (str) => {
|
|
46
|
-
return String(str)
|
|
47
|
-
.replace(/\\/g, "\\\\")
|
|
48
|
-
.replace(/\[/g, "\\[")
|
|
49
|
-
.replace(/\]/g, "\\]")
|
|
50
|
-
.replace(/\(/g, "\\(")
|
|
51
|
-
.replace(/\)/g, "\\)")
|
|
52
|
-
.replace(/->/g, "\\->")
|
|
53
|
-
.replace(/@_/g, "\\@_")
|
|
54
|
-
.replace(/_@/g, "\\_@")
|
|
55
|
-
.replace(/#/g, "\\#");
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Checks if a value needs to be wrapped in double quotes (like if it has spaces or commas).
|
|
60
|
-
*
|
|
61
|
-
* @param {any} val - The value to check.
|
|
62
|
-
* @returns {boolean} - True if quotes are needed.
|
|
63
|
-
*/
|
|
64
|
-
const shouldQuote = (val) => {
|
|
65
|
-
if (typeof val !== "string") return false;
|
|
66
|
-
return /[ \t\n\r,:[\]()@#]/.test(val);
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
// -- Formatting Logic ----------------------------------------------------- //
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Formats the arguments of a node into a SomMark string (e.g., "= key: value, 123").
|
|
73
|
-
*
|
|
74
|
-
* @param {Object} args - The list of arguments to format.
|
|
75
|
-
* @param {string} type - The type of tag.
|
|
76
|
-
* @returns {string} - The final formatted argument string.
|
|
77
|
-
*/
|
|
78
|
-
const formatArgs = (args, type) => {
|
|
79
|
-
if (!args || Object.keys(args).length === 0) return "";
|
|
80
|
-
let usedKeys = new Set();
|
|
81
|
-
let formattedArgs = [];
|
|
82
|
-
|
|
83
|
-
const keys = Object.keys(args);
|
|
84
|
-
const positionalCount = keys.filter(k => !isNaN(parseInt(k))).length;
|
|
85
|
-
|
|
86
|
-
for (let i = 0; i < positionalCount; i++) {
|
|
87
|
-
let val = args[i];
|
|
88
|
-
let matchedKey = null;
|
|
89
|
-
|
|
90
|
-
// Find if this value has a named alias
|
|
91
|
-
if (type !== INLINE) {
|
|
92
|
-
for (const key of keys) {
|
|
93
|
-
if (isNaN(parseInt(key)) && args[key] === val && !usedKeys.has(key)) {
|
|
94
|
-
matchedKey = key;
|
|
95
|
-
usedKeys.add(key);
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
let escapedVal = escapeArg(val, type);
|
|
102
|
-
if (shouldQuote(val)) {
|
|
103
|
-
const quotedVal = String(val).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
104
|
-
escapedVal = `"${quotedVal}"`;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (matchedKey) formattedArgs.push(`${matchedKey}: ${escapedVal}`);
|
|
108
|
-
else formattedArgs.push(escapedVal);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const res = formattedArgs.join(", ");
|
|
112
|
-
if (!res) return "";
|
|
113
|
-
|
|
114
|
-
if (type === BLOCK || type === IMPORT || type === USE_MODULE) return " = " + res;
|
|
115
|
-
if (type === ATBLOCK) return ": " + res + ";";
|
|
116
|
-
if (type === INLINE) return ": " + res;
|
|
117
|
-
return res;
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Formats a list of nodes (like the body of a block) into indented SomMark source.
|
|
122
|
-
* It also handles the correct spacing between text and inline tags.
|
|
123
|
-
*
|
|
124
|
-
* @param {Object[]} body - The list of content nodes.
|
|
125
|
-
* @param {number} depth - How far to indent the text.
|
|
126
|
-
* @returns {string} - The final formatted text content.
|
|
127
|
-
*/
|
|
128
|
-
const formatBody = (body, depth) => {
|
|
129
|
-
if (!body || !Array.isArray(body)) return "";
|
|
130
|
-
const innerIndentStr = depth >= 0 ? indentStr.repeat(depth) : "";
|
|
131
|
-
let result = "";
|
|
132
|
-
let currentText = "";
|
|
133
|
-
|
|
134
|
-
const flushText = () => {
|
|
135
|
-
if (!currentText) return;
|
|
136
|
-
const cleanText = currentText
|
|
137
|
-
.replace(/[ \t]+/g, " ")
|
|
138
|
-
.replace(/\n([ \t]*\n)+/g, "\n\n")
|
|
139
|
-
.trim();
|
|
140
|
-
|
|
141
|
-
if (cleanText) {
|
|
142
|
-
const indentedText = cleanText.split("\n").map(line => {
|
|
143
|
-
return line.trim() ? innerIndentStr + line.trim() : "";
|
|
144
|
-
}).join("\n");
|
|
145
|
-
result += `${indentedText}\n`;
|
|
146
|
-
}
|
|
147
|
-
currentText = "";
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
for (let i = 0; i < body.length; i++) {
|
|
151
|
-
const child = body[i];
|
|
152
|
-
if (child.type === TEXT) {
|
|
153
|
-
let textStr = escapeText(child.text);
|
|
154
|
-
if (i > 0 && body[i - 1].type === INLINE) {
|
|
155
|
-
if (textStr.length > 0 && !/^\s/.test(textStr) && !/^[.,!?;:\])}>"']/.test(textStr)) {
|
|
156
|
-
textStr = " " + textStr;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
currentText += textStr;
|
|
160
|
-
} else if (child.type === INLINE) {
|
|
161
|
-
const argsStr = formatArgs(child.args, INLINE);
|
|
162
|
-
const inlineVal = child.value ? String(child.value).trim() : "";
|
|
163
|
-
const inlineStr = `(${escapeInlineValue(inlineVal)})->(${child.id}${argsStr})`;
|
|
164
|
-
if (i > 0) {
|
|
165
|
-
const prev = body[i - 1];
|
|
166
|
-
if (prev.type === INLINE) { if (!/[ \t\n\r]$/.test(currentText)) currentText += " "; }
|
|
167
|
-
else if (prev.type === TEXT) { if (currentText.length > 0 && !/[ \t\n\r]$/.test(currentText) && !/[({\[<"']$/.test(currentText)) currentText += " "; }
|
|
168
|
-
}
|
|
169
|
-
currentText += inlineStr;
|
|
170
|
-
} else {
|
|
171
|
-
flushText();
|
|
172
|
-
if (child.type === BLOCK) {
|
|
173
|
-
const argsStr = formatArgs(child.args, BLOCK);
|
|
174
|
-
// Check if it's a self-closing block (Rules support)
|
|
175
|
-
const isSelfClosing = child.rules?.is_self_closing;
|
|
176
|
-
if (isSelfClosing && (!child.body || child.body.length === 0)) {
|
|
177
|
-
result += `${innerIndentStr}[${child.id}${argsStr}][end]\n`;
|
|
178
|
-
} else {
|
|
179
|
-
result += `${innerIndentStr}[${child.id}${argsStr}]\n`;
|
|
180
|
-
result += formatBody(child.body, depth + 1);
|
|
181
|
-
result += `${innerIndentStr}[end]\n`;
|
|
182
|
-
}
|
|
183
|
-
} else if (child.type === ATBLOCK) {
|
|
184
|
-
const argsStr = formatArgs(child.args, ATBLOCK);
|
|
185
|
-
const atHeader = argsStr ? `@_${child.id}_@${argsStr}` : `@_${child.id}_@;`;
|
|
186
|
-
result += `${innerIndentStr}${atHeader}\n`;
|
|
187
|
-
if (child.content) {
|
|
188
|
-
const lines = child.content.replace(/\r\n/g, "\n").split("\n");
|
|
189
|
-
while (lines.length && !lines[0].trim()) lines.shift();
|
|
190
|
-
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
191
|
-
let minIndent = Infinity;
|
|
192
|
-
for (const line of lines) { if (line.trim()) { const leading = line.match(/^[ \t]*/)[0].length; if (leading < minIndent) minIndent = leading; } }
|
|
193
|
-
if (minIndent === Infinity) minIndent = 0;
|
|
194
|
-
const indentedContent = lines.map(line => line.trim() ? innerIndentStr + indentStr + line.substring(minIndent) : "").join("\n");
|
|
195
|
-
result += indentedContent + "\n";
|
|
196
|
-
}
|
|
197
|
-
result += `${innerIndentStr}@_end_@\n`;
|
|
198
|
-
} else if (child.type === COMMENT) {
|
|
199
|
-
result += `${innerIndentStr}# ${child.text.replace(/^#+\s*/, "").trim()}\n`;
|
|
200
|
-
} else if (child.type === IMPORT) {
|
|
201
|
-
const argsStr = formatArgs(child.args, IMPORT);
|
|
202
|
-
result += `${innerIndentStr}[import${argsStr}][end]\n`;
|
|
203
|
-
} else if (child.type === USE_MODULE) {
|
|
204
|
-
const argsStr = formatArgs(child.args, USE_MODULE);
|
|
205
|
-
result += `${innerIndentStr}[$use-module${argsStr}][end]\n`;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
flushText();
|
|
210
|
-
return result;
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
const rootNodes = Array.isArray(ast) ? ast : [ast];
|
|
214
|
-
return formatBody(rootNodes, 0).trim() + "\n";
|
|
215
|
-
}
|