sommark 3.3.4 → 4.0.1
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 +98 -82
- package/assets/logo.json +28 -0
- package/assets/smark.logo.png +0 -0
- package/assets/smark.logo.svg +21 -0
- package/cli/cli.mjs +7 -17
- package/cli/commands/build.js +26 -6
- package/cli/commands/color.js +22 -26
- package/cli/commands/help.js +10 -10
- package/cli/commands/init.js +20 -31
- package/cli/commands/print.js +18 -16
- package/cli/commands/show.js +4 -0
- package/cli/commands/version.js +6 -0
- package/cli/constants.js +9 -5
- package/cli/helpers/config.js +11 -0
- package/cli/helpers/file.js +17 -6
- package/cli/helpers/transpile.js +15 -17
- package/core/errors.js +49 -25
- package/core/formats.js +7 -3
- package/core/formatter.js +215 -0
- package/core/helpers/config-loader.js +40 -75
- package/core/labels.js +21 -9
- package/core/lexer.js +491 -212
- package/core/modules.js +164 -0
- package/core/parser.js +516 -389
- package/core/tokenTypes.js +36 -1
- package/core/transpiler.js +238 -154
- package/core/validator.js +79 -0
- package/formatter/mark.js +203 -43
- package/formatter/tag.js +202 -32
- package/grammar.ebnf +57 -50
- package/helpers/colorize.js +26 -13
- package/helpers/dedent.js +19 -0
- package/helpers/escapeHTML.js +13 -6
- package/helpers/kebabize.js +6 -0
- package/helpers/peek.js +9 -0
- package/helpers/removeChar.js +26 -13
- package/helpers/safeDataParser.js +114 -0
- package/helpers/utils.js +140 -158
- package/index.js +186 -188
- package/mappers/languages/html.js +105 -213
- package/mappers/languages/json.js +122 -171
- package/mappers/languages/markdown.js +355 -108
- package/mappers/languages/mdx.js +76 -120
- package/mappers/languages/xml.js +114 -0
- package/mappers/mapper.js +152 -123
- package/mappers/shared/index.js +22 -0
- package/package.json +26 -6
- package/SOMMARK-SPEC.md +0 -481
- package/cli/commands/list.js +0 -124
- package/constants/html_tags.js +0 -146
- package/core/pluginManager.js +0 -149
- package/core/plugins/comment-remover.js +0 -47
- package/core/plugins/module-system.js +0 -176
- package/core/plugins/raw-content-plugin.js +0 -78
- package/core/plugins/rules-validation-plugin.js +0 -231
- package/core/plugins/sommark-format.js +0 -244
- package/coverage_test.js +0 -21
- package/debug.js +0 -15
- package/helpers/camelize.js +0 -2
- package/helpers/defaultTheme.js +0 -3
- package/test_format_fix.js +0 -42
- package/v3-todo.smark +0 -73
package/cli/commands/print.js
CHANGED
|
@@ -8,26 +8,33 @@ import path from "node:path";
|
|
|
8
8
|
// ========================================================================== //
|
|
9
9
|
// Print Output //
|
|
10
10
|
// ========================================================================== //
|
|
11
|
+
/**
|
|
12
|
+
* Creates the code and prints it to the console.
|
|
13
|
+
* @param {string} format - The final file format ('html', 'markdown', etc.).
|
|
14
|
+
* @param {string} filePath - Path to the source .smark file.
|
|
15
|
+
*/
|
|
11
16
|
export async function printOutput(format, filePath) {
|
|
12
17
|
if (await isExist(filePath)) {
|
|
13
18
|
const fileName = path.basename(filePath);
|
|
14
19
|
console.log(formatMessage(`{line}<$blue: Printing output for$> <$yellow:'${fileName}'$>{line}`));
|
|
15
20
|
let source_code = await readContent(filePath);
|
|
21
|
+
const config = await loadConfig(filePath);
|
|
16
22
|
const absolutePath = path.resolve(process.cwd(), filePath);
|
|
17
23
|
if (format === "json") {
|
|
18
|
-
const output = await transpile({ src: source_code.toString(), format, filename: absolutePath });
|
|
24
|
+
const output = await transpile({ src: source_code.toString(), format, filename: absolutePath, config });
|
|
19
25
|
console.log(JSON.stringify(JSON.parse(output, null, 2), null, 2));
|
|
20
26
|
} else {
|
|
21
|
-
console.log(await transpile({ src: source_code.toString(), format, filename: absolutePath }));
|
|
27
|
+
console.log(await transpile({ src: source_code.toString(), format, filename: absolutePath, config }));
|
|
22
28
|
}
|
|
23
29
|
} else {
|
|
24
30
|
cliError([`{line}<$red:File$> <$blue:'${filePath}'$> <$red: is not found$>{line}`]);
|
|
25
31
|
}
|
|
26
32
|
}
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Prints the raw tokens of the file to the console as JSON.
|
|
36
|
+
* @param {string} filePath - Path to the source .smark file.
|
|
37
|
+
*/
|
|
31
38
|
export async function printLex(filePath) {
|
|
32
39
|
if (await isExist(filePath)) {
|
|
33
40
|
const fileName = path.basename(filePath);
|
|
@@ -37,13 +44,10 @@ export async function printLex(filePath) {
|
|
|
37
44
|
|
|
38
45
|
const absolutePath = path.resolve(process.cwd(), filePath);
|
|
39
46
|
const smark = new SomMark({
|
|
47
|
+
...config,
|
|
40
48
|
src: source_code.toString(),
|
|
41
49
|
format: "text",
|
|
42
50
|
filename: absolutePath,
|
|
43
|
-
plugins: config.plugins,
|
|
44
|
-
priority: config.priority,
|
|
45
|
-
excludePlugins: config.excludePlugins,
|
|
46
|
-
includeDocument: config.includeDocument ?? true
|
|
47
51
|
});
|
|
48
52
|
|
|
49
53
|
|
|
@@ -54,9 +58,10 @@ export async function printLex(filePath) {
|
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Prints the code tree of the file to the console as JSON.
|
|
63
|
+
* @param {string} filePath - Path to the source .smark file.
|
|
64
|
+
*/
|
|
60
65
|
export async function printParse(filePath) {
|
|
61
66
|
if (await isExist(filePath)) {
|
|
62
67
|
const fileName = path.basename(filePath);
|
|
@@ -66,13 +71,10 @@ export async function printParse(filePath) {
|
|
|
66
71
|
|
|
67
72
|
const absolutePath = path.resolve(process.cwd(), filePath);
|
|
68
73
|
const smark = new SomMark({
|
|
74
|
+
...config,
|
|
69
75
|
src: source_code.toString(),
|
|
70
76
|
format: "text",
|
|
71
77
|
filename: absolutePath,
|
|
72
|
-
plugins: config.plugins,
|
|
73
|
-
priority: config.priority,
|
|
74
|
-
excludePlugins: config.excludePlugins,
|
|
75
|
-
includeDocument: config.includeDocument ?? true
|
|
76
78
|
});
|
|
77
79
|
|
|
78
80
|
|
package/cli/commands/show.js
CHANGED
|
@@ -5,6 +5,10 @@ import { loadConfig, getResolvedConfigPath } from "../helpers/config.js";
|
|
|
5
5
|
// Show Command //
|
|
6
6
|
// ========================================================================== //
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Shows the configuration data or where the settings file is located.
|
|
10
|
+
* @param {string} target - The target to show ('config' or '--path-config').
|
|
11
|
+
*/
|
|
8
12
|
export async function runShow(target) {
|
|
9
13
|
const config = await loadConfig();
|
|
10
14
|
const resolvedPath = getResolvedConfigPath();
|
package/cli/commands/version.js
CHANGED
|
@@ -4,12 +4,18 @@ import projectJson from "../../package.json" with { type: "json" };
|
|
|
4
4
|
// Version Command //
|
|
5
5
|
// ========================================================================== //
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Prints the current version of SomMark (from package.json) to the console.
|
|
9
|
+
*/
|
|
7
10
|
export function printVersion() {
|
|
8
11
|
if (projectJson && projectJson.version) {
|
|
9
12
|
console.log(projectJson.version);
|
|
10
13
|
}
|
|
11
14
|
}
|
|
12
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Prints the SomMark header/banner with version, description, and copyright information.
|
|
18
|
+
*/
|
|
13
19
|
export function printHeader() {
|
|
14
20
|
console.log(
|
|
15
21
|
[
|
package/cli/constants.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* CLI Constants
|
|
3
|
+
* Supported options and format-to-extension mappings.
|
|
4
|
+
*/
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
/** @type {Array<string>} List of recognized CLI flags and commands. */
|
|
7
|
+
export const options = ["-v", "--version", "-h", "--help", "--html", "--markdown", "--mdx", "--json", "--text", "--xml", "--print", "-p", "--lex", "--parse", "list"];
|
|
6
8
|
|
|
9
|
+
/** @type {Object<string, string>} Map of output formats to their respective file extensions. */
|
|
7
10
|
export const extensions = {
|
|
8
11
|
text: "txt",
|
|
9
12
|
html: "html",
|
|
10
13
|
markdown: "md",
|
|
11
14
|
mdx: "mdx",
|
|
12
|
-
json: "json"
|
|
15
|
+
json: "json",
|
|
16
|
+
xml: "xml"
|
|
13
17
|
};
|
package/cli/helpers/config.js
CHANGED
|
@@ -6,12 +6,23 @@ import { findAndLoadConfig } from "../../core/helpers/config-loader.js";
|
|
|
6
6
|
|
|
7
7
|
let resolvedConfigPath = null;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Loads the SomMark settings (configuration) for a specific file or folder.
|
|
11
|
+
* Saves the path to the settings file for later.
|
|
12
|
+
*
|
|
13
|
+
* @param {string|null} [filename=null] - The file path to start searching for a config from.
|
|
14
|
+
* @returns {Promise<Object>} - The loaded configuration object.
|
|
15
|
+
*/
|
|
9
16
|
export async function loadConfig(filename = null) {
|
|
10
17
|
const config = await findAndLoadConfig(filename);
|
|
11
18
|
resolvedConfigPath = config.resolvedConfigPath;
|
|
12
19
|
return config;
|
|
13
20
|
}
|
|
14
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Returns the absolute path to the configuration file that was last loaded.
|
|
24
|
+
* @returns {string|null}
|
|
25
|
+
*/
|
|
15
26
|
export function getResolvedConfigPath() {
|
|
16
27
|
return resolvedConfigPath;
|
|
17
28
|
}
|
package/cli/helpers/file.js
CHANGED
|
@@ -7,6 +7,11 @@ import path from "node:path";
|
|
|
7
7
|
// ========================================================================== //
|
|
8
8
|
// Check if file exists //
|
|
9
9
|
// ========================================================================== //
|
|
10
|
+
/**
|
|
11
|
+
* Checks if a file or folder exists at the given path.
|
|
12
|
+
* @param {string} path - The path to check.
|
|
13
|
+
* @returns {Promise<boolean>}
|
|
14
|
+
*/
|
|
10
15
|
export const isExist = async path => {
|
|
11
16
|
try {
|
|
12
17
|
if (path) {
|
|
@@ -20,17 +25,23 @@ export const isExist = async path => {
|
|
|
20
25
|
}
|
|
21
26
|
};
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Reads all the text from an entire file.
|
|
30
|
+
* @param {string} path - The path to the file.
|
|
31
|
+
* @returns {Promise<Buffer>}
|
|
32
|
+
*/
|
|
26
33
|
export async function readContent(path) {
|
|
27
34
|
const content = await fs.readFile(path);
|
|
28
35
|
return content;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Creates a new file with text. It will create folders if they don't exist.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} folder - The directory path.
|
|
42
|
+
* @param {string} file - The filename.
|
|
43
|
+
* @param {string} content - The file content.
|
|
44
|
+
*/
|
|
34
45
|
export async function createFile(folder, file, content) {
|
|
35
46
|
if (!(await isExist(folder))) {
|
|
36
47
|
await fs.mkdir(folder, { recursive: true });
|
package/cli/helpers/transpile.js
CHANGED
|
@@ -15,38 +15,36 @@ const default_mapperFiles = { [htmlFormat]: HTML, [markdownFormat]: MARKDOWN, [m
|
|
|
15
15
|
// ========================================================================== //
|
|
16
16
|
// Transpile Function //
|
|
17
17
|
// ========================================================================== //
|
|
18
|
-
export async function transpile({ src, format, filename = null,
|
|
19
|
-
const
|
|
20
|
-
let finalMapper =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
18
|
+
export async function transpile({ src, format, filename = null, mapperFile = "", config = null }) {
|
|
19
|
+
const finalConfig = config || await loadConfig(filename);
|
|
20
|
+
let finalMapper = mapperFile;
|
|
21
|
+
|
|
22
|
+
// 1. Find the Mapping File
|
|
23
|
+
if (typeof mapperFile !== "object" || mapperFile === null) {
|
|
24
|
+
// Support both names from config
|
|
25
|
+
const configMapper = finalConfig.mapperFile || finalConfig.mappingFile;
|
|
26
|
+
|
|
27
|
+
if (configMapper) {
|
|
28
|
+
finalMapper = configMapper;
|
|
28
29
|
} else {
|
|
29
30
|
finalMapper = default_mapperFiles[format];
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
// Custom Mapper (String Path)
|
|
33
34
|
if (typeof finalMapper === "string" && finalMapper !== "" && (await isExist(finalMapper))) {
|
|
34
|
-
const
|
|
35
|
-
const loadedMapper = await import(
|
|
35
|
+
const mapperFileURL = pathToFileURL(path.resolve(process.cwd(), finalMapper)).href;
|
|
36
|
+
const loadedMapper = await import(mapperFileURL);
|
|
36
37
|
finalMapper = loadedMapper.default;
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
// 2.
|
|
41
|
+
// 2. Run SomMark Process
|
|
41
42
|
const smark = new SomMark({
|
|
43
|
+
...finalConfig,
|
|
42
44
|
src,
|
|
43
45
|
format,
|
|
44
46
|
filename,
|
|
45
47
|
mapperFile: finalMapper,
|
|
46
|
-
plugins: config.plugins,
|
|
47
|
-
priority: config.priority,
|
|
48
|
-
excludePlugins: config.excludePlugins,
|
|
49
|
-
includeDocument: config.includeDocument ?? true
|
|
50
48
|
});
|
|
51
49
|
|
|
52
50
|
return await smark.transpile();
|
package/core/errors.js
CHANGED
|
@@ -9,13 +9,17 @@ import colorize from "../helpers/colorize.js";
|
|
|
9
9
|
// Message Formatting //
|
|
10
10
|
// ========================================================================== //
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Processes a message by applying colors and formatting.
|
|
14
|
+
* Supports:
|
|
15
|
+
* - {line} : Adds a horizontal line
|
|
16
|
+
* - {N} : Adds a new line
|
|
17
|
+
* - <$color: Text$> : Adds color (red, yellow, green, blue, magenta, cyan)
|
|
18
|
+
*
|
|
19
|
+
* @param {string|string[]} text - The message or list of message parts to format.
|
|
20
|
+
* @returns {string} - The final formatted and colored string.
|
|
21
|
+
*/
|
|
12
22
|
function formatMessage(text) {
|
|
13
|
-
/*
|
|
14
|
-
Format System:
|
|
15
|
-
{line} = Draws a horizontal line
|
|
16
|
-
{N} = Inserts a newline
|
|
17
|
-
<$color: Text$> = Colors the text (supports red, yellow, green, blue, magenta, cyan)
|
|
18
|
-
*/
|
|
19
23
|
const horizontal_rule = "\n----------------------------------------------------------------------------------------------\n";
|
|
20
24
|
const pattern = /<\$([^:]+):([\s\S]*?)\$>/g;
|
|
21
25
|
|
|
@@ -39,7 +43,15 @@ function formatMessage(text) {
|
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
/**
|
|
42
|
-
*
|
|
46
|
+
* Creates a detailed error message showing where the error happened in the code.
|
|
47
|
+
* It adds a line number, a snippet of the code, and a pointer (^) to the exact spot.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} src - The original code being parsed.
|
|
50
|
+
* @param {Object} range - The location of the error (line and character).
|
|
51
|
+
* @param {string|null} filename - The name of the file (optional).
|
|
52
|
+
* @param {string|string[]} message - The error message to show.
|
|
53
|
+
* @param {string} typeName - The type of error (e.g., "Lexer" or "Parser").
|
|
54
|
+
* @returns {string[]} - A list of message parts that make up the final error report.
|
|
43
55
|
*/
|
|
44
56
|
function formatErrorWithContext(src, range, filename, message, typeName) {
|
|
45
57
|
if (!src || !range || !range.start) return message;
|
|
@@ -70,7 +82,14 @@ function formatErrorWithContext(src, range, filename, message, typeName) {
|
|
|
70
82
|
// Error Classes //
|
|
71
83
|
// ========================================================================== //
|
|
72
84
|
|
|
85
|
+
/** Base class for all SomMark errors that automatically formats messages for the terminal. */
|
|
73
86
|
class CustomError extends Error {
|
|
87
|
+
/**
|
|
88
|
+
* Creates a new error.
|
|
89
|
+
*
|
|
90
|
+
* @param {string|string[]} message - The text describing what went wrong.
|
|
91
|
+
* @param {string} name - The name of the error type.
|
|
92
|
+
*/
|
|
74
93
|
constructor(message, name) {
|
|
75
94
|
super(message);
|
|
76
95
|
this.name = name;
|
|
@@ -82,45 +101,39 @@ class CustomError extends Error {
|
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
class ParserError extends CustomError {
|
|
85
|
-
constructor(message) {
|
|
86
|
-
super(message, "Parser Error");
|
|
87
|
-
}
|
|
104
|
+
constructor(message) { super(message, "Parser Error"); }
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
class LexerError extends CustomError {
|
|
91
|
-
constructor(message) {
|
|
92
|
-
super(message, "Lexer Error");
|
|
93
|
-
}
|
|
108
|
+
constructor(message) { super(message, "Lexer Error"); }
|
|
94
109
|
}
|
|
95
110
|
|
|
96
111
|
class TranspilerError extends CustomError {
|
|
97
|
-
constructor(message) {
|
|
98
|
-
super(message, "Transpiler Error");
|
|
99
|
-
}
|
|
112
|
+
constructor(message) { super(message, "Transpiler Error"); }
|
|
100
113
|
}
|
|
101
114
|
|
|
102
115
|
class CLIError extends CustomError {
|
|
103
|
-
constructor(message) {
|
|
104
|
-
super(message, "CLI Error");
|
|
105
|
-
}
|
|
116
|
+
constructor(message) { super(message, "CLI Error"); }
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
class RuntimeError extends CustomError {
|
|
109
|
-
constructor(message) {
|
|
110
|
-
super(message, "Runtime Error");
|
|
111
|
-
}
|
|
120
|
+
constructor(message) { super(message, "Runtime Error"); }
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
class SommarkError extends CustomError {
|
|
115
|
-
constructor(message) {
|
|
116
|
-
super(message, "SomMark Error");
|
|
117
|
-
}
|
|
124
|
+
constructor(message) { super(message, "SomMark Error"); }
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
// ========================================================================== //
|
|
121
128
|
// Error Dispatcher (Helper) //
|
|
122
129
|
// ========================================================================== //
|
|
123
130
|
|
|
131
|
+
/**
|
|
132
|
+
* A helper that creates an error "dispatcher" for a specific category.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} type - The category of error (e.g., 'lexer', 'parser').
|
|
135
|
+
* @returns {Function} - A function that throws the formatted error.
|
|
136
|
+
*/
|
|
124
137
|
function getError(type) {
|
|
125
138
|
const validate_msg = msg => (Array.isArray(msg) && msg.length > 0) || typeof msg === "string";
|
|
126
139
|
const typeNames = {
|
|
@@ -157,11 +170,22 @@ function getError(type) {
|
|
|
157
170
|
};
|
|
158
171
|
}
|
|
159
172
|
|
|
173
|
+
/** Helper to throw Lexer errors. */
|
|
160
174
|
const lexerError = getError("lexer");
|
|
175
|
+
|
|
176
|
+
/** Helper to throw Parser errors. */
|
|
161
177
|
const parserError = getError("parser");
|
|
178
|
+
|
|
179
|
+
/** Helper to throw Transpiler errors. */
|
|
162
180
|
const transpilerError = getError("transpiler");
|
|
181
|
+
|
|
182
|
+
/** Helper to throw CLI errors. */
|
|
163
183
|
const cliError = getError("cli");
|
|
184
|
+
|
|
185
|
+
/** Helper to throw Runtime or Module errors. */
|
|
164
186
|
const runtimeError = getError("runtime");
|
|
187
|
+
|
|
188
|
+
/** Helper to throw general internal SomMark errors. */
|
|
165
189
|
const sommarkError = getError("sommark");
|
|
166
190
|
|
|
167
191
|
export { parserError, lexerError, transpilerError, cliError, runtimeError, sommarkError, formatMessage };
|
package/core/formats.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A list of supported output formats for SomMark.
|
|
3
|
+
*/
|
|
1
4
|
const formats = {
|
|
2
|
-
|
|
5
|
+
textFormat: "text",
|
|
3
6
|
htmlFormat: "html",
|
|
4
7
|
markdownFormat: "markdown",
|
|
5
8
|
mdxFormat: "mdx",
|
|
6
|
-
jsonFormat: "json"
|
|
9
|
+
jsonFormat: "json",
|
|
10
|
+
xmlFormat: "xml"
|
|
7
11
|
};
|
|
8
12
|
|
|
9
|
-
export const {textFormat, htmlFormat, markdownFormat, mdxFormat, jsonFormat} = formats;
|
|
13
|
+
export const { textFormat, htmlFormat, markdownFormat, mdxFormat, jsonFormat, xmlFormat } = formats;
|
|
10
14
|
export default formats;
|
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
}
|