@jigs.design/compiler 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +28 -2
- package/dist/ast.d.ts +78 -0
- package/dist/block-hoist.js +237 -0
- package/dist/check-code.d.ts +46 -0
- package/dist/check-code.js +700 -0
- package/dist/codegen.d.ts +8 -0
- package/dist/codegen.js +219 -0
- package/dist/compile.d.ts +177 -0
- package/dist/compile.js +950 -0
- package/dist/hoist.d.ts +20 -0
- package/dist/hoist.js +76 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +9 -0
- package/dist/offsets.d.ts +18 -0
- package/dist/offsets.js +40 -0
- package/dist/parse.d.ts +16 -0
- package/dist/parse.js +690 -0
- package/dist/patch.d.ts +55 -0
- package/dist/patch.js +140 -0
- package/dist/script.d.ts +14 -0
- package/dist/script.js +695 -0
- package/dist/signature.d.ts +5 -0
- package/dist/signature.js +25 -0
- package/dist/spec.d.ts +29 -0
- package/dist/spec.js +14 -0
- package/dist/suggest.d.ts +8 -0
- package/dist/suggest.js +53 -0
- package/package.json +27 -3
package/dist/parse.js
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
import { extendMap, sliceMap, sourceOffset, sourceRange } from "./offsets.js";
|
|
2
|
+
//#region src/parse.ts
|
|
3
|
+
const TAG_NAME = /[A-Za-z][\w-]*/y;
|
|
4
|
+
const ATTR_NAME = /[A-Za-z_][\w-]*/y;
|
|
5
|
+
const QUOTES = /* @__PURE__ */ new Set([
|
|
6
|
+
"\"",
|
|
7
|
+
"'",
|
|
8
|
+
"`"
|
|
9
|
+
]);
|
|
10
|
+
const isSpace = (char) => /\s/.test(char);
|
|
11
|
+
function matchAt(pattern, source, index) {
|
|
12
|
+
pattern.lastIndex = index;
|
|
13
|
+
return pattern.exec(source)?.[0] ?? null;
|
|
14
|
+
}
|
|
15
|
+
function skipSpace(source, index) {
|
|
16
|
+
let cursor = index;
|
|
17
|
+
while (cursor < source.length && isSpace(source.charAt(cursor))) cursor++;
|
|
18
|
+
return cursor;
|
|
19
|
+
}
|
|
20
|
+
/** Code in braces that is only comments, such as `{/* note *\/}`. */
|
|
21
|
+
const ONLY_COMMENTS = /^(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*$/;
|
|
22
|
+
/**
|
|
23
|
+
* Index of the `}` in a line comment that closes the outermost brace, as in
|
|
24
|
+
* `{@body const total = 3 // it's the total}`, or -1. Braces the comment opens
|
|
25
|
+
* itself, as in `// sort by {value}`, are its own text.
|
|
26
|
+
*/
|
|
27
|
+
function commentCloser(source, start, end) {
|
|
28
|
+
let depth = 0;
|
|
29
|
+
for (let index = start; index < end; index++) {
|
|
30
|
+
const char = source.charAt(index);
|
|
31
|
+
if (char === "{") depth++;
|
|
32
|
+
else if (char === "}") {
|
|
33
|
+
depth--;
|
|
34
|
+
if (depth < 0) return index;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return -1;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Index just past the `}` matching the `{` at `start`, or -1 if unbalanced.
|
|
41
|
+
* Quotes inside comments don't open strings. A line comment ends at its
|
|
42
|
+
* newline, or at an unmatched `}` that closes the outermost brace (see
|
|
43
|
+
* `commentCloser`).
|
|
44
|
+
*/
|
|
45
|
+
function scanBalanced(source, start) {
|
|
46
|
+
let depth = 0;
|
|
47
|
+
let quote = null;
|
|
48
|
+
for (let index = start; index < source.length; index++) {
|
|
49
|
+
const char = source.charAt(index);
|
|
50
|
+
if (quote !== null) {
|
|
51
|
+
if (char === "\\") index++;
|
|
52
|
+
else if (char === quote) quote = null;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (char === "/" && source.charAt(index + 1) === "/") {
|
|
56
|
+
const newline = source.indexOf("\n", index);
|
|
57
|
+
const lineEnd = newline === -1 ? source.length : newline;
|
|
58
|
+
const closer = depth === 1 ? commentCloser(source, index + 2, lineEnd) : -1;
|
|
59
|
+
if (closer !== -1) return closer + 1;
|
|
60
|
+
index = lineEnd - 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (char === "/" && source.charAt(index + 1) === "*") {
|
|
64
|
+
const end = source.indexOf("*/", index + 2);
|
|
65
|
+
if (end === -1) return -1;
|
|
66
|
+
index = end + 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (QUOTES.has(char)) quote = char;
|
|
70
|
+
else if (char === "{") depth++;
|
|
71
|
+
else if (char === "}") {
|
|
72
|
+
depth--;
|
|
73
|
+
if (depth === 0) return index + 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return -1;
|
|
77
|
+
}
|
|
78
|
+
/** The code between `from` and `to`, trimmed, as a prop value. */
|
|
79
|
+
function readBraced(source, from, to, map) {
|
|
80
|
+
const code = source.slice(from, to);
|
|
81
|
+
const trimmed = code.trim();
|
|
82
|
+
if (trimmed === "") return null;
|
|
83
|
+
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return {
|
|
84
|
+
kind: "literal",
|
|
85
|
+
value: Number(trimmed)
|
|
86
|
+
};
|
|
87
|
+
if (trimmed === "true" || trimmed === "false") return {
|
|
88
|
+
kind: "literal",
|
|
89
|
+
value: trimmed === "true"
|
|
90
|
+
};
|
|
91
|
+
const quoted = /^"([^"\\]*)"$|^'([^'\\]*)'$/.exec(trimmed);
|
|
92
|
+
if (quoted) return {
|
|
93
|
+
kind: "literal",
|
|
94
|
+
value: quoted[1] ?? quoted[2] ?? ""
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
at: sliceMap(map, from + code.length - code.trimStart().length),
|
|
98
|
+
code: trimmed,
|
|
99
|
+
kind: "expr"
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** `map` gives the source offsets of `source`, for the tag's props. */
|
|
103
|
+
function scanOpenTag(source, start, map) {
|
|
104
|
+
const name = matchAt(TAG_NAME, source, start + 1);
|
|
105
|
+
if (name === null) return "invalid";
|
|
106
|
+
const props = [];
|
|
107
|
+
let cursor = start + 1 + name.length;
|
|
108
|
+
for (;;) {
|
|
109
|
+
cursor = skipSpace(source, cursor);
|
|
110
|
+
if (cursor >= source.length) return "incomplete";
|
|
111
|
+
if (source.startsWith("/>", cursor) || source.charAt(cursor) === ">") {
|
|
112
|
+
const selfClosing = source.charAt(cursor) === "/";
|
|
113
|
+
const end = cursor + (selfClosing ? 2 : 1);
|
|
114
|
+
const range = sourceRange(map, start, end);
|
|
115
|
+
return {
|
|
116
|
+
end,
|
|
117
|
+
token: {
|
|
118
|
+
end: range.end,
|
|
119
|
+
name,
|
|
120
|
+
props,
|
|
121
|
+
selfClosing,
|
|
122
|
+
start: range.start,
|
|
123
|
+
type: "open"
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (source.charAt(cursor) === "/" && cursor + 1 >= source.length) return "incomplete";
|
|
128
|
+
const attr = matchAt(ATTR_NAME, source, cursor);
|
|
129
|
+
if (attr === null) return "invalid";
|
|
130
|
+
const attrStart = cursor;
|
|
131
|
+
cursor = skipSpace(source, cursor + attr.length);
|
|
132
|
+
if (source.charAt(cursor) !== "=") {
|
|
133
|
+
const range = sourceRange(map, attrStart, attrStart + attr.length);
|
|
134
|
+
props.push({
|
|
135
|
+
end: range.end,
|
|
136
|
+
name: attr,
|
|
137
|
+
shorthand: true,
|
|
138
|
+
start: range.start,
|
|
139
|
+
value: {
|
|
140
|
+
kind: "literal",
|
|
141
|
+
value: true
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
cursor = skipSpace(source, cursor + 1);
|
|
147
|
+
const opener = source.charAt(cursor);
|
|
148
|
+
if (opener === "") return "incomplete";
|
|
149
|
+
if (opener === "\"" || opener === "'") {
|
|
150
|
+
const close = source.indexOf(opener, cursor + 1);
|
|
151
|
+
if (close === -1) return "incomplete";
|
|
152
|
+
const range = sourceRange(map, attrStart, close + 1);
|
|
153
|
+
props.push({
|
|
154
|
+
end: range.end,
|
|
155
|
+
name: attr,
|
|
156
|
+
start: range.start,
|
|
157
|
+
value: {
|
|
158
|
+
kind: "literal",
|
|
159
|
+
value: source.slice(cursor + 1, close)
|
|
160
|
+
},
|
|
161
|
+
written: source.slice(attrStart, close + 1)
|
|
162
|
+
});
|
|
163
|
+
cursor = close + 1;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (opener === "{") {
|
|
167
|
+
const end = scanBalanced(source, cursor);
|
|
168
|
+
if (end === -1) return "braces";
|
|
169
|
+
const value = readBraced(source, cursor + 1, end - 1, map);
|
|
170
|
+
if (value !== null) {
|
|
171
|
+
const range = sourceRange(map, attrStart, end);
|
|
172
|
+
props.push({
|
|
173
|
+
end: range.end,
|
|
174
|
+
name: attr,
|
|
175
|
+
start: range.start,
|
|
176
|
+
value,
|
|
177
|
+
written: source.slice(attrStart, end)
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
cursor = end;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
return "invalid";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function scanCloseTag(source, start, map) {
|
|
187
|
+
const name = matchAt(TAG_NAME, source, start + 2);
|
|
188
|
+
if (name === null) return start + 2 >= source.length ? "incomplete" : "invalid";
|
|
189
|
+
const cursor = skipSpace(source, start + 2 + name.length);
|
|
190
|
+
if (cursor >= source.length) return "incomplete";
|
|
191
|
+
if (source.charAt(cursor) !== ">") return "invalid";
|
|
192
|
+
const range = sourceRange(map, start, cursor + 1);
|
|
193
|
+
return {
|
|
194
|
+
end: cursor + 1,
|
|
195
|
+
token: {
|
|
196
|
+
end: range.end,
|
|
197
|
+
name,
|
|
198
|
+
start: range.start,
|
|
199
|
+
type: "close"
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Splits a run of Pickle into tags, `{expressions}` and text. While more input
|
|
205
|
+
* may follow, an unclosed tag or `{` ends the run as `incomplete`. Once
|
|
206
|
+
* `final`, nothing more can arrive: an unclosed `{` is kept as text and an
|
|
207
|
+
* unclosed tag is dropped, each with a problem. `map` gives the source
|
|
208
|
+
* offsets of `source`.
|
|
209
|
+
*/
|
|
210
|
+
function tokenizeInline(source, final, map) {
|
|
211
|
+
const tokens = [];
|
|
212
|
+
const problems = [];
|
|
213
|
+
let cursor = 0;
|
|
214
|
+
let text = "";
|
|
215
|
+
const flush = () => {
|
|
216
|
+
if (text !== "") {
|
|
217
|
+
tokens.push({
|
|
218
|
+
index: cursor - text.length,
|
|
219
|
+
type: "text",
|
|
220
|
+
value: text
|
|
221
|
+
});
|
|
222
|
+
text = "";
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
while (cursor < source.length) {
|
|
226
|
+
const char = source.charAt(cursor);
|
|
227
|
+
if (char === "<") {
|
|
228
|
+
const next = source.charAt(cursor + 1);
|
|
229
|
+
if (next === "" && !final) {
|
|
230
|
+
flush();
|
|
231
|
+
return {
|
|
232
|
+
inBraces: false,
|
|
233
|
+
incomplete: "tag",
|
|
234
|
+
problems,
|
|
235
|
+
tokens
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (next === "/" || /[A-Za-z]/.test(next)) {
|
|
239
|
+
const scan = next === "/" ? scanCloseTag(source, cursor, map) : scanOpenTag(source, cursor, map);
|
|
240
|
+
if (scan === "incomplete" || scan === "braces") {
|
|
241
|
+
flush();
|
|
242
|
+
if (!final) return {
|
|
243
|
+
inBraces: scan === "braces",
|
|
244
|
+
incomplete: "tag",
|
|
245
|
+
problems,
|
|
246
|
+
tokens
|
|
247
|
+
};
|
|
248
|
+
const slash = next === "/" ? "/" : "";
|
|
249
|
+
const name = matchAt(TAG_NAME, source, cursor + 1 + slash.length);
|
|
250
|
+
problems.push(`<${slash}${name ?? ""}> is unfinished: a closing quote, \`}\` or \`>\` is missing, so it and the rest of the line were removed`);
|
|
251
|
+
return {
|
|
252
|
+
inBraces: false,
|
|
253
|
+
incomplete: null,
|
|
254
|
+
problems,
|
|
255
|
+
tokens
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (scan !== "invalid") {
|
|
259
|
+
flush();
|
|
260
|
+
tokens.push(scan.token);
|
|
261
|
+
cursor = scan.end;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} else if (char === "{") {
|
|
266
|
+
const end = scanBalanced(source, cursor);
|
|
267
|
+
if (end === -1 && !final) {
|
|
268
|
+
flush();
|
|
269
|
+
return {
|
|
270
|
+
inBraces: false,
|
|
271
|
+
incomplete: "expression",
|
|
272
|
+
problems,
|
|
273
|
+
tokens
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (end === -1) {
|
|
277
|
+
problems.push("`{` has no matching `}`, so it was shown as text. Close the expression, or write {\"{\"} for a brace in text");
|
|
278
|
+
text += char;
|
|
279
|
+
cursor++;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
flush();
|
|
283
|
+
const inner = source.slice(cursor + 1, end - 1);
|
|
284
|
+
const code = inner.trim();
|
|
285
|
+
if (!ONLY_COMMENTS.test(code)) {
|
|
286
|
+
const from = cursor + 1 + inner.length - inner.trimStart().length;
|
|
287
|
+
tokens.push({
|
|
288
|
+
at: sliceMap(map, from),
|
|
289
|
+
code,
|
|
290
|
+
type: "expr"
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
cursor = end;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
text += char;
|
|
297
|
+
cursor++;
|
|
298
|
+
}
|
|
299
|
+
flush();
|
|
300
|
+
return {
|
|
301
|
+
inBraces: false,
|
|
302
|
+
incomplete: null,
|
|
303
|
+
problems,
|
|
304
|
+
tokens
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function element(name, props, line, range, children = []) {
|
|
308
|
+
return {
|
|
309
|
+
children,
|
|
310
|
+
end: range.end,
|
|
311
|
+
line,
|
|
312
|
+
name,
|
|
313
|
+
props,
|
|
314
|
+
start: range.start,
|
|
315
|
+
type: "element"
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function createTreeBuilder(repairs, errors) {
|
|
319
|
+
const root = element("#root", [], 0, {
|
|
320
|
+
end: 0,
|
|
321
|
+
start: 0
|
|
322
|
+
});
|
|
323
|
+
const stack = [];
|
|
324
|
+
const append = (node) => {
|
|
325
|
+
(stack.at(-1)?.node ?? root).children.push(node);
|
|
326
|
+
};
|
|
327
|
+
const open = (node, kind) => {
|
|
328
|
+
append(node);
|
|
329
|
+
const frame = {
|
|
330
|
+
kind,
|
|
331
|
+
node
|
|
332
|
+
};
|
|
333
|
+
stack.push(frame);
|
|
334
|
+
return frame;
|
|
335
|
+
};
|
|
336
|
+
const isOpen = (frame) => stack.includes(frame);
|
|
337
|
+
const insideTag = () => stack.some((frame) => frame.kind === "tag");
|
|
338
|
+
/** Closes `frame` and anything opened after it. */
|
|
339
|
+
const close = (frame) => {
|
|
340
|
+
const index = stack.indexOf(frame);
|
|
341
|
+
if (index === -1) return;
|
|
342
|
+
for (const above of stack.slice(index + 1)) if (above.kind === "tag") repairs.push({
|
|
343
|
+
end: above.node.end,
|
|
344
|
+
line: above.node.line,
|
|
345
|
+
message: `Closed unclosed <${above.node.name}>`,
|
|
346
|
+
start: above.node.start
|
|
347
|
+
});
|
|
348
|
+
stack.length = index;
|
|
349
|
+
};
|
|
350
|
+
const closeEmphasis = () => {
|
|
351
|
+
while (stack.at(-1)?.kind === "emphasis") stack.pop();
|
|
352
|
+
};
|
|
353
|
+
/** `index` is where `value` starts in the run `map` describes. */
|
|
354
|
+
const feedText = (value, line, map, index) => {
|
|
355
|
+
let buffer = "";
|
|
356
|
+
const flush = () => {
|
|
357
|
+
if (buffer !== "") {
|
|
358
|
+
append({
|
|
359
|
+
type: "text",
|
|
360
|
+
value: buffer
|
|
361
|
+
});
|
|
362
|
+
buffer = "";
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
let cursor = 0;
|
|
366
|
+
while (cursor < value.length) {
|
|
367
|
+
const char = value.charAt(cursor);
|
|
368
|
+
if (char === "`") {
|
|
369
|
+
const end = value.indexOf("`", cursor + 1);
|
|
370
|
+
if (end > cursor + 1) {
|
|
371
|
+
flush();
|
|
372
|
+
append(element("code", [], line, sourceRange(map, index + cursor, index + end + 1), [{
|
|
373
|
+
type: "text",
|
|
374
|
+
value: value.slice(cursor + 1, end)
|
|
375
|
+
}]));
|
|
376
|
+
cursor = end + 1;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (char === "*") {
|
|
381
|
+
const name = value.startsWith("**", cursor) ? "bold" : "italic";
|
|
382
|
+
const width = name === "bold" ? 2 : 1;
|
|
383
|
+
const current = stack.findLast((frame) => frame.kind === "emphasis" && frame.node.name === name);
|
|
384
|
+
if (current && !isSpace(value.charAt(cursor - 1))) {
|
|
385
|
+
flush();
|
|
386
|
+
close(current);
|
|
387
|
+
cursor += width;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (!current && !isSpace(value.charAt(cursor + width))) {
|
|
391
|
+
flush();
|
|
392
|
+
open(element(name, [], line, sourceRange(map, index + cursor, index + cursor + width)), "emphasis");
|
|
393
|
+
cursor += width;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
buffer += char;
|
|
398
|
+
cursor++;
|
|
399
|
+
}
|
|
400
|
+
flush();
|
|
401
|
+
};
|
|
402
|
+
const feed = (tokens, line, dropBlankText, map) => {
|
|
403
|
+
const isExprAt = (index) => tokens[index]?.type === "expr";
|
|
404
|
+
for (const [index, token] of tokens.entries()) switch (token.type) {
|
|
405
|
+
case "open": {
|
|
406
|
+
const node = element(token.name, token.props, line, token);
|
|
407
|
+
if (token.selfClosing) {
|
|
408
|
+
node.closeEnd = token.end;
|
|
409
|
+
append(node);
|
|
410
|
+
} else open(node, "tag");
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
case "close": {
|
|
414
|
+
const frame = stack.findLast((candidate) => candidate.kind === "tag" && candidate.node.name === token.name);
|
|
415
|
+
if (frame) {
|
|
416
|
+
frame.node.closeEnd = token.end;
|
|
417
|
+
close(frame);
|
|
418
|
+
} else repairs.push({
|
|
419
|
+
end: token.end,
|
|
420
|
+
line,
|
|
421
|
+
message: `Dropped stray </${token.name}>`,
|
|
422
|
+
start: token.start
|
|
423
|
+
});
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
case "expr":
|
|
427
|
+
append({
|
|
428
|
+
at: token.at,
|
|
429
|
+
code: token.code,
|
|
430
|
+
line,
|
|
431
|
+
type: "expr"
|
|
432
|
+
});
|
|
433
|
+
break;
|
|
434
|
+
case "text": if (!dropBlankText || token.value.trim() !== "" || isExprAt(index - 1) && isExprAt(index + 1)) feedText(token.value, line, map, token.index);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
/**
|
|
438
|
+
* A Markdown block (heading, paragraph, list item) whose content is inline
|
|
439
|
+
* Pickle. `map` gives the source offsets of `content`.
|
|
440
|
+
*/
|
|
441
|
+
const inlineBlock = (name, props, content, line, final, map) => {
|
|
442
|
+
const range = sourceRange(map, 0, content.length);
|
|
443
|
+
const frame = open(element(name, props, line, range), "block");
|
|
444
|
+
const inline = tokenizeInline(content, final, map);
|
|
445
|
+
for (const message of inline.problems) errors.push({
|
|
446
|
+
end: range.end,
|
|
447
|
+
line,
|
|
448
|
+
message,
|
|
449
|
+
start: range.start
|
|
450
|
+
});
|
|
451
|
+
if (inline.incomplete !== null) repairs.push({
|
|
452
|
+
line,
|
|
453
|
+
message: `Dropped incomplete ${inline.incomplete}`
|
|
454
|
+
});
|
|
455
|
+
feed(inline.tokens, line, false, map);
|
|
456
|
+
close(frame);
|
|
457
|
+
};
|
|
458
|
+
const finish = () => {
|
|
459
|
+
for (const frame of stack) if (frame.kind === "tag") repairs.push({
|
|
460
|
+
end: frame.node.end,
|
|
461
|
+
line: frame.node.line,
|
|
462
|
+
message: `Closed unclosed <${frame.node.name}>`,
|
|
463
|
+
start: frame.node.start
|
|
464
|
+
});
|
|
465
|
+
stack.length = 0;
|
|
466
|
+
return root.children;
|
|
467
|
+
};
|
|
468
|
+
return {
|
|
469
|
+
append,
|
|
470
|
+
close,
|
|
471
|
+
closeEmphasis,
|
|
472
|
+
feed,
|
|
473
|
+
finish,
|
|
474
|
+
inlineBlock,
|
|
475
|
+
insideTag,
|
|
476
|
+
isOpen,
|
|
477
|
+
open
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const HEADING = /^(#{1,6})\s+(.*)$/;
|
|
481
|
+
const LIST_ITEM = /^([-*+]|\d+[.)])\s+(.*)$/;
|
|
482
|
+
const DIVIDER = /^(?:-{3,}|\*{3,})$/;
|
|
483
|
+
const TAG_LINE = /^<\/?[A-Za-z]/;
|
|
484
|
+
const STATEMENT_PREFIX = "{@body";
|
|
485
|
+
const headingSizes = [
|
|
486
|
+
"xl",
|
|
487
|
+
"lg",
|
|
488
|
+
"md"
|
|
489
|
+
];
|
|
490
|
+
/**
|
|
491
|
+
* Parses Pickle: Markdown blocks, catalog tags and `{@body ...}` statements.
|
|
492
|
+
*
|
|
493
|
+
* Never throws. Partial input (a response that is still being written) is
|
|
494
|
+
* repaired: open elements are closed and a trailing incomplete tag, expression
|
|
495
|
+
* or statement is dropped. Each fix is recorded in `repairs`.
|
|
496
|
+
*
|
|
497
|
+
* An unclosed tag, `{` or statement stops at the next tag line or `{@body`
|
|
498
|
+
* line rather than swallowing the rest of the document; a tag also stops at a
|
|
499
|
+
* blank line, unless one of its attributes has a `{` still open. Since that text can't be
|
|
500
|
+
* finished by more streaming, it is reported in `errors`.
|
|
501
|
+
*/
|
|
502
|
+
function parsePickle(source) {
|
|
503
|
+
const lines = source.split(/\r?\n/);
|
|
504
|
+
const lineStarts = [0];
|
|
505
|
+
for (let newline = source.indexOf("\n"); newline !== -1; newline = source.indexOf("\n", newline + 1)) lineStarts.push(newline + 1);
|
|
506
|
+
/** Where line `at` (0-based) starts once trimmed. */
|
|
507
|
+
const trimmedStart = (at) => {
|
|
508
|
+
const raw = lines[at] ?? "";
|
|
509
|
+
return (lineStarts[at] ?? 0) + raw.length - raw.trimStart().length;
|
|
510
|
+
};
|
|
511
|
+
const repairs = [];
|
|
512
|
+
const errors = [];
|
|
513
|
+
const statements = [];
|
|
514
|
+
const builder = createTreeBuilder(repairs, errors);
|
|
515
|
+
let paragraph = null;
|
|
516
|
+
let list = null;
|
|
517
|
+
/** `final` unless the paragraph ends the input, where more may still stream in. */
|
|
518
|
+
const flushParagraph = (final = true) => {
|
|
519
|
+
if (paragraph !== null) {
|
|
520
|
+
builder.inlineBlock("text", [], paragraph.lines.join(" "), paragraph.line, final, paragraph.map);
|
|
521
|
+
paragraph = null;
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
const endList = () => {
|
|
525
|
+
if (list !== null) {
|
|
526
|
+
builder.close(list.frame);
|
|
527
|
+
list = null;
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
const endBlocks = (final = true) => {
|
|
531
|
+
flushParagraph(final);
|
|
532
|
+
endList();
|
|
533
|
+
};
|
|
534
|
+
/** Whether line `at` can continue an unclosed tag or statement: a new block can't. */
|
|
535
|
+
const continues = (at, blankLines) => {
|
|
536
|
+
const next = (lines[at] ?? "").trim();
|
|
537
|
+
return (blankLines || next !== "") && !next.startsWith(STATEMENT_PREFIX) && !TAG_LINE.test(next);
|
|
538
|
+
};
|
|
539
|
+
let index = 0;
|
|
540
|
+
while (index < lines.length) {
|
|
541
|
+
const line = index + 1;
|
|
542
|
+
const trimmed = (lines[index] ?? "").trim();
|
|
543
|
+
const lineMap = [[0, trimmedStart(index)]];
|
|
544
|
+
if (trimmed === "") {
|
|
545
|
+
endBlocks();
|
|
546
|
+
index++;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (trimmed.startsWith(STATEMENT_PREFIX)) {
|
|
550
|
+
endBlocks();
|
|
551
|
+
let text = trimmed;
|
|
552
|
+
const map = [...lineMap];
|
|
553
|
+
let end = scanBalanced(text, 0);
|
|
554
|
+
while (end === -1 && index + 1 < lines.length && continues(index + 1, true)) {
|
|
555
|
+
index++;
|
|
556
|
+
extendMap(map, text.length + 1, lineStarts[index] ?? 0);
|
|
557
|
+
text += `\n${lines[index] ?? ""}`;
|
|
558
|
+
end = scanBalanced(text, 0);
|
|
559
|
+
}
|
|
560
|
+
index++;
|
|
561
|
+
if (end === -1 && index < lines.length) {
|
|
562
|
+
const range = sourceRange(map, 0, text.length);
|
|
563
|
+
errors.push({
|
|
564
|
+
end: range.end,
|
|
565
|
+
line,
|
|
566
|
+
message: "{@body} line has no closing `}` before the next tag or {@body} line, so it was removed. Close it on its own line",
|
|
567
|
+
start: range.start
|
|
568
|
+
});
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (end === -1) {
|
|
572
|
+
repairs.push({
|
|
573
|
+
line,
|
|
574
|
+
message: "Dropped incomplete {@body} statement"
|
|
575
|
+
});
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
const inner = text.slice(6, end - 1);
|
|
579
|
+
const code = inner.trim();
|
|
580
|
+
const at = sliceMap(map, 6 + inner.length - inner.trimStart().length);
|
|
581
|
+
const range = sourceRange(map, 0, end);
|
|
582
|
+
if (code !== "" && builder.insideTag()) builder.append({
|
|
583
|
+
at,
|
|
584
|
+
code,
|
|
585
|
+
line,
|
|
586
|
+
...range,
|
|
587
|
+
type: "statement"
|
|
588
|
+
});
|
|
589
|
+
else if (code !== "") statements.push({
|
|
590
|
+
at,
|
|
591
|
+
code,
|
|
592
|
+
line,
|
|
593
|
+
...range
|
|
594
|
+
});
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (TAG_LINE.test(trimmed)) {
|
|
598
|
+
endBlocks();
|
|
599
|
+
let text = trimmed;
|
|
600
|
+
const map = [...lineMap];
|
|
601
|
+
let result = tokenizeInline(text, false, map);
|
|
602
|
+
while (result.incomplete !== null && index + 1 < lines.length && continues(index + 1, result.inBraces)) {
|
|
603
|
+
index++;
|
|
604
|
+
extendMap(map, text.length + 1, trimmedStart(index));
|
|
605
|
+
text += `\n${(lines[index] ?? "").trim()}`;
|
|
606
|
+
result = tokenizeInline(text, false, map);
|
|
607
|
+
}
|
|
608
|
+
index++;
|
|
609
|
+
if (result.incomplete !== null && index < lines.length) result = tokenizeInline(text, true, map);
|
|
610
|
+
const range = sourceRange(map, 0, text.length);
|
|
611
|
+
for (const message of result.problems) errors.push({
|
|
612
|
+
end: range.end,
|
|
613
|
+
line,
|
|
614
|
+
message,
|
|
615
|
+
start: range.start
|
|
616
|
+
});
|
|
617
|
+
if (result.incomplete !== null) repairs.push({
|
|
618
|
+
line,
|
|
619
|
+
message: `Dropped incomplete ${result.incomplete}`
|
|
620
|
+
});
|
|
621
|
+
builder.feed(result.tokens, line, true, map);
|
|
622
|
+
builder.closeEmphasis();
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
index++;
|
|
626
|
+
/** The map of `content`, which ends the trimmed line. */
|
|
627
|
+
const contentMap = (content) => sliceMap(lineMap, trimmed.length - content.length);
|
|
628
|
+
const heading = HEADING.exec(trimmed);
|
|
629
|
+
if (heading) {
|
|
630
|
+
endBlocks();
|
|
631
|
+
const level = (heading[1] ?? "#").length;
|
|
632
|
+
const size = headingSizes[Math.min(level, headingSizes.length) - 1];
|
|
633
|
+
const content = heading[2] ?? "";
|
|
634
|
+
builder.inlineBlock("title", [{
|
|
635
|
+
name: "size",
|
|
636
|
+
value: {
|
|
637
|
+
kind: "literal",
|
|
638
|
+
value: size ?? "md"
|
|
639
|
+
}
|
|
640
|
+
}], content, line, true, contentMap(content));
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (DIVIDER.test(trimmed)) {
|
|
644
|
+
endBlocks();
|
|
645
|
+
builder.append(element("divider", [], line, sourceRange(lineMap, 0, trimmed.length)));
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const item = LIST_ITEM.exec(trimmed);
|
|
649
|
+
if (item) {
|
|
650
|
+
flushParagraph();
|
|
651
|
+
const ordered = /\d/.test(item[1] ?? "");
|
|
652
|
+
if (list === null || list.ordered !== ordered || !builder.isOpen(list.frame)) {
|
|
653
|
+
endList();
|
|
654
|
+
const props = ordered ? [{
|
|
655
|
+
name: "ordered",
|
|
656
|
+
value: {
|
|
657
|
+
kind: "literal",
|
|
658
|
+
value: true
|
|
659
|
+
}
|
|
660
|
+
}] : [];
|
|
661
|
+
list = {
|
|
662
|
+
frame: builder.open(element("list", props, line, sourceRange(lineMap, 0, trimmed.length)), "block"),
|
|
663
|
+
ordered
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
const content = item[2] ?? "";
|
|
667
|
+
builder.inlineBlock("item", [], content, line, true, contentMap(content));
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
endList();
|
|
671
|
+
paragraph ??= {
|
|
672
|
+
line,
|
|
673
|
+
lines: [],
|
|
674
|
+
map: [],
|
|
675
|
+
width: 0
|
|
676
|
+
};
|
|
677
|
+
extendMap(paragraph.map, paragraph.width + (paragraph.lines.length > 0 ? 1 : 0), sourceOffset(lineMap, 0));
|
|
678
|
+
paragraph.width += trimmed.length + (paragraph.lines.length > 0 ? 1 : 0);
|
|
679
|
+
paragraph.lines.push(trimmed);
|
|
680
|
+
}
|
|
681
|
+
endBlocks(false);
|
|
682
|
+
return {
|
|
683
|
+
children: builder.finish(),
|
|
684
|
+
errors,
|
|
685
|
+
repairs,
|
|
686
|
+
statements
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
//#endregion
|
|
690
|
+
export { parsePickle };
|