@pieceful/ravel-markdown 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/LICENSE +9 -0
- package/package.json +23 -0
- package/src/.gitkeep +1 -0
- package/src/index.d.ts +6 -0
- package/src/index.js +582 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Taylor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pieceful/ravel-markdown",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Markdown profile adapters for Ravel.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "James Taylor",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/jostylr/ravel.git", "directory": "packages/markdown" },
|
|
9
|
+
"bugs": { "url": "https://github.com/jostylr/ravel/issues" },
|
|
10
|
+
"homepage": "https://github.com/jostylr/ravel#readme",
|
|
11
|
+
"engines": { "node": ">=22" },
|
|
12
|
+
"keywords": ["ravel", "literate-programming", "markdown"],
|
|
13
|
+
"publishConfig": { "access": "public" },
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.js"
|
|
16
|
+
},
|
|
17
|
+
"types": "./src/index.d.ts",
|
|
18
|
+
"files": ["src", "LICENSE"],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"mdast-util-from-markdown": "^2.0.3",
|
|
21
|
+
"yaml": "^2.9.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface SourcePosition { line: number; column: number; offset: number; }
|
|
2
|
+
export interface SourceRange { start: SourcePosition; end: SourcePosition; }
|
|
3
|
+
export interface SourceLocation { uri: string; range: SourceRange; }
|
|
4
|
+
export interface Diagnostic { code: string; severity: "error" | "warning" | "info"; message: string; source: SourceLocation; }
|
|
5
|
+
export interface MarkdownRavelMap { version: 1; document: { id: string; uri: string; format: string }; chunks: Array<Record<string, unknown>>; directives: Array<Record<string, unknown>>; }
|
|
6
|
+
export function markdownToMap(text: string, options?: { uri?: string; document?: string; mode?: "opt-in" | "primary" }): { map: MarkdownRavelMap; diagnostics: Diagnostic[] };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
|
|
4
|
+
const componentPattern = /^[a-z][a-z0-9-]*$/;
|
|
5
|
+
const controlClasses = new Set(["ravel", "no-ravel", "greedy", "end"]);
|
|
6
|
+
|
|
7
|
+
const diagnostic = (code, message, source) => ({
|
|
8
|
+
code,
|
|
9
|
+
severity: "error",
|
|
10
|
+
message,
|
|
11
|
+
source
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const lineStarts = (text) => {
|
|
15
|
+
const starts = [0];
|
|
16
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
17
|
+
if (text[index] === "\n") starts.push(index + 1);
|
|
18
|
+
}
|
|
19
|
+
return starts;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const positionAt = (starts, offset) => {
|
|
23
|
+
let low = 0;
|
|
24
|
+
let high = starts.length;
|
|
25
|
+
while (low + 1 < high) {
|
|
26
|
+
const middle = Math.floor((low + high) / 2);
|
|
27
|
+
if (starts[middle] <= offset) low = middle;
|
|
28
|
+
else high = middle;
|
|
29
|
+
}
|
|
30
|
+
return { line: low, column: offset - starts[low], offset };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const rangeAt = (uri, starts, start, end) => ({
|
|
34
|
+
uri,
|
|
35
|
+
range: { start: positionAt(starts, start), end: positionAt(starts, end) }
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const defaultDocumentId = (uri) => {
|
|
39
|
+
const base = uri.split(/[\\/]/).at(-1)?.replace(/\.[^.]+$/, "") ?? "";
|
|
40
|
+
const id = base.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
41
|
+
return componentPattern.test(id) ? id : null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const documentFromFrontMatter = (text) => {
|
|
45
|
+
if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) return undefined;
|
|
46
|
+
const firstEnd = text.indexOf("\n");
|
|
47
|
+
const close = /^---\s*\r?$/m.exec(text.slice(firstEnd + 1));
|
|
48
|
+
if (!close) return undefined;
|
|
49
|
+
try {
|
|
50
|
+
const data = parseYaml(text.slice(firstEnd + 1, firstEnd + 1 + close.index));
|
|
51
|
+
return data?.ravel?.document;
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const tokens = (text) => {
|
|
58
|
+
const result = [];
|
|
59
|
+
let index = 0;
|
|
60
|
+
while (index < text.length) {
|
|
61
|
+
while (/\s/.test(text[index] ?? "")) index += 1;
|
|
62
|
+
if (index >= text.length) break;
|
|
63
|
+
const start = index;
|
|
64
|
+
let quote = "";
|
|
65
|
+
let escaped = false;
|
|
66
|
+
while (index < text.length) {
|
|
67
|
+
const character = text[index];
|
|
68
|
+
if (quote) {
|
|
69
|
+
if (escaped) escaped = false;
|
|
70
|
+
else if (character === "\\") escaped = true;
|
|
71
|
+
else if (character === quote) quote = "";
|
|
72
|
+
} else if (character === "\"" || character === "'") {
|
|
73
|
+
quote = character;
|
|
74
|
+
} else if (/\s/.test(character)) {
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
index += 1;
|
|
78
|
+
}
|
|
79
|
+
if (quote) return null;
|
|
80
|
+
result.push(text.slice(start, index));
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const stringValue = (value) => {
|
|
86
|
+
if (value.startsWith("\"")) {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(value);
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
94
|
+
return value.slice(1, -1).replace(/\\'/g, "'").replace(/\\\\/g, "\\");
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const parseAttributes = (meta, source) => {
|
|
100
|
+
const empty = { id: null, classes: [], values: {}, diagnostics: [] };
|
|
101
|
+
if (!meta?.trim()) return empty;
|
|
102
|
+
const trimmed = meta.trim();
|
|
103
|
+
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return empty;
|
|
104
|
+
const parts = tokens(trimmed.slice(1, -1));
|
|
105
|
+
if (!parts) {
|
|
106
|
+
return { ...empty, diagnostics: [diagnostic("RM101", "Unterminated quoted fenced-block attribute.", source)] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = { ...empty };
|
|
110
|
+
for (const part of parts) {
|
|
111
|
+
if (part.startsWith("#")) {
|
|
112
|
+
if (result.id !== null || part.length === 1) {
|
|
113
|
+
result.diagnostics.push(diagnostic("RM101", "A Ravel fence may have only one #chunk identifier.", source));
|
|
114
|
+
} else {
|
|
115
|
+
result.id = part.slice(1);
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (part.startsWith(".")) {
|
|
120
|
+
if (part.length === 1) result.diagnostics.push(diagnostic("RM101", "Empty fenced-block class.", source));
|
|
121
|
+
else result.classes.push(part.slice(1));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const separator = part.indexOf("=");
|
|
125
|
+
if (separator <= 0) {
|
|
126
|
+
result.diagnostics.push(diagnostic("RM101", "Fenced-block attributes must be #id, .class, or key=value.", source));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const key = part.slice(0, separator);
|
|
130
|
+
const value = stringValue(part.slice(separator + 1));
|
|
131
|
+
if (!componentPattern.test(key) || typeof value !== "string" || Object.hasOwn(result.values, key)) {
|
|
132
|
+
result.diagnostics.push(diagnostic("RM101", "Malformed or duplicate fenced-block attribute: " + part, source));
|
|
133
|
+
} else {
|
|
134
|
+
result.values[key] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const codeNodes = (node, found = []) => {
|
|
141
|
+
if (node.type === "code") found.push(node);
|
|
142
|
+
for (const child of node.children ?? []) codeNodes(child, found);
|
|
143
|
+
return found;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const fenceBody = (text, node, starts, uri) => {
|
|
147
|
+
const start = node.position.start.offset;
|
|
148
|
+
const end = node.position.end.offset;
|
|
149
|
+
const block = text.slice(start, end);
|
|
150
|
+
const openingEnd = block.indexOf("\n");
|
|
151
|
+
const closingStart = block.lastIndexOf("\n") + 1;
|
|
152
|
+
const bodyStart = openingEnd === -1 ? end : start + openingEnd + 1;
|
|
153
|
+
const bodyEnd = closingStart === 0 ? bodyStart : start + closingStart;
|
|
154
|
+
return {
|
|
155
|
+
body: text.slice(bodyStart, bodyEnd),
|
|
156
|
+
start: bodyStart,
|
|
157
|
+
end: bodyEnd,
|
|
158
|
+
source: rangeAt(uri, starts, bodyStart, bodyEnd),
|
|
159
|
+
fenceSource: rangeAt(uri, starts, start, end)
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const directiveTokenize = (text, sourceAt, diagnostics) => {
|
|
164
|
+
const result = [];
|
|
165
|
+
let index = 0;
|
|
166
|
+
const add = (type, value, start, end) => result.push({ type, value, start, end, source: sourceAt(start, end) });
|
|
167
|
+
const readString = (start, reference = false) => {
|
|
168
|
+
const quote = text[index];
|
|
169
|
+
index += 1;
|
|
170
|
+
let escaped = false;
|
|
171
|
+
while (index < text.length) {
|
|
172
|
+
const character = text[index];
|
|
173
|
+
if (escaped) escaped = false;
|
|
174
|
+
else if (character === "\\") escaped = true;
|
|
175
|
+
else if (character === quote) break;
|
|
176
|
+
index += 1;
|
|
177
|
+
}
|
|
178
|
+
if (index >= text.length) {
|
|
179
|
+
diagnostics.push(diagnostic("RM104", "Unterminated directive string.", sourceAt(start, text.length)));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const raw = text.slice(start + (reference ? 1 : 0), index + 1);
|
|
183
|
+
const value = stringValue(raw);
|
|
184
|
+
if (typeof value !== "string") {
|
|
185
|
+
diagnostics.push(diagnostic("RM104", "Malformed directive string.", sourceAt(start, index + 1)));
|
|
186
|
+
} else {
|
|
187
|
+
add(reference ? "reference" : "string", value, start, index + 1);
|
|
188
|
+
}
|
|
189
|
+
index += 1;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
while (index < text.length) {
|
|
193
|
+
const character = text[index];
|
|
194
|
+
if (/\s/.test(character)) {
|
|
195
|
+
index += 1;
|
|
196
|
+
} else if (character === "_" && (text[index + 1] === "\"" || text[index + 1] === "'")) {
|
|
197
|
+
const start = index;
|
|
198
|
+
index += 1;
|
|
199
|
+
readString(start, true);
|
|
200
|
+
} else if (character === "\"" || character === "'") {
|
|
201
|
+
const start = index;
|
|
202
|
+
readString(start);
|
|
203
|
+
} else if (/[a-z]/.test(character)) {
|
|
204
|
+
const start = index;
|
|
205
|
+
index += 1;
|
|
206
|
+
while (/[a-z0-9-]/.test(text[index] ?? "")) index += 1;
|
|
207
|
+
add("identifier", text.slice(start, index), start, index);
|
|
208
|
+
} else if (/[0-9]/.test(character)) {
|
|
209
|
+
const start = index;
|
|
210
|
+
index += 1;
|
|
211
|
+
while (/[0-9]/.test(text[index] ?? "")) index += 1;
|
|
212
|
+
add("number", Number(text.slice(start, index)), start, index);
|
|
213
|
+
} else if (character === "(" || character === ")" || character === "," || character === ";") {
|
|
214
|
+
add(character, character, index, index + 1);
|
|
215
|
+
index += 1;
|
|
216
|
+
} else {
|
|
217
|
+
diagnostics.push(diagnostic("RM104", "Unexpected directive character: " + character, sourceAt(index, index + 1)));
|
|
218
|
+
index += 1;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const parseDirectiveCommands = (text, sourceAt, diagnostics) => {
|
|
225
|
+
const tokens = directiveTokenize(text, sourceAt, diagnostics);
|
|
226
|
+
let index = 0;
|
|
227
|
+
const current = () => tokens[index];
|
|
228
|
+
const take = (type) => {
|
|
229
|
+
if (current()?.type === type) return tokens[index++];
|
|
230
|
+
return null;
|
|
231
|
+
};
|
|
232
|
+
const error = (message, token = current()) => diagnostics.push(diagnostic("RM104", message, token?.source ?? sourceAt(text.length, text.length)));
|
|
233
|
+
|
|
234
|
+
const expression = () => {
|
|
235
|
+
const token = current();
|
|
236
|
+
if (!token) return null;
|
|
237
|
+
if (token.type === "string" || token.type === "reference" || token.type === "number") {
|
|
238
|
+
index += 1;
|
|
239
|
+
return { type: token.type, value: token.value, source: token.source };
|
|
240
|
+
}
|
|
241
|
+
if (token.type !== "identifier") {
|
|
242
|
+
error("Expected a directive value.");
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
index += 1;
|
|
246
|
+
const name = token.value;
|
|
247
|
+
if (!take("(")) {
|
|
248
|
+
error("Directive names must be followed by (...).", current() ?? token);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const argumentsValue = [];
|
|
252
|
+
if (!take(")")) {
|
|
253
|
+
while (true) {
|
|
254
|
+
const value = expression();
|
|
255
|
+
if (!value) return null;
|
|
256
|
+
argumentsValue.push(value);
|
|
257
|
+
if (take(")")) break;
|
|
258
|
+
if (!take(",")) {
|
|
259
|
+
error("Expected , or ) in directive call.");
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const end = tokens[index - 1]?.end ?? token.end;
|
|
265
|
+
return { type: "call", name, arguments: argumentsValue, source: sourceAt(token.start, end) };
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const commands = [];
|
|
269
|
+
while (index < tokens.length) {
|
|
270
|
+
const command = expression();
|
|
271
|
+
if (!command) {
|
|
272
|
+
index += 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (command.type !== "call") error("Top-level directive entries must be calls.", command);
|
|
276
|
+
else commands.push(command);
|
|
277
|
+
take(";");
|
|
278
|
+
}
|
|
279
|
+
return commands;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const callPipeline = (call, diagnostics) => {
|
|
283
|
+
if (call.type !== "call") {
|
|
284
|
+
diagnostics.push(diagnostic("RM104", "pipe and pass accept command calls only.", call.source));
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
if (call.name === "emit") {
|
|
288
|
+
if (call.arguments.length !== 1 || call.arguments[0].type !== "string") {
|
|
289
|
+
diagnostics.push(diagnostic("RM104", "emit requires one string suffix.", call.source));
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
return { type: "emit", suffix: call.arguments[0].value, metadata: {}, source: call.source };
|
|
293
|
+
}
|
|
294
|
+
if (call.arguments.some((argument) => argument.type !== "string" && argument.type !== "number")) {
|
|
295
|
+
diagnostics.push(diagnostic("RM104", "Transform arguments must be strings or numbers.", call.source));
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return { type: "transform", name: call.name, arguments: call.arguments.map((argument) => argument.value), source: call.source };
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const composeStep = (value, diagnostics) => {
|
|
302
|
+
if (value.type === "reference") return { kind: "append", reference: value.value, source: value.source };
|
|
303
|
+
if (value.type !== "call") {
|
|
304
|
+
diagnostics.push(diagnostic("RM104", "compose accepts references or directive calls.", value.source));
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
if (value.name === "newline") {
|
|
308
|
+
if (value.arguments.length !== 1 || value.arguments[0].type !== "number" || value.arguments[0].value < 0) {
|
|
309
|
+
diagnostics.push(diagnostic("RM104", "newline requires one non-negative integer.", value.source));
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
return { kind: "newline", count: value.arguments[0].value, source: value.source };
|
|
313
|
+
}
|
|
314
|
+
if (value.name === "append") {
|
|
315
|
+
if (value.arguments.length !== 1 || value.arguments[0].type !== "reference") {
|
|
316
|
+
diagnostics.push(diagnostic("RM104", "append requires one quoted reference.", value.source));
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
return { kind: "append", reference: value.arguments[0].value, source: value.source };
|
|
320
|
+
}
|
|
321
|
+
if (value.name === "pipe" || value.name === "pass") {
|
|
322
|
+
const steps = value.arguments.map((argument) => callPipeline(argument, diagnostics));
|
|
323
|
+
return steps.some((step) => !step) ? null : { kind: value.name, steps, source: value.source };
|
|
324
|
+
}
|
|
325
|
+
diagnostics.push(diagnostic("RM104", "Unknown compose step: " + value.name, value.source));
|
|
326
|
+
return null;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const directiveFromCommand = (command, document, diagnostics) => {
|
|
330
|
+
const args = command.arguments;
|
|
331
|
+
if (command.name === "create") {
|
|
332
|
+
if (args.length !== 2 || args[0].type !== "string" || args[1].type !== "call" || args[1].name !== "compose") {
|
|
333
|
+
diagnostics.push(diagnostic("RM104", "create requires a local name and compose(...).", command.source));
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const steps = args[1].arguments.map((value) => composeStep(value, diagnostics));
|
|
337
|
+
return steps.some((step) => !step) ? null : { kind: "create", document, name: args[0].value, compose: steps, source: command.source };
|
|
338
|
+
}
|
|
339
|
+
if (command.name === "alias") {
|
|
340
|
+
if (args.length !== 2 || args[0].type !== "string" || args[1].type !== "reference") {
|
|
341
|
+
diagnostics.push(diagnostic("RM104", "alias requires a local name and quoted reference.", command.source));
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
return { kind: "alias", document, name: args[0].value, reference: args[1].value, source: command.source };
|
|
345
|
+
}
|
|
346
|
+
if (command.name === "in") {
|
|
347
|
+
if (args.length !== 1 || args[0].type !== "string") {
|
|
348
|
+
diagnostics.push(diagnostic("RM104", "in requires one file path string.", command.source));
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return { kind: "in", target: args[0].value, source: command.source };
|
|
352
|
+
}
|
|
353
|
+
if (command.name === "out") {
|
|
354
|
+
if (args.length !== 2 || args[0].type !== "string" || args[1].type !== "reference") {
|
|
355
|
+
diagnostics.push(diagnostic("RM104", "out requires a file name and quoted reference.", command.source));
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
const from = args[1].value.includes("::") ? args[1].value : document + "::" + args[1].value;
|
|
359
|
+
return { kind: "out", name: args[0].value, from, source: command.source };
|
|
360
|
+
}
|
|
361
|
+
diagnostics.push(diagnostic("RM104", "Unknown Ravel directive: " + command.name, command.source));
|
|
362
|
+
return null;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const parseDirectiveFence = (block, document, starts, uri, diagnostics) => {
|
|
366
|
+
const sourceAt = (start, end) => rangeAt(uri, starts, block.start + start, block.start + end);
|
|
367
|
+
const commands = parseDirectiveCommands(block.body, sourceAt, diagnostics);
|
|
368
|
+
return commands.map((command) => directiveFromCommand(command, document, diagnostics)).filter(Boolean);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const identityFor = (document, attributes, language, source, diagnostics) => {
|
|
372
|
+
const shorthand = attributes.id;
|
|
373
|
+
const split = shorthand?.indexOf("--") ?? -1;
|
|
374
|
+
const shortChunk = split === -1 ? shorthand : shorthand.slice(0, split);
|
|
375
|
+
const shortMinor = split === -1 ? null : shorthand.slice(split + 2);
|
|
376
|
+
const chunk = attributes.values.chunk ?? shortChunk;
|
|
377
|
+
const minor = attributes.values.minor ?? shortMinor;
|
|
378
|
+
const type = attributes.values.type ?? language ?? null;
|
|
379
|
+
|
|
380
|
+
if (!componentPattern.test(chunk ?? "") ||
|
|
381
|
+
(minor !== null && !componentPattern.test(minor)) ||
|
|
382
|
+
(type !== null && !componentPattern.test(type))) {
|
|
383
|
+
diagnostics.push(diagnostic("RM102", "Chunk, minor, and type names must be lowercase Ravel identifiers.", source));
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
return { document, chunk, minor, type };
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const formatId = (identity) => identity.document + "::" + identity.chunk +
|
|
390
|
+
(identity.minor === null ? "" : ":" + identity.minor) +
|
|
391
|
+
(identity.type === null ? "" : "." + identity.type);
|
|
392
|
+
|
|
393
|
+
const splitDefinitionPipeline = (text, separator = "|") => {
|
|
394
|
+
const parts = [];
|
|
395
|
+
let start = 0;
|
|
396
|
+
let quote = "";
|
|
397
|
+
let escaped = false;
|
|
398
|
+
let depth = 0;
|
|
399
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
400
|
+
const character = text[index];
|
|
401
|
+
if (quote) {
|
|
402
|
+
if (escaped) escaped = false;
|
|
403
|
+
else if (character === "\\") escaped = true;
|
|
404
|
+
else if (character === quote) quote = "";
|
|
405
|
+
} else if (character === "\"" || character === "'") {
|
|
406
|
+
quote = character;
|
|
407
|
+
} else if (character === "(" || character === "[" || character === "{") {
|
|
408
|
+
depth += 1;
|
|
409
|
+
} else if (character === ")" || character === "]" || character === "}") {
|
|
410
|
+
depth -= 1;
|
|
411
|
+
} else if (character === separator && depth === 0) {
|
|
412
|
+
parts.push(text.slice(start, index).trim());
|
|
413
|
+
start = index + 1;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
parts.push(text.slice(start).trim());
|
|
417
|
+
return parts;
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const definitionValue = (text) => {
|
|
421
|
+
const value = text.trim();
|
|
422
|
+
const string = stringValue(value);
|
|
423
|
+
if (value.startsWith("\"") || value.startsWith("'")) return typeof string === "string" ? string : undefined;
|
|
424
|
+
if (value === "true") return true;
|
|
425
|
+
if (value === "false") return false;
|
|
426
|
+
if (value === "null") return null;
|
|
427
|
+
if (/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(value)) return Number(value);
|
|
428
|
+
if (value.startsWith("{") || value.startsWith("[")) {
|
|
429
|
+
try {
|
|
430
|
+
return JSON.parse(value);
|
|
431
|
+
} catch {
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return undefined;
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const definitionPipeline = (text, source, diagnostics) => {
|
|
439
|
+
if (!text?.trim()) return [];
|
|
440
|
+
const steps = [];
|
|
441
|
+
for (const part of splitDefinitionPipeline(text)) {
|
|
442
|
+
const match = /^([a-z][a-z0-9-]*)\s*(?:\((.*)\))?$/s.exec(part);
|
|
443
|
+
if (!match) {
|
|
444
|
+
diagnostics.push(diagnostic("RM101", "Definition pipes accept transform calls only: " + part, source));
|
|
445
|
+
return [];
|
|
446
|
+
}
|
|
447
|
+
// Definition-time emit remains a graph-expansion feature for a later
|
|
448
|
+
// phase. Keep the authored pipe metadata, but do not execute it here.
|
|
449
|
+
if (match[1] === "emit") continue;
|
|
450
|
+
const argumentsValue = match[2]?.trim() ? splitDefinitionPipeline(match[2], ",") : [];
|
|
451
|
+
const argumentsParsed = argumentsValue.map(definitionValue);
|
|
452
|
+
if (argumentsParsed.some((argument) => typeof argument === "undefined")) {
|
|
453
|
+
diagnostics.push(diagnostic("RM101", "Definition transform arguments must be JSON-like literals.", source));
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
steps.push({ name: match[1], arguments: argumentsParsed });
|
|
457
|
+
}
|
|
458
|
+
return steps;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const newChunk = (identity, body, attributes, language, diagnostics) => {
|
|
462
|
+
const tags = attributes.classes.filter((entry) => !controlClasses.has(entry));
|
|
463
|
+
const metadata = {
|
|
464
|
+
language: language ?? undefined,
|
|
465
|
+
tags,
|
|
466
|
+
data: {}
|
|
467
|
+
};
|
|
468
|
+
if (attributes.values.pipe) metadata.data.ravel = { definitionPipe: attributes.values.pipe };
|
|
469
|
+
return {
|
|
470
|
+
id: formatId(identity),
|
|
471
|
+
identity,
|
|
472
|
+
name: identity.chunk,
|
|
473
|
+
body: body.body,
|
|
474
|
+
definitionPipeline: definitionPipeline(attributes.values.pipe, body.fenceSource ?? body.source, diagnostics),
|
|
475
|
+
metadata,
|
|
476
|
+
source: body.source,
|
|
477
|
+
fragments: undefined,
|
|
478
|
+
_fragments: [{ body: body.body, source: body.source }]
|
|
479
|
+
};
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const appendToChunk = (chunk, body) => {
|
|
483
|
+
chunk.body += body.body;
|
|
484
|
+
chunk._fragments.push({ body: body.body, source: body.source });
|
|
485
|
+
chunk.fragments = chunk._fragments.map(({ body: value, source }) => ({ body: value, source }));
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const cleanChunk = ({ _fragments, fragments, ...chunk }) => ({
|
|
489
|
+
...chunk,
|
|
490
|
+
...(fragments ? { fragments } : {})
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Convert the Ravel fenced-code Markdown profile into a format-neutral map.
|
|
495
|
+
* `mode` is `opt-in` or `primary`; in primary mode every non-excluded code
|
|
496
|
+
* fence must be a named Ravel chunk or a valid greedy continuation.
|
|
497
|
+
*/
|
|
498
|
+
export const markdownToMap = (text, options = {}) => {
|
|
499
|
+
const uri = options.uri ?? "document.md";
|
|
500
|
+
const mode = options.mode ?? "opt-in";
|
|
501
|
+
const starts = lineStarts(text);
|
|
502
|
+
const diagnostics = [];
|
|
503
|
+
const frontMatterDocument = documentFromFrontMatter(text);
|
|
504
|
+
const documentId = options.document ?? frontMatterDocument ?? defaultDocumentId(uri);
|
|
505
|
+
|
|
506
|
+
if (!componentPattern.test(documentId ?? "")) {
|
|
507
|
+
throw new Error("Ravel Markdown document identity must be a lowercase identifier: " + String(documentId));
|
|
508
|
+
}
|
|
509
|
+
if (mode !== "opt-in" && mode !== "primary") {
|
|
510
|
+
throw new Error("Ravel Markdown mode must be opt-in or primary: " + mode);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const chunks = [];
|
|
514
|
+
const directives = [];
|
|
515
|
+
let activeGreedy = null;
|
|
516
|
+
const tree = fromMarkdown(text);
|
|
517
|
+
for (const node of codeNodes(tree)) {
|
|
518
|
+
const block = fenceBody(text, node, starts, uri);
|
|
519
|
+
if (node.lang === "ravel") {
|
|
520
|
+
activeGreedy = null;
|
|
521
|
+
directives.push(...parseDirectiveFence(block, documentId, starts, uri, diagnostics));
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const attributes = parseAttributes(node.meta, block.fenceSource);
|
|
525
|
+
diagnostics.push(...attributes.diagnostics);
|
|
526
|
+
const classes = new Set(attributes.classes);
|
|
527
|
+
const excluded = classes.has("no-ravel");
|
|
528
|
+
const explicit = attributes.id !== null || classes.has("ravel") || Object.hasOwn(attributes.values, "chunk");
|
|
529
|
+
const continuation = !excluded && attributes.id === null && !classes.has("ravel") &&
|
|
530
|
+
!classes.has("greedy") && !classes.has("end") && Object.keys(attributes.values).length === 0 &&
|
|
531
|
+
!node.meta?.trim();
|
|
532
|
+
const ending = !excluded && classes.has("end") && attributes.id === null &&
|
|
533
|
+
!classes.has("ravel") && !classes.has("greedy") && Object.keys(attributes.values).length === 0;
|
|
534
|
+
|
|
535
|
+
if (activeGreedy && continuation && node.lang === activeGreedy.language) {
|
|
536
|
+
appendToChunk(activeGreedy.chunk, block);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (activeGreedy && ending && node.lang === activeGreedy.language) {
|
|
540
|
+
appendToChunk(activeGreedy.chunk, block);
|
|
541
|
+
activeGreedy = null;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (activeGreedy) activeGreedy = null;
|
|
545
|
+
|
|
546
|
+
if (excluded) continue;
|
|
547
|
+
if (!explicit) {
|
|
548
|
+
if (mode === "primary") {
|
|
549
|
+
diagnostics.push(diagnostic("RM103", "Primary Ravel mode requires #chunk, a greedy continuation, or .no-ravel.", block.fenceSource));
|
|
550
|
+
}
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (classes.has("end")) {
|
|
554
|
+
diagnostics.push(diagnostic("RM103", ".end may only close an active matching .greedy chunk.", block.fenceSource));
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (attributes.id === null && !Object.hasOwn(attributes.values, "chunk")) {
|
|
558
|
+
diagnostics.push(diagnostic("RM103", "A .ravel fence requires #chunk or chunk=name.", block.fenceSource));
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (classes.has("greedy") && !classes.has("ravel")) {
|
|
562
|
+
diagnostics.push(diagnostic("RM103", ".greedy requires the explicit .ravel class.", block.fenceSource));
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const identity = identityFor(documentId, attributes, node.lang, block.fenceSource, diagnostics);
|
|
567
|
+
if (!identity) continue;
|
|
568
|
+
const chunk = newChunk(identity, block, attributes, node.lang, diagnostics);
|
|
569
|
+
chunks.push(chunk);
|
|
570
|
+
if (classes.has("greedy")) activeGreedy = { chunk, language: node.lang };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
map: {
|
|
575
|
+
version: 1,
|
|
576
|
+
document: { id: documentId, uri, format: "markdown+ravel-fences-v1" },
|
|
577
|
+
chunks: chunks.map(cleanChunk),
|
|
578
|
+
directives
|
|
579
|
+
},
|
|
580
|
+
diagnostics
|
|
581
|
+
};
|
|
582
|
+
};
|