Skip to content

Commit e5873ec

Browse files
committed
Initial release
1 parent 2bbf4ac commit e5873ec

19 files changed

Lines changed: 713 additions & 0 deletions

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
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.
44

5+
## Installing the Parser
6+
7+
- Clone the repository: `git clone https://github.com/ente/protoml-parser.git`
8+
- Install the app: `npm install -g .`
9+
- Restart your terminal
10+
- Run `protoparser test.pml html` to convert a file named `test.pml` to HTML.
11+
512
## Key Concepts
613

714
- **Purely declarative** - no logic, no runtime, just the code
@@ -108,6 +115,7 @@ ProtoML is a lightweight, declarative markup language designed for writing and s
108115
## protoparser (protoml-parser)
109116

110117
`protoparser` is the command-line tool for parsing `.pml` files (ProtoML) and converting them into structured formats such as JSON, HTML, PDF and more.
118+
**The parser currently only support HTML rendering.** The other formats are planned for future releases.
111119

112120
### Basic Usage
113121

bin/protoparser.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env node
2+
const { log, setVerbosity } = require("../src/utils/logger.js")
3+
const { parseArgs } = require("../src/cli/options.js");
4+
const { parseFile } = require("../src/core/parser.js");
5+
const { saveToFile } = require("../src/utils/file.js");
6+
const renderers = require("../src/renders/index.js");
7+
8+
const args = parseArgs()
9+
setVerbosity(args.verbosity)
10+
const ast = parseFile(args.filename, args)
11+
const output = renderers[args.format](ast, args)
12+
13+
saveToFile(args.output, output, args.format)
14+
log("info", `File saved to ${args.output}.${args.format}`)

package.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "protoml-parser",
3+
"version": "1.0.1",
4+
"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.",
5+
"keywords": [
6+
"protoml",
7+
"parser",
8+
"meeting",
9+
"protocol",
10+
"notes",
11+
"task",
12+
"list"
13+
],
14+
"main": "bin/protoparser.js",
15+
"bin": {
16+
"protoml-parser": "bin/protoparser.js",
17+
"protoparser": "bin/protoparser.js"
18+
},
19+
"scripts": {
20+
"test": "echo \"Error: no test specified\" && exit 1"
21+
},
22+
"repository": {
23+
"type": "git",
24+
"url": "git+https://github.com/Ente/protoml-parser.git"
25+
},
26+
"author": "Ente",
27+
"license": "GPL-3.0-or-later",
28+
"bugs": {
29+
"url": "https://github.com/Ente/protoml-parser/issues"
30+
},
31+
"homepage": "https://github.com/Ente/protoml-parser#readme"
32+
}

src/cli/options.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
function parseArgs(argv = process.argv.slice(2)) {
2+
const options = {
3+
filename: null,
4+
format: null,
5+
output: null,
6+
verbosity: 0,
7+
strict: false,
8+
theme: null,
9+
}
10+
11+
const positional = []
12+
13+
for (const arg of argv) {
14+
if (arg.startsWith("-v")) {
15+
options.verbosity = arg.length - 1 // -v, -vv, -vvv = 1–3
16+
} else if (arg.startsWith("-output=")) {
17+
options.output = arg.split("=")[1]
18+
} else if (arg.startsWith("-theme=")) {
19+
options.theme = arg.split("=")[1]
20+
} else if (arg === "-strict") {
21+
options.strict = true
22+
} else if (arg === "--help") {
23+
printHelp()
24+
process.exit(0)
25+
} else if (arg === "--version"){
26+
// read from package.json as JSON
27+
const fs = require("fs")
28+
const path = require("path")
29+
const packageJsonPath = path.join(__dirname, "..", "..", "package.json")
30+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
31+
const version = packageJson.version
32+
printVersion(version)
33+
process.exit(0)
34+
} else {
35+
positional.push(arg)
36+
}
37+
}
38+
39+
if (positional.length >= 1) options.filename = positional[0]
40+
if (positional.length >= 2) options.format = positional[1]
41+
42+
if (!options.filename || options.filename.startsWith("-")) {
43+
console.error("[X] No valid input file provided.")
44+
printHelp()
45+
process.exit(1)
46+
}
47+
48+
if (!options.format) {
49+
options.format = "json"
50+
}
51+
52+
if (!options.output) {
53+
options.output = options.filename.replace(/\.[^/.]+$/, "")
54+
}
55+
56+
return options
57+
}
58+
59+
60+
61+
function printHelp() {
62+
console.log(`
63+
Usage:
64+
protoparser [options] <filename> <format>
65+
66+
Options:
67+
-v, -vv, -vvv Set verbosity level (1–3)
68+
-output=<filename> Set output base name (without extension)
69+
-theme=<name> Apply export theme (HTML/PDF only)
70+
-strict Enable strict parsing
71+
--help Show this help
72+
--version Show version information
73+
74+
Examples:
75+
protoparser Meeting.pml json
76+
protoparser -vv -output=notes Meeting.pml html
77+
`)
78+
}
79+
80+
function printVersion(version) {
81+
console.log(`Protoparser version: ${version}`)
82+
}
83+
84+
module.exports = { parseArgs }

src/core/blockParser.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
function parseBlocks(tokens, options = {}) {
2+
const result = {};
3+
let currentBlock = null;
4+
5+
for (const token of tokens) {
6+
if (token.type === "meta") {
7+
result.meta = result.meta || {};
8+
result.meta[token.key] = token.value;
9+
continue;
10+
}
11+
12+
if (token.type === "command") {
13+
currentBlock = token.value.toLowerCase();
14+
if (!result[currentBlock]) {
15+
result[currentBlock] =
16+
currentBlock === "meeting"
17+
? []
18+
: currentBlock === "participants" || currentBlock === "tags"
19+
? {}
20+
: [];
21+
}
22+
}
23+
24+
if (!currentBlock) continue;
25+
26+
switch (token.type) {
27+
case "declaration":
28+
if (currentBlock === "participants") {
29+
const [name, alias, email] = token.value.split(",");
30+
result[currentBlock][token.key] = {
31+
name: name?.trim(),
32+
alias: alias?.trim(),
33+
email: email?.trim(),
34+
};
35+
} else if (currentBlock === "tags") {
36+
result[currentBlock][token.key] = token.value;
37+
} else {
38+
// General key:value map
39+
result[currentBlock][token.key] = token.value;
40+
}
41+
break;
42+
43+
case "entry":
44+
if (currentBlock === "tasks") {
45+
const done = token.value.startsWith("[x]");
46+
47+
const ptp = token.raw.match(/@ptp=([^\s]+)/)?.[1] || null;
48+
const subject = token.raw.match(/=([^\s]+)/)?.[1] || null;
49+
const tag = token.raw.match(/@tag=([^\s]+)/)?.[1] || null;
50+
51+
const cleanedText = token.value
52+
.replace(/^\[(x| )\]/, "")
53+
.replace(/@ptp=[^\s]+/g, "")
54+
.replace(/@tag=[^\s]+/g, "")
55+
.replace(/=[^\s]+/g, "")
56+
.replace(/\/\/.*/, "") // comments
57+
.trim();
58+
59+
result[currentBlock].push({
60+
raw: token.raw, // raw line (für referenceLinker)
61+
text: cleanedText, // cleaned text for display
62+
done,
63+
meta: {
64+
ptp,
65+
subject,
66+
tag,
67+
},
68+
});
69+
} else {
70+
result[currentBlock].push(token.value);
71+
}
72+
break;
73+
74+
case "heading":
75+
case "inlineCommand":
76+
case "text":
77+
if (currentBlock === "meeting") {
78+
result[currentBlock].push(token.raw);
79+
}
80+
break;
81+
}
82+
}
83+
84+
return result;
85+
}
86+
87+
module.exports = {parseBlocks};

src/core/inlineParser.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
function parseInline(ast, options = {}) {
2+
const parseText = (text) => {
3+
if (!text || typeof text !== "string") return text
4+
5+
// Links: -a=url text -a-
6+
text = text.replace(/-a=([^\s]+)\s(.*?)-a-/g, (_, url, label) =>
7+
`<a href="${url}" target="_blank">${label}</a>`)
8+
9+
// Bold: -b Text -b-
10+
text = text.replace(/-b\s(.*?)-b-/g, (_, content) =>
11+
`<b>${content}</b>`)
12+
13+
// Italic: -i Text -i-
14+
text = text.replace(/-i\s(.*?)-i-/g, (_, content) =>
15+
`<i>${content}</i>`)
16+
17+
return text
18+
}
19+
20+
// Meeting
21+
if (Array.isArray(ast.meeting)) {
22+
ast.meeting = ast.meeting.map(line => {
23+
// Optional: Kommentar entfernen
24+
line = line.replace(/\/\/.*/, "").trim()
25+
26+
if (line.startsWith("### ")) return `<h3>${line.slice(4)}</h3>`
27+
if (line.startsWith("## ")) return `<h2>${line.slice(3)}</h2>`
28+
if (line.startsWith("# ")) return `<h1>${line.slice(2)}</h1>`
29+
30+
return parseText(line)
31+
})
32+
}
33+
34+
35+
// Notes
36+
if (Array.isArray(ast.notes)) {
37+
ast.notes = ast.notes.map(parseText)
38+
}
39+
40+
// Tasks
41+
if (Array.isArray(ast.tasks)) {
42+
ast.tasks = ast.tasks.map(task => ({
43+
...task,
44+
text: parseText(task.text)
45+
}))
46+
}
47+
48+
// Subjects
49+
if (ast.subjects) {
50+
for (const key in ast.subjects) {
51+
ast.subjects[key] = parseText(ast.subjects[key])
52+
}
53+
}
54+
55+
return ast
56+
}
57+
58+
module.exports = { parseInline }

src/core/loader.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const fs = require("fs")
2+
const path = require("path")
3+
const { tokenize } = require("./tokenizer")
4+
const { parseBlocks } = require("./blockParser")
5+
6+
function loadAndMergeImports(mainAst, basePath, options = {}) {
7+
if (!mainAst || typeof mainAst !== "object") return mainAst
8+
9+
const importLines = findTagImports(mainAst)
10+
11+
for (const importFile of importLines) {
12+
const fullPath = path.resolve(basePath, importFile)
13+
14+
if (!fs.existsSync(fullPath)) {
15+
if (options.strict) throw new Error(`Import file not found: ${fullPath}`)
16+
else continue
17+
}
18+
19+
const raw = fs.readFileSync(fullPath, "utf8")
20+
const tokens = tokenize(raw)
21+
const importedAst = parseBlocks(tokens, options)
22+
23+
// Merge relevant blocks (tags, participants, subjects…)
24+
for (const key in importedAst) {
25+
if (!mainAst[key]) mainAst[key] = {}
26+
if (typeof importedAst[key] === "object" && !Array.isArray(importedAst[key])) {
27+
mainAst[key] = { ...importedAst[key], ...mainAst[key] } // imported gets overridden by main
28+
}
29+
}
30+
}
31+
32+
return mainAst
33+
}
34+
35+
function findTagImports(ast) {
36+
if (!ast || !ast.tags_import) return []
37+
38+
const lines = Array.isArray(ast.tags_import) ? ast.tags_import : [ast.tags_import]
39+
40+
return lines
41+
.map(line => line.match(/@tags_import\s+"(.+?)"/))
42+
.filter(Boolean)
43+
.map(match => match[1])
44+
}
45+
46+
module.exports = { loadAndMergeImports }

src/core/parser.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const fs = require("fs")
2+
const path = require("path")
3+
4+
const { tokenize } = require("./tokenizer")
5+
const { parseBlocks } = require("./blockParser")
6+
const { resolveReferences } = require("./referenceLinker")
7+
const { parseInline } = require("./inlineParser")
8+
const { loadAndMergeImports } = require("./loader")
9+
10+
function parseFile(filename, options = {}) {
11+
if (!fs.existsSync(filename)) {
12+
throw new Error(`File not found: ${filename}`)
13+
}
14+
15+
const raw = fs.readFileSync(filename, "utf8")
16+
const tokens = tokenize(raw)
17+
let ast = parseBlocks(tokens, options)
18+
19+
ast = loadAndMergeImports(ast, path.dirname(filename), options)
20+
21+
ast = resolveReferences(ast, options)
22+
ast = parseInline(ast, options)
23+
24+
return ast
25+
}
26+
27+
module.exports = { parseFile }

0 commit comments

Comments
 (0)