Skip to content

Commit 34b63a4

Browse files
committed
Adding macros
1 parent 82e5bc0 commit 34b63a4

7 files changed

Lines changed: 139 additions & 35 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ ProtoML is a lightweight, declarative markup language designed for writing and s
3232
## Example
3333
```plaintext
3434
@tags_import "tags.pml"
35+
@macro myMacro "myMacro.pml"
3536
3637
@date:21.05.2025
3738
@participants // or @ptp
@@ -52,6 +53,8 @@ ProtoML is a lightweight, declarative markup language designed for writing and s
5253
# Meeting Title: @@e=0 // echoes value of ID 0
5354
## Participants
5455
@@e=pt1 , @@e=pt2
56+
## Some topic
57+
@@macro=myMacro:title=IMPORTANT;text=@@e=1
5558
.....
5659
5760
```
@@ -64,6 +67,16 @@ ProtoML is a lightweight, declarative markup language designed for writing and s
6467
=important:Critical, high priority
6568
```
6669

70+
## External macro file (myMacro.pml)
71+
72+
```plaintext
73+
@new_macro
74+
=name:myMacro
75+
=template:
76+
<div class="warn-box><strong>{{title}}</strong><br />{{text}}</div>
77+
78+
```
79+
6780
## Parser logic (simplified)
6881

6982
- `@` starts a block

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "protoml-parser",
3-
"version": "1.0.2",
3+
"version": "1.0.4",
44
"description": "ProtoML is a lightweight, declarative markup language designed for writing and structuring meeting protocols, notes and task lists in a human-readable and machine-parseable format.",
55
"keywords": [
66
"protoml",

src/core/blockParser.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@ function parseBlocks(tokens, options = {}) {
99
continue;
1010
}
1111

12+
if (token.type === "macro") {
13+
result.macros = result.macros || {};
14+
result.macros[token.name.trim()] = token.file.trim();
15+
continue;
16+
}
17+
1218
if (token.type === "command") {
19+
1320
currentBlock = token.value.toLowerCase();
1421
if (!result[currentBlock]) {
1522
result[currentBlock] =
@@ -49,7 +56,7 @@ function parseBlocks(tokens, options = {}) {
4956
const tag = token.raw.match(/@tag=([^\s]+)/)?.[1] || null;
5057

5158
const cleanedText = token.value
52-
.replace(/^\[(x| )\]/, "")
59+
.replace(/^\[(x| )\]/, "")
5360
.replace(/@ptp=[^\s]+/g, "")
5461
.replace(/@tag=[^\s]+/g, "")
5562
.replace(/=[^\s]+/g, "")

src/core/loader.js

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,46 @@ function findTagImports(ast) {
4343
.map(match => match[1])
4444
}
4545

46-
module.exports = { loadAndMergeImports }
46+
const macroCache = {}
47+
48+
function loadAndMergeMacros(ast, basePath, options = {}) {
49+
if (!ast.macros) return ast
50+
51+
for (const name in ast.macros) {
52+
const file = ast.macros[name]
53+
const fullPath = path.join(basePath, file.replace(/\"/g, ""))
54+
55+
if (!fs.existsSync(fullPath)) continue
56+
const lines = fs.readFileSync(fullPath, "utf8").split(/\r?\n/)
57+
58+
let macroName = null
59+
let templateLines = []
60+
let inTemplate = false
61+
62+
for (const line of lines) {
63+
if (line.startsWith("=name:")) {
64+
macroName = line.slice(6).trim()
65+
} else if (line.startsWith("=template:")) {
66+
inTemplate = true
67+
const rest = line.slice(10).trim()
68+
if (rest) templateLines.push(rest)
69+
} else if (inTemplate && (line.startsWith("@") || line.startsWith("="))) {
70+
// Stop reading template
71+
inTemplate = false
72+
} else if (inTemplate) {
73+
templateLines.push(line)
74+
}
75+
}
76+
77+
if (macroName && templateLines.length) {
78+
macroCache[macroName] = templateLines.join("\n")
79+
}
80+
}
81+
82+
ast._macroCache = macroCache
83+
return ast
84+
}
85+
86+
87+
88+
module.exports = { loadAndMergeImports, loadAndMergeMacros }

src/core/parser.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const { tokenize } = require("./tokenizer")
55
const { parseBlocks } = require("./blockParser")
66
const { resolveReferences } = require("./referenceLinker")
77
const { parseInline } = require("./inlineParser")
8-
const { loadAndMergeImports } = require("./loader")
8+
const { loadAndMergeImports, loadAndMergeMacros } = require("./loader")
99

1010
function parseFile(filename, options = {}) {
1111
if (!fs.existsSync(filename)) {
@@ -17,6 +17,7 @@ function parseFile(filename, options = {}) {
1717
let ast = parseBlocks(tokens, options)
1818

1919
ast = loadAndMergeImports(ast, path.dirname(filename), options)
20+
ast = loadAndMergeMacros(ast, path.dirname(filename), options)
2021

2122
ast = resolveReferences(ast, options)
2223
ast = parseInline(ast, options)

src/core/referenceLinker.js

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,45 @@ function resolveReferences(ast, options = {}) {
33
if (!ast[group] || !ast[group][id]) {
44
if (options.strict)
55
throw new Error(`Unresolved reference @${group}=${id}`);
6-
return {id, unresolved: true};
6+
return { id, unresolved: true };
77
}
8-
return {id, ...ast[group][id]};
8+
return { id, ...ast[group][id] };
9+
};
10+
11+
const expandMacros = (block) => {
12+
return block.flatMap((line) => {
13+
const match = line.match(/@@macro=([\w-]+):(.*)/);
14+
if (!match || !ast._macroCache) return [line];
15+
16+
const [_, macroName, rawParams] = match;
17+
const template = ast._macroCache[macroName];
18+
if (!template) return [line];
19+
20+
const params = {};
21+
rawParams.split(";").forEach((p) => {
22+
const [k, v] = p.split("=");
23+
params[k.trim()] = v.trim();
24+
});
25+
26+
const rendered = template.replace(/\{\{(.*?)\}\}/g, (_, key) => {
27+
const val = params[key] || "";
28+
if (val.startsWith("@@e=")) {
29+
const id = val.slice(5);
30+
return ast.subjects?.[id]
31+
|| ast.participants?.[id]?.name
32+
|| ast.tags?.[id]
33+
|| id;
34+
}
35+
return val;
36+
});
37+
38+
return [rendered];
39+
});
940
};
1041

1142
if (Array.isArray(ast.tasks)) {
1243
ast.tasks = ast.tasks.map((task) => {
13-
const out = {...task};
44+
const out = { ...task };
1445

1546
const ptpMatch = task.raw.match(/@ptp=([^\s]+)/);
1647
const subjMatch = [...task.raw.matchAll(/(?:\s|^)=([^\s]+)/g)].pop();
@@ -25,32 +56,37 @@ function resolveReferences(ast, options = {}) {
2556
}
2657

2758
if (Array.isArray(ast.meeting)) {
28-
ast.meeting = ast.meeting.map((line) => {
29-
const echoMatches = [...line.matchAll(/@@e=([^\s]+)/g)];
30-
let resolved = line;
31-
32-
for (const match of echoMatches) {
33-
const id = match[1];
34-
let replacement = id;
35-
36-
if (ast.subjects?.[id]) {
37-
replacement = ast.subjects[id];
38-
} else if (ast.participants?.[id]) {
39-
replacement = ast.participants[id].name;
40-
} else if (ast.tags?.[id]) {
41-
replacement = ast.tags[id];
42-
} else if (options.strict) {
43-
throw new Error(`@@e=${id} not found`);
59+
ast.meeting = ast.meeting
60+
.map((line) => {
61+
const echoMatches = [...line.matchAll(/@@e=([^\s]+)/g)];
62+
let resolved = line;
63+
64+
for (const match of echoMatches) {
65+
const id = match[1];
66+
let replacement = id;
67+
68+
if (ast.subjects?.[id]) {
69+
replacement = ast.subjects[id];
70+
} else if (ast.participants?.[id]) {
71+
replacement = ast.participants[id].name;
72+
} else if (ast.tags?.[id]) {
73+
replacement = ast.tags[id];
74+
} else if (options.strict) {
75+
throw new Error(`@@e=${id} not found`);
76+
}
77+
78+
resolved = resolved.replace(match[0], replacement);
4479
}
4580

46-
resolved = resolved.replace(match[0], replacement);
47-
}
81+
return resolved;
82+
});
4883

49-
return resolved;
50-
});
84+
ast.meeting = expandMacros(ast.meeting);
5185
}
5286

5387
return ast;
5488
}
5589

90+
91+
5692
module.exports = {resolveReferences};

src/core/tokenizer.js

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,22 @@ function tokenize(text) {
1111
}
1212
if (raw.match(/^@\w+:/)) {
1313
const [key, ...val] = raw.slice(1).split(":");
14-
tokens.push({type: "meta", key, value: val.join(":").trim(), line});
14+
tokens.push({ type: "meta", key, value: val.join(":").trim(), line });
15+
continue;
16+
}
17+
18+
if (raw.startsWith("@macro ")) {
19+
const [, name, file] = raw.split(" ");
20+
tokens.push({ type: "macro", name, file: file.replace(/\"/g, ""), raw, line });
1521
continue;
1622
}
1723

1824
// Command block e.g. @participants
1925
if (raw.startsWith("@@")) {
20-
tokens.push({type: "inlineCommand", raw, command: raw.slice(2), line});
26+
tokens.push({ type: "inlineCommand", raw, command: raw.slice(2), line });
2127
} else if (raw.startsWith("@")) {
2228
const value = raw.slice(1).split(":")[0].split("=")[0];
23-
tokens.push({type: "command", raw, value, line});
29+
tokens.push({ type: "command", raw, value, line });
2430
}
2531
// Declaration with ID e.g. =pt1:Some value
2632
else if (raw.startsWith("=")) {
@@ -35,21 +41,20 @@ function tokenize(text) {
3541
}
3642
// List/entry item e.g. - Something
3743
else if (raw.startsWith("-")) {
38-
tokens.push({type: "entry", raw, value: raw.slice(1).trim(), line});
44+
tokens.push({ type: "entry", raw, value: raw.slice(1).trim(), line });
3945
}
4046
// Markdown header
4147
else if (raw.match(/^#{1,4}\s+/)) {
4248
const level = raw.match(/^#+/)[0].length;
4349
const value = raw.slice(level).trim();
44-
tokens.push({type: "heading", raw, value, level, line});
50+
tokens.push({ type: "heading", raw, value, level, line });
4551
}
4652
// Fallback
4753
else {
48-
tokens.push({type: "text", raw, value: raw, line});
54+
tokens.push({ type: "text", raw, value: raw, line });
4955
}
5056
}
51-
5257
return tokens;
5358
}
5459

55-
module.exports = {tokenize};
60+
module.exports = { tokenize };

0 commit comments

Comments
 (0)