markedin-parser 0.1.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/package.json +28 -0
- package/parse.js +240 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "markedin-parser",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Parse and render .mi (markedin) files — YAML frontmatter + templated Markdown",
|
|
5
|
+
"main": "parse.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"parse.js"
|
|
8
|
+
],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test tests/parse.test.js"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/stonebraker/markedin.git",
|
|
15
|
+
"directory": "parsers/js"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/stonebraker/markedin#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/stonebraker/markedin/issues"
|
|
20
|
+
},
|
|
21
|
+
"author": "Jason Stonebraker",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"keywords": ["markedin", "mi", "frontmatter", "yaml", "markdown", "template"],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"js-yaml": "^4.1.1",
|
|
26
|
+
"marked": "^17.0.4"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/parse.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .mi (markedin) parser
|
|
3
|
+
*
|
|
4
|
+
* Format spec:
|
|
5
|
+
* - YAML frontmatter between --- delimiters (supports full YAML: objects, arrays, nested)
|
|
6
|
+
* - Markdown body with template expressions:
|
|
7
|
+
* {{key}} — scalar interpolation
|
|
8
|
+
* {{key.nested.path}} — deep path access
|
|
9
|
+
* {{array[0]}} — array index access
|
|
10
|
+
* {{#each items}}...{{/each}} — iterate array; inside, {{this}} or {{this.field}}
|
|
11
|
+
* {{#if condition}}...{{/if}} — conditional block
|
|
12
|
+
* {{> partial_key}} — inline another frontmatter string as markdown
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const yaml = require('js-yaml');
|
|
16
|
+
const { marked } = require('marked');
|
|
17
|
+
|
|
18
|
+
// ─── Frontmatter extraction ───────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function parse(source) {
|
|
21
|
+
// Empty frontmatter block: ---\n---
|
|
22
|
+
const EMPTY_FM_RE = /^---\r?\n---\r?\n?([\s\S]*)$/;
|
|
23
|
+
const emptyMatch = source.match(EMPTY_FM_RE);
|
|
24
|
+
if (emptyMatch) return { data: {}, body: emptyMatch[1] };
|
|
25
|
+
|
|
26
|
+
const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
27
|
+
const match = source.match(FM_RE);
|
|
28
|
+
if (!match) {
|
|
29
|
+
return { data: {}, body: source };
|
|
30
|
+
}
|
|
31
|
+
const data = yaml.load(match[1]) ?? {};
|
|
32
|
+
const body = match[2];
|
|
33
|
+
return { data, body };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Path resolution ─────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function resolvePath(obj, path) {
|
|
39
|
+
// Supports: key, key.nested, key[0], key[0].nested, key.nested[1].deep
|
|
40
|
+
const parts = path
|
|
41
|
+
.replace(/\[(\d+)\]/g, '.$1') // array[0] → array.0
|
|
42
|
+
.split('.');
|
|
43
|
+
let cur = obj;
|
|
44
|
+
for (const part of parts) {
|
|
45
|
+
if (cur == null) return undefined;
|
|
46
|
+
cur = cur[part];
|
|
47
|
+
}
|
|
48
|
+
return cur;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Truthiness ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function isTruthy(val) {
|
|
54
|
+
if (val == null) return false;
|
|
55
|
+
if (typeof val === 'boolean') return val;
|
|
56
|
+
if (typeof val === 'number') return val !== 0;
|
|
57
|
+
if (typeof val === 'string') return val !== '';
|
|
58
|
+
if (Array.isArray(val)) return val.length > 0;
|
|
59
|
+
if (typeof val === 'object') return Object.keys(val).length > 0;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Format value ────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function formatValue(val) {
|
|
66
|
+
if (val == null) return '';
|
|
67
|
+
if (Array.isArray(val)) return val.join(', ');
|
|
68
|
+
if (typeof val === 'object') return JSON.stringify(val);
|
|
69
|
+
return String(val);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Block processing ────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function findClose(str, nestedOpenRe, closeTag, from) {
|
|
75
|
+
let depth = 1;
|
|
76
|
+
let pos = from;
|
|
77
|
+
while (depth > 0) {
|
|
78
|
+
const closeIdx = str.indexOf(closeTag, pos);
|
|
79
|
+
if (closeIdx === -1) return -1;
|
|
80
|
+
nestedOpenRe.lastIndex = pos;
|
|
81
|
+
let nm;
|
|
82
|
+
while ((nm = nestedOpenRe.exec(str)) !== null && nm.index < closeIdx) {
|
|
83
|
+
depth++;
|
|
84
|
+
}
|
|
85
|
+
if (--depth === 0) return closeIdx;
|
|
86
|
+
pos = closeIdx + closeTag.length;
|
|
87
|
+
}
|
|
88
|
+
return -1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findTopLevelElse(content) {
|
|
92
|
+
const re = /\{\{#if [\w.[\]]+\}\}|\{\{\/if\}\}|\{\{else\}\}/g;
|
|
93
|
+
let depth = 0, m;
|
|
94
|
+
while ((m = re.exec(content)) !== null) {
|
|
95
|
+
if (m[0].startsWith('{{#if ')) depth++;
|
|
96
|
+
else if (m[0] === '{{/if}}') depth--;
|
|
97
|
+
else if (m[0] === '{{else}}' && depth === 0) return m.index;
|
|
98
|
+
}
|
|
99
|
+
return -1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function processBlocks(str, openRe, nestedOpenRe, closeTag, fn) {
|
|
103
|
+
let result = '';
|
|
104
|
+
let lastEnd = 0;
|
|
105
|
+
let m;
|
|
106
|
+
while ((m = openRe.exec(str)) !== null) {
|
|
107
|
+
const openEnd = m.index + m[0].length;
|
|
108
|
+
const closeIdx = findClose(str, nestedOpenRe, closeTag, openEnd);
|
|
109
|
+
if (closeIdx === -1) continue;
|
|
110
|
+
const inner = str.slice(openEnd, closeIdx);
|
|
111
|
+
result += str.slice(lastEnd, m.index);
|
|
112
|
+
result += fn(m[1], inner);
|
|
113
|
+
lastEnd = closeIdx + closeTag.length;
|
|
114
|
+
openRe.lastIndex = lastEnd;
|
|
115
|
+
}
|
|
116
|
+
return result + str.slice(lastEnd);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Render ───────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function render(source, { embed = false } = {}) {
|
|
122
|
+
const { data, body } = parse(source);
|
|
123
|
+
let out = renderTemplate(body, data);
|
|
124
|
+
if (embed) {
|
|
125
|
+
out = out.trimEnd() + '\n\n<!-- frontmatter\n' + JSON.stringify(data, null, 2) + '\n-->\n';
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderHtmlFrag(source) {
|
|
131
|
+
const md = render(source);
|
|
132
|
+
return marked.parse(md, { gfm: true });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderHtml(source, { embed = false } = {}) {
|
|
136
|
+
const { data, body } = parse(source);
|
|
137
|
+
const rendered = renderTemplate(body, data);
|
|
138
|
+
const htmlBody = marked.parse(rendered, { gfm: true });
|
|
139
|
+
const title = data.title || '';
|
|
140
|
+
let dataBlock = '';
|
|
141
|
+
if (embed) {
|
|
142
|
+
dataBlock = '\n<script type="application/json" id="frontmatter">\n' + JSON.stringify(data, null, 2) + '\n</script>';
|
|
143
|
+
}
|
|
144
|
+
return HTML_TEMPLATE.replace('%TITLE%', title).replace('%DATA_BLOCK%', dataBlock).replace('%BODY%', htmlBody);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const HTML_TEMPLATE = `<!doctype html>
|
|
148
|
+
<html lang="en">
|
|
149
|
+
<head>
|
|
150
|
+
<meta charset="UTF-8" />
|
|
151
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
152
|
+
<title>%TITLE%</title>%DATA_BLOCK%
|
|
153
|
+
<style>
|
|
154
|
+
body { max-width: 720px; margin: 0 auto; padding: 3rem 1.5rem; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 1.0625rem; line-height: 1.65; color: #1f2937; }
|
|
155
|
+
h1, h2, h3 { color: #111827; letter-spacing: -.02em; }
|
|
156
|
+
h1 { font-size: 2rem; margin-bottom: .5rem; }
|
|
157
|
+
h2 { font-size: 1.4rem; margin-top: 2.5rem; }
|
|
158
|
+
h3 { font-size: 1.1rem; margin-top: 2rem; }
|
|
159
|
+
code { font-family: "SF Mono", ui-monospace, Menlo, monospace; font-size: .875em; background: #f3f4f6; padding: .1em .35em; border-radius: 3px; }
|
|
160
|
+
pre { background: #f3f4f6; border-radius: 6px; padding: 1.25rem; overflow-x: auto; }
|
|
161
|
+
pre code { background: none; padding: 0; }
|
|
162
|
+
table { border-collapse: collapse; width: 100%; }
|
|
163
|
+
th, td { text-align: left; padding: .5rem .75rem; border-bottom: 1px solid #e5e7eb; }
|
|
164
|
+
th { font-size: .8125rem; text-transform: uppercase; letter-spacing: .05em; color: #6b7280; }
|
|
165
|
+
a { color: #2563eb; }
|
|
166
|
+
hr { border: none; border-top: 1px solid #e5e7eb; margin: 2.5rem 0; }
|
|
167
|
+
</style>
|
|
168
|
+
</head>
|
|
169
|
+
<body>
|
|
170
|
+
%BODY%
|
|
171
|
+
</body>
|
|
172
|
+
</html>`;
|
|
173
|
+
|
|
174
|
+
function renderTemplate(template, ctx) {
|
|
175
|
+
const registry = new Map();
|
|
176
|
+
let seq = 0;
|
|
177
|
+
|
|
178
|
+
function protect(str) {
|
|
179
|
+
const tok = `\x02${seq++}\x03`;
|
|
180
|
+
registry.set(tok, str);
|
|
181
|
+
return tok;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function restore(str) {
|
|
185
|
+
let out = str;
|
|
186
|
+
for (const [tok, val] of [...registry.entries()].reverse()) {
|
|
187
|
+
out = out.split(tok).join(val);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let out = template;
|
|
193
|
+
|
|
194
|
+
// 1. {{#each key}} ... {{/each}}
|
|
195
|
+
out = processBlocks(out,
|
|
196
|
+
/\{\{#each ([\w.[\]]+)\}\}/g,
|
|
197
|
+
/\{\{#each [\w.[\]]+\}\}/g,
|
|
198
|
+
'{{/each}}',
|
|
199
|
+
(key, inner) => {
|
|
200
|
+
const arr = resolvePath(ctx, key);
|
|
201
|
+
if (!Array.isArray(arr)) return protect('');
|
|
202
|
+
return protect(arr.map((item, i) => {
|
|
203
|
+
const itemCtx = { ...ctx, this: item, '@index': i, '@first': i === 0, '@last': i === arr.length - 1 };
|
|
204
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) Object.assign(itemCtx, item);
|
|
205
|
+
return renderTemplate(inner, itemCtx);
|
|
206
|
+
}).join(''));
|
|
207
|
+
}
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// 2. {{#if key}} ... {{else}} ... {{/if}}
|
|
211
|
+
out = processBlocks(out,
|
|
212
|
+
/\{\{#if ([\w.[\]]+)\}\}/g,
|
|
213
|
+
/\{\{#if [\w.[\]]+\}\}/g,
|
|
214
|
+
'{{/if}}',
|
|
215
|
+
(key, inner) => {
|
|
216
|
+
const val = resolvePath(ctx, key);
|
|
217
|
+
const elseIdx = findTopLevelElse(inner);
|
|
218
|
+
const truthy = elseIdx === -1 ? inner : inner.slice(0, elseIdx);
|
|
219
|
+
const falsy = elseIdx === -1 ? '' : inner.slice(elseIdx + '{{else}}'.length);
|
|
220
|
+
return protect(renderTemplate(isTruthy(val) ? truthy : falsy, ctx));
|
|
221
|
+
}
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// 3. {{> partial_key}}
|
|
225
|
+
out = out.replace(/\{\{> ?([\w.[\]]+)\}\}/g, (_, path) => {
|
|
226
|
+
const val = resolvePath(ctx, path);
|
|
227
|
+
return val != null ? protect(String(val)) : '';
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// 4. {{key}} — scalar interpolation
|
|
231
|
+
out = out.replace(/\{\{([\w.[\]@]+)\}\}/g, (_, path) => {
|
|
232
|
+
const val = resolvePath(ctx, path);
|
|
233
|
+
if (val == null) return '';
|
|
234
|
+
return protect(formatValue(val));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return restore(out);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = { parse, render, renderHtmlFrag, renderHtml, renderTemplate, resolvePath };
|