sommark 2.3.1 → 3.0.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 +47 -42
- package/SOMMARK-SPEC.md +483 -0
- package/cli/cli.mjs +42 -2
- package/cli/commands/color.js +36 -0
- package/cli/commands/help.js +7 -0
- package/cli/commands/init.js +2 -0
- package/cli/commands/list.js +119 -0
- package/cli/commands/print.js +61 -6
- package/cli/commands/show.js +24 -27
- package/cli/constants.js +1 -1
- package/cli/helpers/config.js +14 -4
- package/cli/helpers/transpile.js +28 -33
- package/constants/html_props.js +100 -0
- package/constants/html_tags.js +146 -0
- package/constants/void_elements.js +26 -0
- package/core/lexer.js +70 -39
- package/core/parser.js +102 -81
- package/core/pluginManager.js +139 -0
- package/core/plugins/comment-remover.js +47 -0
- package/core/plugins/module-system.js +137 -0
- package/core/plugins/quote-escaper.js +37 -0
- package/core/plugins/raw-content-plugin.js +72 -0
- package/core/plugins/rules-validation-plugin.js +197 -0
- package/core/plugins/sommark-format.js +211 -0
- package/core/transpiler.js +65 -198
- package/debug.js +9 -4
- package/format.js +23 -0
- package/formatter/mark.js +3 -3
- package/formatter/tag.js +6 -2
- package/grammar.ebnf +5 -5
- package/helpers/camelize.js +2 -0
- package/helpers/colorize.js +20 -14
- package/helpers/kebabize.js +2 -0
- package/helpers/utils.js +161 -0
- package/index.js +243 -44
- package/mappers/languages/html.js +200 -105
- package/mappers/languages/json.js +23 -4
- package/mappers/languages/markdown.js +88 -67
- package/mappers/languages/mdx.js +130 -2
- package/mappers/mapper.js +77 -246
- package/package.json +7 -5
- package/unformatted.smark +90 -0
- package/v3-todo.smark +75 -0
- package/CHANGELOG.md +0 -113
- package/helpers/loadCss.js +0 -46
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quote Escaper Plugin for SomMark
|
|
3
|
+
*
|
|
4
|
+
* Scope: arguments
|
|
5
|
+
*
|
|
6
|
+
* Automatically escapes special characters within double quotes in arguments.
|
|
7
|
+
*/
|
|
8
|
+
const QuoteEscaper = {
|
|
9
|
+
name: "quote-escaper",
|
|
10
|
+
type: "preprocessor",
|
|
11
|
+
author: "Adam-Elmi",
|
|
12
|
+
description: "Automatically escapes special characters within double-quoted string arguments.",
|
|
13
|
+
scope: "arguments",
|
|
14
|
+
beforeLex(args) {
|
|
15
|
+
return args.replace(/"([^"]*)"/g, (match, content) => {
|
|
16
|
+
const options = this.options || {};
|
|
17
|
+
|
|
18
|
+
const escapedContent = content
|
|
19
|
+
.replace(/\\/g, "\\\\")
|
|
20
|
+
.replace(/\[/g, "\\[")
|
|
21
|
+
.replace(/\]/g, "\\]")
|
|
22
|
+
.replace(/=/g, "\\=")
|
|
23
|
+
.replace(/->/g, "\\->")
|
|
24
|
+
.replace(/\(/g, "\\(")
|
|
25
|
+
.replace(/\)/g, "\\)")
|
|
26
|
+
.replace(/@_/g, "\\@_")
|
|
27
|
+
.replace(/_@/g, "\\_@")
|
|
28
|
+
.replace(/:/g, "\\:")
|
|
29
|
+
.replace(/,/g, "\\,")
|
|
30
|
+
.replace(/;/g, "\\;")
|
|
31
|
+
.replace(/#/g, "\\#");
|
|
32
|
+
return `"${escapedContent}"`;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default QuoteEscaper;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw Content Plugin for SomMark
|
|
3
|
+
*
|
|
4
|
+
* Scope: top-level
|
|
5
|
+
*
|
|
6
|
+
* Automatically escapes SomMark tokens within specified blocks to allow raw content.
|
|
7
|
+
*/
|
|
8
|
+
const RawContentPlugin = {
|
|
9
|
+
name: "raw-content",
|
|
10
|
+
type: ["preprocessor", "on-ast"],
|
|
11
|
+
author: "Adam-Elmi",
|
|
12
|
+
description: "Prevents SomMark syntax parsing within specific blocks (e.g., [code], [mdx]) to allow raw content.",
|
|
13
|
+
scope: "top-level",
|
|
14
|
+
options: {
|
|
15
|
+
targetBlocks: ["mdx", "raw"] // Blocks to treat as raw
|
|
16
|
+
},
|
|
17
|
+
beforeLex(src) {
|
|
18
|
+
let processed = src;
|
|
19
|
+
const options = this.options || {};
|
|
20
|
+
const targetBlocks = options.targetBlocks || ["mdx", "raw"];
|
|
21
|
+
|
|
22
|
+
targetBlocks.forEach(tag => {
|
|
23
|
+
const regex = new RegExp(`\\[${tag}([^ \\]]*|\\s*=[^\\]]*)?\\]([\\s\\S]*?)\\[end\\]`, "g");
|
|
24
|
+
processed = processed.replace(regex, (match, argsPart, content) => {
|
|
25
|
+
const escapedContent = content
|
|
26
|
+
.replace(/\\/g, "\\\\")
|
|
27
|
+
.replace(/\[/g, "\\[")
|
|
28
|
+
.replace(/\]/g, "\\]")
|
|
29
|
+
.replace(/@_/g, "\\@_")
|
|
30
|
+
.replace(/_@/g, "\\_@")
|
|
31
|
+
.replace(/=/g, "\\=")
|
|
32
|
+
.replace(/:/g, "\\:")
|
|
33
|
+
.replace(/,/g, "\\,")
|
|
34
|
+
.replace(/\(/g, "\\(")
|
|
35
|
+
.replace(/\)/g, "\\)")
|
|
36
|
+
.replace(/ /g, "\u200B ")
|
|
37
|
+
.replace(/\t/g, "\u200B\t")
|
|
38
|
+
.replace(/\n/g, "\u200B\n")
|
|
39
|
+
.replace(/\r/g, "\u200B\r");
|
|
40
|
+
return `[${tag}${argsPart || ""}]${escapedContent}[end]`;
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
return processed;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
onAst(ast) {
|
|
47
|
+
const options = this.options || {};
|
|
48
|
+
const targetBlocks = (options.targetBlocks || ["mdx", "raw", "code"]).map(t => t.toLowerCase());
|
|
49
|
+
|
|
50
|
+
const processNodes = (nodes) => {
|
|
51
|
+
if (!Array.isArray(nodes)) return;
|
|
52
|
+
nodes.forEach(node => {
|
|
53
|
+
if (node.type === "Block" && targetBlocks.includes(node.id.toLowerCase())) {
|
|
54
|
+
if (node.body) {
|
|
55
|
+
node.body.forEach(child => {
|
|
56
|
+
if (child.type === "Text") {
|
|
57
|
+
child.text = child.text.replace(/\u200B/g, "");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (node.body) processNodes(node.body);
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const root = Array.isArray(ast) ? ast : [ast];
|
|
67
|
+
processNodes(root);
|
|
68
|
+
return ast;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export default RawContentPlugin;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { transpilerError } from "../errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rules Validation Plugin
|
|
5
|
+
* Validates rules defined in mapper files.
|
|
6
|
+
* Supports rules for arguments (key, value) and content.
|
|
7
|
+
*/
|
|
8
|
+
const RulesValidationPlugin = {
|
|
9
|
+
name: "rules-validation",
|
|
10
|
+
type: "on-ast",
|
|
11
|
+
author: "Adam-Elmi",
|
|
12
|
+
description: "Validates AST nodes against rules defined in the mapper (e.g., argument count, required keys, content length).",
|
|
13
|
+
onAst(ast, { mapperFile }) {
|
|
14
|
+
if (!mapperFile) return ast;
|
|
15
|
+
|
|
16
|
+
const validateNode = (node, parentTarget = null) => {
|
|
17
|
+
if (!node) return;
|
|
18
|
+
|
|
19
|
+
// ========================================================================== //
|
|
20
|
+
// 1. TEXT nodes validation //
|
|
21
|
+
// ========================================================================== //
|
|
22
|
+
if (node.type === "Text" && parentTarget) {
|
|
23
|
+
this.runValidations(parentTarget, null, node.text, "Text", mapperFile);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ========================================================================== //
|
|
27
|
+
// 2. Identifier nodes validation (Block, Inline, AtBlock) //
|
|
28
|
+
// ========================================================================== //
|
|
29
|
+
if (node.id) {
|
|
30
|
+
const target = mapperFile.get(node.id);
|
|
31
|
+
if (target) {
|
|
32
|
+
this.runValidations(target, node.args, this.getContent(node), node.type, mapperFile);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ========================================================================== //
|
|
37
|
+
// Recursive traversal //
|
|
38
|
+
// ========================================================================== //
|
|
39
|
+
if (node.body && Array.isArray(node.body)) {
|
|
40
|
+
const currentTarget = node.id ? mapperFile.get(node.id) : parentTarget;
|
|
41
|
+
node.body.forEach(child => validateNode(child, currentTarget));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const root = Array.isArray(ast) ? ast : [ast];
|
|
46
|
+
root.forEach(node => validateNode(node));
|
|
47
|
+
|
|
48
|
+
return ast;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
getContent(node) {
|
|
52
|
+
if (node.type === "Inline") return node.value;
|
|
53
|
+
if (node.type === "AtBlock") return node.content;
|
|
54
|
+
return "";
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
runValidations(target, args, content, type, mapperFile) {
|
|
58
|
+
if (!target.options) return;
|
|
59
|
+
const rules = target.options.rules || {};
|
|
60
|
+
const id = Array.isArray(target.id) ? target.id.join(" | ") : target.id;
|
|
61
|
+
|
|
62
|
+
// ========================================================================== //
|
|
63
|
+
// 1. Validate Args Count & Keys //
|
|
64
|
+
// ========================================================================== //
|
|
65
|
+
if (args && rules.args) {
|
|
66
|
+
const { min, max, required, includes } = rules.args;
|
|
67
|
+
const argKeys = Object.keys(args).filter(key => isNaN(parseInt(key)));
|
|
68
|
+
const argCount = args.length;
|
|
69
|
+
|
|
70
|
+
if (min !== undefined && argCount < min) {
|
|
71
|
+
transpilerError([
|
|
72
|
+
"{line}<$red:Validation Error:$> ",
|
|
73
|
+
`<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:requires at least$> <$green:${min}$> <$yellow:argument(s). Found$> <$red:${argCount}$>{line}`
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
if (max !== undefined && argCount > max) {
|
|
77
|
+
transpilerError([
|
|
78
|
+
"{line}<$red:Validation Error:$> ",
|
|
79
|
+
`<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:accepts at most$> <$green:${max}$> <$yellow:argument(s). Found$> <$red:${argCount}$>{line}`
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
if (required && Array.isArray(required)) {
|
|
83
|
+
const missingKeys = required.filter(key => !Object.prototype.hasOwnProperty.call(args, key));
|
|
84
|
+
if (missingKeys.length > 0) {
|
|
85
|
+
transpilerError([
|
|
86
|
+
"{line}<$red:Validation Error:$> ",
|
|
87
|
+
`<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is missing required argument(s):$> <$red:${missingKeys.join(", ")}$>{line}`
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (includes && Array.isArray(includes)) {
|
|
92
|
+
const invalidKeys = argKeys.filter(key => {
|
|
93
|
+
return !includes.includes(key) && !mapperFile.extraProps.has(key);
|
|
94
|
+
});
|
|
95
|
+
if (invalidKeys.length > 0) {
|
|
96
|
+
transpilerError([
|
|
97
|
+
"{line}<$red:Validation Error:$> ",
|
|
98
|
+
`<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:contains invalid argument key(s):$> <$red:${invalidKeys.join(", ")}$>`,
|
|
99
|
+
`{N}<$yellow:Allowed keys are:$> <$green:${includes.join(", ")}$>{line}`
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ========================================================================== //
|
|
106
|
+
// 2. Validation on Keys and Values //
|
|
107
|
+
// ========================================================================== //
|
|
108
|
+
if (args) {
|
|
109
|
+
const argKeys = Object.keys(args).filter(key => isNaN(parseInt(key)));
|
|
110
|
+
|
|
111
|
+
// Validate keys pattern
|
|
112
|
+
if (rules.keys) {
|
|
113
|
+
const keyPattern = rules.keys instanceof RegExp ? rules.keys : null;
|
|
114
|
+
if (keyPattern) {
|
|
115
|
+
const invalidKeys = argKeys.filter(key => !keyPattern.test(key));
|
|
116
|
+
if (invalidKeys.length > 0) {
|
|
117
|
+
transpilerError([
|
|
118
|
+
"{line}<$red:Validation Error:$> ",
|
|
119
|
+
`<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:contains argument keys that do not match pattern $> <$green:${keyPattern.toString()}$>: <$red:${invalidKeys.join(", ")}$>{line}`
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ========================================================================== //
|
|
126
|
+
// Validate specific values //
|
|
127
|
+
// ========================================================================== //
|
|
128
|
+
if (rules.values) {
|
|
129
|
+
for (const [key, value] of Object.entries(args)) {
|
|
130
|
+
if (isNaN(parseInt(key))) {
|
|
131
|
+
const valueRule = rules.values[key];
|
|
132
|
+
if (valueRule) {
|
|
133
|
+
if (valueRule instanceof RegExp && !valueRule.test(value)) {
|
|
134
|
+
transpilerError([
|
|
135
|
+
"{line}<$red:Validation Error:$> ",
|
|
136
|
+
`<$yellow:Argument key$> <$blue:'${key}'$> <$yellow:in$> <$blue:'${id}'$> <$yellow:has invalid value:$> <$red:'${value}'$>{N}<$yellow:Expected to match pattern:$> <$green:${valueRule.toString()}$>{line}`
|
|
137
|
+
]);
|
|
138
|
+
} else if (typeof valueRule === "function" && !valueRule(value)) {
|
|
139
|
+
transpilerError([
|
|
140
|
+
"{line}<$red:Validation Error:$> ",
|
|
141
|
+
`<$yellow:Argument key$> <$blue:'${key}'$> <$yellow:in$> <$blue:'${id}'$> <$yellow:failed custom validation for value:$> <$red:'${value}'$>{line}`
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ========================================================================== //
|
|
151
|
+
// 3. Content Validation //
|
|
152
|
+
// ========================================================================== //
|
|
153
|
+
if (content !== undefined && rules.content) {
|
|
154
|
+
const { maxLength, match } = rules.content;
|
|
155
|
+
if (maxLength && content.length > maxLength) {
|
|
156
|
+
transpilerError([
|
|
157
|
+
"{line}<$red:Validation Error:$> ",
|
|
158
|
+
`<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:content exceeds maximum length of$> <$green:${maxLength}$> <$yellow:characters. Found$> <$red:${content.length}$>{line}`
|
|
159
|
+
]);
|
|
160
|
+
}
|
|
161
|
+
if (match && match instanceof RegExp && !match.test(content)) {
|
|
162
|
+
transpilerError([
|
|
163
|
+
"{line}<$red:Validation Error:$> ",
|
|
164
|
+
`<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:content does not match required pattern:$> <$green:${match.toString()}$>{line}`
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ========================================================================== //
|
|
170
|
+
// 4. self-closing //
|
|
171
|
+
// ========================================================================== //
|
|
172
|
+
if (rules.is_self_closing && (type === "Block" || content)) {
|
|
173
|
+
if (content) {
|
|
174
|
+
transpilerError([
|
|
175
|
+
"{line}<$red:Validation Error:$> ",
|
|
176
|
+
`<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is self-closing tag and is not allowed to have a content | children$>{line}`
|
|
177
|
+
]);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ========================================================================== //
|
|
182
|
+
// 5. Type Validation (Block, Inline, AtBlock) //
|
|
183
|
+
// ========================================================================== //
|
|
184
|
+
const typeToValidate = target.options.type;
|
|
185
|
+
if (typeToValidate && type !== "Text") {
|
|
186
|
+
const allowedTypes = Array.isArray(typeToValidate) ? typeToValidate : [typeToValidate];
|
|
187
|
+
if (!allowedTypes.includes("any") && !allowedTypes.includes(type)) {
|
|
188
|
+
transpilerError([
|
|
189
|
+
"{line}<$red:Validation Error:$> ",
|
|
190
|
+
`<$yellow:Identifier$> <$blue:'${id}'$> <$yellow:is expected to be type$> <$green:'${allowedTypes.join(" | ")}'$>{N}<$cyan:Received type: $> <$magenta:'${type}'$>{line}`
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export default RulesValidationPlugin;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
name: "sommark-format",
|
|
3
|
+
type: "on-ast",
|
|
4
|
+
author: "Adam-Elmi",
|
|
5
|
+
description: "Formats SomMark AST into a standardized formatted string.",
|
|
6
|
+
options: {
|
|
7
|
+
indentString: "\t"
|
|
8
|
+
},
|
|
9
|
+
onAst(ast) {
|
|
10
|
+
const indentStr = this.options?.indentString || "\t";
|
|
11
|
+
|
|
12
|
+
const escapeArg = (val, type) => {
|
|
13
|
+
let escaped = String(val)
|
|
14
|
+
.replace(/\\/g, "\\\\")
|
|
15
|
+
.replace(/,/g, "\\,");
|
|
16
|
+
|
|
17
|
+
if (type === "Block" || type === "AtBlock") {
|
|
18
|
+
escaped = escaped.replace(/:/g, "\\:");
|
|
19
|
+
}
|
|
20
|
+
if (type === "AtBlock") {
|
|
21
|
+
escaped = escaped.replace(/;/g, "\\;");
|
|
22
|
+
}
|
|
23
|
+
if (type === "Block" && escaped.startsWith("=")) {
|
|
24
|
+
escaped = escaped.replace(/^=/, "\\=");
|
|
25
|
+
}
|
|
26
|
+
return escaped;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const escapeInlineValue = (val) => {
|
|
30
|
+
return String(val)
|
|
31
|
+
.replace(/\\/g, "\\\\")
|
|
32
|
+
.replace(/\)/g, "\\)");
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const escapeText = (str) => {
|
|
36
|
+
return String(str)
|
|
37
|
+
.replace(/\\/g, "\\\\")
|
|
38
|
+
.replace(/\[/g, "\\[")
|
|
39
|
+
.replace(/\]/g, "\\]")
|
|
40
|
+
.replace(/\(/g, "\\(")
|
|
41
|
+
.replace(/\)/g, "\\)")
|
|
42
|
+
.replace(/->/g, "\\->")
|
|
43
|
+
.replace(/@_/g, "\\@_")
|
|
44
|
+
.replace(/_@/g, "\\_@")
|
|
45
|
+
.replace(/#/g, "\\#");
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const formatArgs = (args, type) => {
|
|
49
|
+
if (!args || args.length === 0) return "";
|
|
50
|
+
let usedKeys = new Set();
|
|
51
|
+
let formattedArgs = [];
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < args.length; i++) {
|
|
54
|
+
let val = args[i];
|
|
55
|
+
let matchedKey = null;
|
|
56
|
+
if (type !== "Inline") {
|
|
57
|
+
for (let key of Object.keys(args)) {
|
|
58
|
+
if (isNaN(parseInt(key)) && args[key] === val && !usedKeys.has(key)) {
|
|
59
|
+
matchedKey = key;
|
|
60
|
+
usedKeys.add(key);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let escapedVal = escapeArg(val, type);
|
|
67
|
+
if (matchedKey) {
|
|
68
|
+
formattedArgs.push(`${matchedKey}:${escapedVal}`);
|
|
69
|
+
} else {
|
|
70
|
+
formattedArgs.push(escapedVal);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let res = formattedArgs.join(", ");
|
|
75
|
+
if (!res) return "";
|
|
76
|
+
|
|
77
|
+
if (type === "Block") return " = " + res;
|
|
78
|
+
if (type === "AtBlock") return ": " + res + ";";
|
|
79
|
+
if (type === "Inline") return ": " + res;
|
|
80
|
+
|
|
81
|
+
return res;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const formatBody = (body, depth) => {
|
|
85
|
+
if (!body || !Array.isArray(body)) return "";
|
|
86
|
+
const innerIndentStr = depth >= 0 ? indentStr.repeat(depth) : "";
|
|
87
|
+
let result = "";
|
|
88
|
+
let currentText = "";
|
|
89
|
+
|
|
90
|
+
const flushText = () => {
|
|
91
|
+
if (!currentText) return;
|
|
92
|
+
let cleanText = currentText
|
|
93
|
+
.replace(/[ \t]+/g, " ") // collapse horizontal spaces
|
|
94
|
+
.replace(/\n([ \t]*\n)+/g, "\n\n") // preserve max 1 empty line (paragraphs)
|
|
95
|
+
.trim();
|
|
96
|
+
|
|
97
|
+
if (cleanText) {
|
|
98
|
+
const indentedText = cleanText.split('\n').map(line => {
|
|
99
|
+
return line.trim() ? innerIndentStr + line.trim() : "";
|
|
100
|
+
}).join('\n');
|
|
101
|
+
result += `${indentedText}\n`;
|
|
102
|
+
}
|
|
103
|
+
currentText = "";
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < body.length; i++) {
|
|
107
|
+
const child = body[i];
|
|
108
|
+
if (child.type === "Text") {
|
|
109
|
+
let textStr = escapeText(child.text);
|
|
110
|
+
|
|
111
|
+
// Separate Text from a preceding Inline statement
|
|
112
|
+
if (i > 0 && body[i - 1].type === "Inline") {
|
|
113
|
+
// Don't add space if the text starts with punctuation or already starts with whitespace
|
|
114
|
+
if (textStr.length > 0 && !/^\s/.test(textStr) && !/^[.,!?;:\])}>"']/.test(textStr)) {
|
|
115
|
+
textStr = " " + textStr;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
currentText += textStr;
|
|
119
|
+
} else if (child.type === "Inline") {
|
|
120
|
+
const argsStr = formatArgs(child.args, "Inline");
|
|
121
|
+
const inlineVal = child.value ? String(child.value).trim() : "";
|
|
122
|
+
const inlineStr = `(${escapeInlineValue(inlineVal)})->(${child.id}${argsStr})`;
|
|
123
|
+
|
|
124
|
+
if (i > 0) {
|
|
125
|
+
const prev = body[i - 1];
|
|
126
|
+
if (prev.type === "Inline") {
|
|
127
|
+
if (!/[ \t\n\r]$/.test(currentText)) currentText += " ";
|
|
128
|
+
} else if (prev.type === "Text") {
|
|
129
|
+
if (currentText.length > 0 && !/[ \t\n\r]$/.test(currentText)) {
|
|
130
|
+
// Don't add space if text ends with opening punctuation/quotes
|
|
131
|
+
if (!/[({\[<"']$/.test(currentText)) currentText += " ";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
currentText += inlineStr;
|
|
136
|
+
} else {
|
|
137
|
+
// Helper: check if a block has no meaningful body content
|
|
138
|
+
const isEmptyBlock = (node) => {
|
|
139
|
+
if (node.type !== "Block") return false;
|
|
140
|
+
if (!node.body || node.body.length === 0) return true;
|
|
141
|
+
// Body with only whitespace text nodes
|
|
142
|
+
return node.body.every(
|
|
143
|
+
n => n.type === "Text" && !n.text.trim()
|
|
144
|
+
);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (child.type === "Block" && isEmptyBlock(child)) {
|
|
148
|
+
// Keep empty blocks inline with surrounding text
|
|
149
|
+
const argsStr = formatArgs(child.args, "Block");
|
|
150
|
+
const blockStr = `[${child.id}${argsStr}][end]`;
|
|
151
|
+
|
|
152
|
+
if (i > 0) {
|
|
153
|
+
const prev = body[i - 1];
|
|
154
|
+
if (prev.type === "Text" || prev.type === "Inline") {
|
|
155
|
+
if (currentText.length > 0 && !/[ \t\n\r]$/.test(currentText)) {
|
|
156
|
+
if (!/[({\[<"']$/.test(currentText)) currentText += " ";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
currentText += blockStr;
|
|
161
|
+
} else {
|
|
162
|
+
flushText();
|
|
163
|
+
if (child.type === "Block") {
|
|
164
|
+
const argsStr = formatArgs(child.args, "Block");
|
|
165
|
+
result += `${innerIndentStr}[${child.id}${argsStr}]\n`;
|
|
166
|
+
result += formatBody(child.body, depth + 1);
|
|
167
|
+
result += `${innerIndentStr}[end]\n`;
|
|
168
|
+
} else if (child.type === "AtBlock") {
|
|
169
|
+
const argsStr = formatArgs(child.args, "AtBlock");
|
|
170
|
+
result += `${innerIndentStr}@_${child.id}_@${argsStr}\n`;
|
|
171
|
+
if (child.content) {
|
|
172
|
+
// Remove the leading spaces from the messy text block then re-indent properly
|
|
173
|
+
const lines = child.content.replace(/\r\n/g, '\n').split('\n');
|
|
174
|
+
while (lines.length && !lines[0].trim()) lines.shift();
|
|
175
|
+
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
|
|
176
|
+
|
|
177
|
+
let minIndent = Infinity;
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
if (line.trim()) {
|
|
180
|
+
const leadingSpaces = line.match(/^[ \t]*/)[0].length;
|
|
181
|
+
if (leadingSpaces < minIndent) minIndent = leadingSpaces;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (minIndent === Infinity) minIndent = 0;
|
|
185
|
+
|
|
186
|
+
const indentedContent = lines.map(line => {
|
|
187
|
+
if (!line.trim()) return "";
|
|
188
|
+
return innerIndentStr + indentStr + line.substring(minIndent);
|
|
189
|
+
}).join('\n');
|
|
190
|
+
|
|
191
|
+
result += indentedContent + "\n";
|
|
192
|
+
}
|
|
193
|
+
result += `${innerIndentStr}@_end_@\n`;
|
|
194
|
+
} else if (child.type === "Comment") {
|
|
195
|
+
result += `${innerIndentStr}# ${child.text.replace(/^#+\s*/, "").trim()}\n`;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
flushText();
|
|
201
|
+
return result;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const rootNodes = Array.isArray(ast) ? ast : [ast];
|
|
205
|
+
|
|
206
|
+
// The formatted string is available via `plugin.formattedSource`
|
|
207
|
+
this.formattedSource = formatBody(rootNodes, 0).trim() + "\n";
|
|
208
|
+
|
|
209
|
+
return ast; // Return original AST
|
|
210
|
+
}
|
|
211
|
+
};
|