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.
Files changed (45) hide show
  1. package/README.md +47 -42
  2. package/SOMMARK-SPEC.md +483 -0
  3. package/cli/cli.mjs +42 -2
  4. package/cli/commands/color.js +36 -0
  5. package/cli/commands/help.js +7 -0
  6. package/cli/commands/init.js +2 -0
  7. package/cli/commands/list.js +119 -0
  8. package/cli/commands/print.js +61 -6
  9. package/cli/commands/show.js +24 -27
  10. package/cli/constants.js +1 -1
  11. package/cli/helpers/config.js +14 -4
  12. package/cli/helpers/transpile.js +28 -33
  13. package/constants/html_props.js +100 -0
  14. package/constants/html_tags.js +146 -0
  15. package/constants/void_elements.js +26 -0
  16. package/core/lexer.js +70 -39
  17. package/core/parser.js +102 -81
  18. package/core/pluginManager.js +139 -0
  19. package/core/plugins/comment-remover.js +47 -0
  20. package/core/plugins/module-system.js +137 -0
  21. package/core/plugins/quote-escaper.js +37 -0
  22. package/core/plugins/raw-content-plugin.js +72 -0
  23. package/core/plugins/rules-validation-plugin.js +197 -0
  24. package/core/plugins/sommark-format.js +211 -0
  25. package/core/transpiler.js +65 -198
  26. package/debug.js +9 -4
  27. package/format.js +23 -0
  28. package/formatter/mark.js +3 -3
  29. package/formatter/tag.js +6 -2
  30. package/grammar.ebnf +5 -5
  31. package/helpers/camelize.js +2 -0
  32. package/helpers/colorize.js +20 -14
  33. package/helpers/kebabize.js +2 -0
  34. package/helpers/utils.js +161 -0
  35. package/index.js +243 -44
  36. package/mappers/languages/html.js +200 -105
  37. package/mappers/languages/json.js +23 -4
  38. package/mappers/languages/markdown.js +88 -67
  39. package/mappers/languages/mdx.js +130 -2
  40. package/mappers/mapper.js +77 -246
  41. package/package.json +7 -5
  42. package/unformatted.smark +90 -0
  43. package/v3-todo.smark +75 -0
  44. package/CHANGELOG.md +0 -113
  45. 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
+ };