@longform/longform 0.0.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 +52 -0
- package/dist/longform.d.ts +67 -0
- package/dist/longform.js +398 -0
- package/dist/longform.js.gz +0 -0
- package/dist/longform.js.map +7 -0
- package/dist/longform.min.js +5 -0
- package/dist/longform.min.js.gz +0 -0
- package/dist/longform.min.js.map +7 -0
- package/lib/longform.test.ts +287 -0
- package/lib/longform.ts +583 -0
- package/lib/types.ts +70 -0
- package/package.json +58 -0
package/lib/longform.ts
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import type { ChunkType, FragmentType, ParsedResult, WorkingChunk, WorkingElement, WorkingFragment, Fragment } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
FragmentType,
|
|
5
|
+
Fragment,
|
|
6
|
+
ParsedResult
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const sniffTestRe = /^(?:(?:(--).*)|(?: *(@|#).*)|(?: *[\w\-]+(?::[\w\-]+)?(?:[#.[][^\n]+)?(::).*)|(?: +([\["]).*)|(\ \ .*))$/gmi
|
|
10
|
+
, element1 = /((?:\ \ )+)? ?([\w\-]+(?::[\w\-]+)?)([#\.\[][^\n]*)?::(?: ({{?|[^\n]+))?/gmi
|
|
11
|
+
, directive1 = /((?:\ \ )+)? ?@([\w][\w\-]+)(?::: ?([^\n]+)?)?/gmi
|
|
12
|
+
, attribute1 = /((?:\ \ )+)\[(\w[\w-]*(?::\w[\w-]*)?)(?:=([^\n]+))?\]/
|
|
13
|
+
, preformattedClose = /[ \t]*}}?[ \t]*/
|
|
14
|
+
, id1 = /((?:\ \ )+)?#(#)?([\w\-]+)(?: ([\["]))?/gmi
|
|
15
|
+
, idnt1 = /^(\ \ )+/
|
|
16
|
+
, text1 = /^((?:\ \ )+)([^ \n][^\n]*)$/i
|
|
17
|
+
, paramsRe = /(?:(#|\.)([^#.\[\n]+)|(?:\[(\w[\w\-]*(?::\w[\w\-]*)?)(?:=([^\n\]]+))?\]))/g
|
|
18
|
+
, refRe = /#\[([\w\-]+)\]/g
|
|
19
|
+
, escapeRe = /([&<>"'#\[\]{}])/g
|
|
20
|
+
, templateLinesRe = /^(\ \ )?([^\n]*)$/gmi
|
|
21
|
+
, voids = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wrb']);
|
|
22
|
+
|
|
23
|
+
let m1: RegExpExecArray | null
|
|
24
|
+
, m2: RegExpExecArray | null
|
|
25
|
+
, m3: RegExpExecArray | null;
|
|
26
|
+
|
|
27
|
+
const entities = {
|
|
28
|
+
'&': '&',
|
|
29
|
+
'<': '<',
|
|
30
|
+
'>': '>',
|
|
31
|
+
'"': '"',
|
|
32
|
+
"'": ''',
|
|
33
|
+
// '#': '#',
|
|
34
|
+
// '[': '&lbrak;',
|
|
35
|
+
// ']': '&rbrak;',
|
|
36
|
+
// '{': '}',
|
|
37
|
+
// '}': '{',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function escape(value: string): string {
|
|
41
|
+
return value.replace(escapeRe, (match) => {
|
|
42
|
+
return entities[match] ?? match;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeElement(indent: number = 0): WorkingElement {
|
|
47
|
+
return {
|
|
48
|
+
indent,
|
|
49
|
+
html: '',
|
|
50
|
+
attrs: {},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeChunk(type: ChunkType = 'parsed'): WorkingChunk {
|
|
55
|
+
return {
|
|
56
|
+
type,
|
|
57
|
+
html: '',
|
|
58
|
+
els: [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeFragment(type: FragmentType = 'bare'): WorkingFragment {
|
|
63
|
+
return {
|
|
64
|
+
type,
|
|
65
|
+
html: '',
|
|
66
|
+
template: false,
|
|
67
|
+
els: [],
|
|
68
|
+
chunks: [],
|
|
69
|
+
refs: [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parses a longform document into a object containing the root and fragments
|
|
75
|
+
* in the output format.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} doc - The longform document to parse.
|
|
78
|
+
* @returns {ParsedResult}
|
|
79
|
+
*/
|
|
80
|
+
export function longform(doc: string, debug: (...d: unknown[]) => void = () => {}): ParsedResult {
|
|
81
|
+
let skipping: boolean = false
|
|
82
|
+
, textIndent: number | null = null
|
|
83
|
+
, verbatimSerialize: boolean = true
|
|
84
|
+
, verbatimIndent: number | null = null
|
|
85
|
+
, verbatimFirst: boolean = false
|
|
86
|
+
, element: WorkingElement = makeElement()
|
|
87
|
+
, chunk: WorkingChunk | null = makeChunk()
|
|
88
|
+
, fragment: WorkingFragment = makeFragment()
|
|
89
|
+
// the root fragment
|
|
90
|
+
, root: WorkingFragment | null = null
|
|
91
|
+
// ids of claimed fragments
|
|
92
|
+
const claimed: Set<string> = new Set()
|
|
93
|
+
// parsed fragments
|
|
94
|
+
, parsed: Map<string, WorkingFragment> = new Map()
|
|
95
|
+
, output: ParsedResult = Object.create(null);
|
|
96
|
+
|
|
97
|
+
output.fragments = Object.create(null);
|
|
98
|
+
output.templates = Object.create(null);
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Closes any current in progress element definition
|
|
103
|
+
* and creates a new working element.
|
|
104
|
+
*/
|
|
105
|
+
function applyIndent(targetIndent: number) {
|
|
106
|
+
if (element.tag != null) {
|
|
107
|
+
const root = fragment.type === 'range'
|
|
108
|
+
? targetIndent < 2
|
|
109
|
+
: fragment.html === ''
|
|
110
|
+
;
|
|
111
|
+
|
|
112
|
+
fragment.html += `<${element.tag}`
|
|
113
|
+
|
|
114
|
+
if (root) {
|
|
115
|
+
if (fragment.type === 'root') {
|
|
116
|
+
fragment.html += ` data-lf-root`;
|
|
117
|
+
} else if (fragment.type === 'bare' || fragment.type === 'range') {
|
|
118
|
+
fragment.html += ` data-lf="${fragment.id}"`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (element.id != null) {
|
|
123
|
+
fragment.html += ' id="' + element.id + '"';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (element.class != null) {
|
|
127
|
+
fragment.html += ' class="' + element.class + '"';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const attr of Object.entries(element.attrs)) {
|
|
131
|
+
if (attr[1] == null) {
|
|
132
|
+
fragment.html += ' ' + attr[0]
|
|
133
|
+
} else {
|
|
134
|
+
fragment.html += ` ${attr[0]}="${attr[1]}"`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fragment.html += '>';
|
|
139
|
+
|
|
140
|
+
if (!voids.has(element.tag as string) && element.text != null) {
|
|
141
|
+
fragment.html += element.text;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
!voids.has(element.tag as string)
|
|
146
|
+
) {
|
|
147
|
+
fragment.els.push(element);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (targetIndent <= element.indent) {
|
|
152
|
+
element = makeElement(targetIndent);
|
|
153
|
+
|
|
154
|
+
while (
|
|
155
|
+
fragment.els.length !== 0 && (
|
|
156
|
+
targetIndent == null ||
|
|
157
|
+
fragment.els[fragment.els.length - 1].indent !== targetIndent - 1
|
|
158
|
+
)
|
|
159
|
+
) {
|
|
160
|
+
const element = fragment.els.pop();
|
|
161
|
+
|
|
162
|
+
fragment.html += `</${element?.tag}>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (targetIndent === 0) {
|
|
166
|
+
debug(0, '<', fragment.type, fragment.id);
|
|
167
|
+
if (fragment.template) {
|
|
168
|
+
output.templates[fragment.id] = fragment.html;
|
|
169
|
+
} else if (fragment.type === 'root') {
|
|
170
|
+
root = fragment;
|
|
171
|
+
} else {
|
|
172
|
+
parsed.set(fragment.id, fragment);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
fragment = makeFragment();
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
element = makeElement(targetIndent)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
while ((m1 = sniffTestRe.exec(doc))) {
|
|
183
|
+
if (m1[1] === '--') {
|
|
184
|
+
continue;
|
|
185
|
+
} else if (fragment.template) {
|
|
186
|
+
fragment.html += m1[0];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// If this is a script tag or preformatted block
|
|
190
|
+
// we want to retain the intended formatting less
|
|
191
|
+
// the indent. Preformatting can apply to any element
|
|
192
|
+
// by ending the declaration with `:: {`.
|
|
193
|
+
if (verbatimIndent != null) {
|
|
194
|
+
// inside a script or preformatted block
|
|
195
|
+
idnt1.lastIndex = 0;
|
|
196
|
+
m2 = idnt1.exec(m1[0]);
|
|
197
|
+
const indent = m2 == null
|
|
198
|
+
? null
|
|
199
|
+
: m2[0].length / 2;
|
|
200
|
+
|
|
201
|
+
if (m2 == null || indent as number <= verbatimIndent) {
|
|
202
|
+
fragment.html += '\n';
|
|
203
|
+
debug(indent, '}', m2?.[0]);
|
|
204
|
+
|
|
205
|
+
applyIndent(indent);
|
|
206
|
+
verbatimIndent = null;
|
|
207
|
+
verbatimFirst = false;
|
|
208
|
+
textIndent = indent;
|
|
209
|
+
|
|
210
|
+
if (preformattedClose.test(m1[0])) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
const line = m1[0].replace(' '.repeat(verbatimIndent + 1), '');
|
|
215
|
+
debug(indent, '{', line);
|
|
216
|
+
|
|
217
|
+
if (element.tag != null) {
|
|
218
|
+
applyIndent(indent as number);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (verbatimFirst) {
|
|
222
|
+
verbatimFirst = false;
|
|
223
|
+
} else {
|
|
224
|
+
fragment.html += '\n';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (verbatimSerialize) {
|
|
228
|
+
fragment.html += escape(line);
|
|
229
|
+
} else {
|
|
230
|
+
fragment.html += line;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (m1[0].trim() === '') {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
switch (m1[2] ?? m1[3] ?? m1[4]) {
|
|
242
|
+
// deno-lint-ignore no-fallthrough
|
|
243
|
+
case '#': {
|
|
244
|
+
id1.lastIndex = 0;
|
|
245
|
+
m2 = id1.exec(m1[0]);
|
|
246
|
+
|
|
247
|
+
if (m2 != null) {
|
|
248
|
+
const indent = (m2[1]?.length ?? 0) / 2;
|
|
249
|
+
|
|
250
|
+
if (element.tag != null || textIndent != null) {
|
|
251
|
+
applyIndent(indent);
|
|
252
|
+
textIndent = null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
debug(indent, 'id', m2[2], m2[3], m2[4]);
|
|
256
|
+
|
|
257
|
+
fragment.id = m2[3];
|
|
258
|
+
|
|
259
|
+
if (indent === 0) {
|
|
260
|
+
if (m2[4] == '[') {
|
|
261
|
+
fragment.type = 'range';
|
|
262
|
+
} else if (m2[4] === '"') {
|
|
263
|
+
fragment.type = 'text';
|
|
264
|
+
} else if (m2[2] != null) {
|
|
265
|
+
fragment.type = 'bare';
|
|
266
|
+
} else {
|
|
267
|
+
fragment.type = 'embed';
|
|
268
|
+
element.id = fragment.id;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
case '@':
|
|
276
|
+
case '[':
|
|
277
|
+
// deno-lint-ignore no-fallthrough
|
|
278
|
+
case '::': {
|
|
279
|
+
element1.lastIndex = 0;
|
|
280
|
+
// fall through if m1[3] is a # or @
|
|
281
|
+
m2 = m1[2] ?? m1[4] != null
|
|
282
|
+
? null
|
|
283
|
+
: element1.exec(m1[0]);
|
|
284
|
+
|
|
285
|
+
// if null then invalid element selector
|
|
286
|
+
// allow the default text case to handle
|
|
287
|
+
if (m2 != null) {
|
|
288
|
+
const indent = (m2[1]?.length ?? 0) / 2
|
|
289
|
+
, tg = m2[2]
|
|
290
|
+
, ar = m2[3]
|
|
291
|
+
, pr = m2[4] === '{' || m2[4] === '{{'
|
|
292
|
+
const tx = pr ? null : m2[4]
|
|
293
|
+
|
|
294
|
+
debug(indent, 'e', tg, pr, tx);
|
|
295
|
+
|
|
296
|
+
if (
|
|
297
|
+
element.tag != null ||
|
|
298
|
+
element.indent > indent
|
|
299
|
+
) {
|
|
300
|
+
applyIndent(indent);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
element.indent = indent;
|
|
304
|
+
element.tag = tg;
|
|
305
|
+
|
|
306
|
+
textIndent = null;
|
|
307
|
+
|
|
308
|
+
if (indent === 0 && fragment.id == null) {
|
|
309
|
+
if (root != null) {
|
|
310
|
+
skipping = true;
|
|
311
|
+
} else {
|
|
312
|
+
fragment.type = 'root';
|
|
313
|
+
root = fragment;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (ar != null) {
|
|
318
|
+
debug(indent, 'a', ar);
|
|
319
|
+
while ((m2 = paramsRe.exec(ar))) {
|
|
320
|
+
if (m2[1] === '#') {
|
|
321
|
+
element.id = m2[2];
|
|
322
|
+
} else if (m2[1] === '.') {
|
|
323
|
+
if (element.class == null) {
|
|
324
|
+
element.class = m2[2];
|
|
325
|
+
} else {
|
|
326
|
+
element.class += ' ' + m2[2];
|
|
327
|
+
}
|
|
328
|
+
} else {
|
|
329
|
+
if (m2[3] === 'id') {
|
|
330
|
+
if (element.id == null) {
|
|
331
|
+
element.id = m2[4];
|
|
332
|
+
}
|
|
333
|
+
} else if (m2[3] === 'class') {
|
|
334
|
+
if (element.class == null) {
|
|
335
|
+
element.class = m2[4]
|
|
336
|
+
} else {
|
|
337
|
+
element.class += ' ' + m2[4]
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
element.attrs[m2[3]] = m2[4];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!pr && tx != null) {
|
|
347
|
+
element.text = tx;
|
|
348
|
+
} else if (pr) {
|
|
349
|
+
verbatimFirst = true;
|
|
350
|
+
verbatimIndent = indent;
|
|
351
|
+
verbatimSerialize = m2[4] === '{';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
attribute1.lastIndex = 0;
|
|
358
|
+
m2 = m1[2] != null
|
|
359
|
+
? null
|
|
360
|
+
: attribute1.exec(m1[0]);
|
|
361
|
+
|
|
362
|
+
if (m2 != null && element.tag != null) {
|
|
363
|
+
debug('a', m2[2], m2[3]);
|
|
364
|
+
|
|
365
|
+
if (m2[2] === 'id') {
|
|
366
|
+
if (element.id == null) {
|
|
367
|
+
element.id = m2[3].trim();
|
|
368
|
+
}
|
|
369
|
+
} else if (m2[2] === 'class') {
|
|
370
|
+
if (element.class != null) {
|
|
371
|
+
element.class += ' ' + m2[3].trim();
|
|
372
|
+
} else {
|
|
373
|
+
element.class = m2[3].trim();
|
|
374
|
+
}
|
|
375
|
+
} else if (element.attrs[m2[2]] != null) {
|
|
376
|
+
element.attrs[m2[2]] += m2[3];
|
|
377
|
+
} else {
|
|
378
|
+
element.attrs[m2[2]] = m2[3];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
directive1.lastIndex = 0;
|
|
385
|
+
m2 = m1[3] != null
|
|
386
|
+
? null
|
|
387
|
+
: directive1.exec(m1[0]);
|
|
388
|
+
|
|
389
|
+
if (m2 != null) {
|
|
390
|
+
const indent = (m2[1]?.length ?? 0) / 2;
|
|
391
|
+
|
|
392
|
+
if (element.tag != null || textIndent != null) {
|
|
393
|
+
applyIndent(indent);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
debug(indent, 'd', m2[2], m2[3]);
|
|
397
|
+
|
|
398
|
+
switch (m2[2]) {
|
|
399
|
+
case 'doctype': {
|
|
400
|
+
fragment.html += `<!doctype ${m2[3] ?? 'html'}>`;
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
case 'xml': {
|
|
404
|
+
fragment.html += `<?xml ${m2[3] ?? 'version="1.0" encoding="UTF-8"'}?>`;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
case 'template': {
|
|
408
|
+
let indented = false;
|
|
409
|
+
fragment.template = indent === 0;
|
|
410
|
+
|
|
411
|
+
templateLinesRe.lastIndex = sniffTestRe.lastIndex;
|
|
412
|
+
while ((m2 = templateLinesRe.exec(doc))) {
|
|
413
|
+
if (m2[1] == null && !indented) {
|
|
414
|
+
id1.lastIndex = 0;
|
|
415
|
+
m3 = id1.exec(m2[0]);
|
|
416
|
+
|
|
417
|
+
fragment.id = m3[3];
|
|
418
|
+
fragment.html += m2[0];
|
|
419
|
+
} else if (m2[1] == null && indented) {
|
|
420
|
+
sniffTestRe.lastIndex = templateLinesRe.lastIndex - 1;
|
|
421
|
+
applyIndent(0)
|
|
422
|
+
break;
|
|
423
|
+
} else {
|
|
424
|
+
fragment.html += '\n' + m2[0];
|
|
425
|
+
}
|
|
426
|
+
indented = true;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
}
|
|
435
|
+
default: {
|
|
436
|
+
m2 = text1.exec(m1[0]) as RegExpExecArray;
|
|
437
|
+
|
|
438
|
+
if (m2 == null) {
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
const indent = m2[1].length / 2;
|
|
442
|
+
const tx = m2[2].trim();
|
|
443
|
+
|
|
444
|
+
debug(indent, 't', m2[2]);
|
|
445
|
+
|
|
446
|
+
if (element.tag != null) {
|
|
447
|
+
applyIndent(indent);
|
|
448
|
+
|
|
449
|
+
fragment.html += tx;
|
|
450
|
+
} else if (fragment.type === 'text' && fragment.html === '') {
|
|
451
|
+
fragment.html += tx;
|
|
452
|
+
} else {
|
|
453
|
+
fragment.html += ' ' + tx;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
textIndent = indent;
|
|
457
|
+
|
|
458
|
+
while ((m2 = refRe.exec(tx))) {
|
|
459
|
+
const start = fragment.html.length + m2.index - tx.length;
|
|
460
|
+
|
|
461
|
+
fragment.refs.push({
|
|
462
|
+
id: m2[1],
|
|
463
|
+
start,
|
|
464
|
+
end: start + m2[0].length,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
applyIndent(0);
|
|
474
|
+
|
|
475
|
+
const arr = Array.from(parsed.values());
|
|
476
|
+
|
|
477
|
+
function flatten(fragment: WorkingFragment): WorkingFragment {
|
|
478
|
+
// work backwards so we don't change the html string length
|
|
479
|
+
// for the later replacements
|
|
480
|
+
for (let j = fragment.refs.length - 1; j >= 0; j--) {
|
|
481
|
+
const ref = fragment.refs[j];
|
|
482
|
+
|
|
483
|
+
if (claimed.has(ref.id) || !parsed.has(ref.id)) {
|
|
484
|
+
fragment.html = fragment.html.slice(0, ref.start)
|
|
485
|
+
+ fragment.html.slice(ref.end)
|
|
486
|
+
} else {
|
|
487
|
+
const child = flatten(parsed.get(ref.id));
|
|
488
|
+
|
|
489
|
+
fragment.html = fragment.html.slice(0, ref.start)
|
|
490
|
+
+ child.html
|
|
491
|
+
+ fragment.html.slice(ref.end);
|
|
492
|
+
|
|
493
|
+
if (child.type === 'embed') {
|
|
494
|
+
claimed.add(child.id)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
fragment.refs = [];
|
|
500
|
+
|
|
501
|
+
return fragment;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
for (let i = 0; i < parsed.size + 1; i++) {
|
|
505
|
+
let fragment: WorkingFragment;
|
|
506
|
+
|
|
507
|
+
if (i === 0 && root == null) {
|
|
508
|
+
continue;
|
|
509
|
+
} else if (i === 0) {
|
|
510
|
+
fragment = root;
|
|
511
|
+
} else {
|
|
512
|
+
fragment = arr[i - 1];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (fragment.refs.length === 0) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
flatten(fragment)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (root?.html != null) {
|
|
523
|
+
output.root = root.html;
|
|
524
|
+
output.selector = `[data-lf-root]`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
for (let i = 0; i < arr.length; i++) {
|
|
528
|
+
let selector: string;
|
|
529
|
+
const fragment = arr[i];
|
|
530
|
+
|
|
531
|
+
if (fragment == null || claimed.has(fragment.id)) {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (fragment.type === 'embed') {
|
|
536
|
+
selector = `[id=${fragment.id}]`;
|
|
537
|
+
} else if (fragment.type === 'bare') {
|
|
538
|
+
selector = `[data-lf=${fragment.id}]`;
|
|
539
|
+
} else if (fragment.type === 'range') {
|
|
540
|
+
selector = `[data-lf=${fragment.id}]`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
output.fragments[fragment.id] = {
|
|
544
|
+
id: fragment.id,
|
|
545
|
+
selector,
|
|
546
|
+
type: fragment.type as 'embed' | 'bare' | 'range',
|
|
547
|
+
html: fragment.html,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return output;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
const templateRe = /(?:#{([\w][\w\-_]*)})|(?:#\[([\w][\w\-_]+)\])/g;
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Processes a client side Longform template to HTML fragment string.
|
|
559
|
+
*
|
|
560
|
+
* @param fragment - The fragment identifier.
|
|
561
|
+
* @param args - A record of template arguments.
|
|
562
|
+
* @param getFragment - A function which returns an already processed fragment's HTML string.
|
|
563
|
+
* @returns The processed template.
|
|
564
|
+
*/
|
|
565
|
+
export function processTemplate(
|
|
566
|
+
template: string,
|
|
567
|
+
args: Record<string, string | number>,
|
|
568
|
+
getFragment: (fragment: string) => string | undefined,
|
|
569
|
+
): string | undefined {
|
|
570
|
+
const lf = template.replace(templateRe, (_match, param, ref) => {
|
|
571
|
+
if (ref != null) {
|
|
572
|
+
const fragment = getFragment(ref);
|
|
573
|
+
|
|
574
|
+
if (fragment == null) return '';
|
|
575
|
+
|
|
576
|
+
return fragment;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return args[param] != null ? escape(args[param].toString()) : '';
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
return Object.values(longform(lf).fragments)[0]?.html ?? null;
|
|
583
|
+
}
|
package/lib/types.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
|
|
2
|
+
export type WorkingElement = {
|
|
3
|
+
indent: number;
|
|
4
|
+
key?: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
tag?: string;
|
|
7
|
+
class?: string;
|
|
8
|
+
attrs: Record<string, string | null>;
|
|
9
|
+
text?: string;
|
|
10
|
+
html: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ChunkType =
|
|
14
|
+
| 'parsed'
|
|
15
|
+
| 'ref'
|
|
16
|
+
| 'scope'
|
|
17
|
+
;
|
|
18
|
+
|
|
19
|
+
export type WorkingChunk = {
|
|
20
|
+
type: ChunkType;
|
|
21
|
+
html: string;
|
|
22
|
+
els: WorkingElement[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type WorkingFragmentType =
|
|
26
|
+
| 'root'
|
|
27
|
+
| 'embed'
|
|
28
|
+
| 'bare'
|
|
29
|
+
| 'range'
|
|
30
|
+
| 'text'
|
|
31
|
+
| 'template'
|
|
32
|
+
;
|
|
33
|
+
|
|
34
|
+
export type FragmentType =
|
|
35
|
+
| 'embed'
|
|
36
|
+
| 'bare'
|
|
37
|
+
| 'range'
|
|
38
|
+
| 'text'
|
|
39
|
+
;
|
|
40
|
+
|
|
41
|
+
export type FragmentRef = {
|
|
42
|
+
id: string;
|
|
43
|
+
start: number;
|
|
44
|
+
end: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type WorkingFragment = {
|
|
48
|
+
id?: string;
|
|
49
|
+
template: boolean;
|
|
50
|
+
type: WorkingFragmentType;
|
|
51
|
+
html: string;
|
|
52
|
+
refs: FragmentRef[];
|
|
53
|
+
chunks: WorkingChunk[];
|
|
54
|
+
els: WorkingElement[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type Fragment = {
|
|
58
|
+
id: string;
|
|
59
|
+
selector: string;
|
|
60
|
+
type: FragmentType;
|
|
61
|
+
html: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type ParsedResult = {
|
|
65
|
+
root: string | null;
|
|
66
|
+
selector: string | null;
|
|
67
|
+
fragments: Record<string, Fragment>;
|
|
68
|
+
templates: Record<string, string>;
|
|
69
|
+
};
|
|
70
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@longform/longform",
|
|
3
|
+
"description": "A markup and templating language for creating HTML fragments and full documents.",
|
|
4
|
+
"author": "Matthew Quinn",
|
|
5
|
+
"homepage": "https://github.com/occultist-dev/longform",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"version": "0.0.1",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "dist/longform.js",
|
|
10
|
+
"types": "dist/longform.d.ts",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/occultist-dev/longform"
|
|
14
|
+
},
|
|
15
|
+
"maintainers": [
|
|
16
|
+
{
|
|
17
|
+
"name": "Matthew Quinn",
|
|
18
|
+
"url": "https://matthewquinn.me"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"licenses": [
|
|
22
|
+
{
|
|
23
|
+
"type": "MIT",
|
|
24
|
+
"url": "https://github.com/occultist-dev/longform/blob/main/LICENSE"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"keywords": [
|
|
28
|
+
"HTML",
|
|
29
|
+
"Fragments",
|
|
30
|
+
"Templating",
|
|
31
|
+
"Longform"
|
|
32
|
+
],
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/commonmark": "^0.27.10",
|
|
35
|
+
"@types/jsdom": "^27.0.0",
|
|
36
|
+
"@types/node": "^24.8.1",
|
|
37
|
+
"commonmark": "^0.31.2",
|
|
38
|
+
"dompurify": "^3.3.0",
|
|
39
|
+
"esbuild": "^0.25.11",
|
|
40
|
+
"jsdom": "^27.0.1",
|
|
41
|
+
"marked": "^16.4.1",
|
|
42
|
+
"prettier": "^3.6.2",
|
|
43
|
+
"typescript": "^5.9.3",
|
|
44
|
+
"vnu-jar": "^24.10.17"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"package.json",
|
|
48
|
+
"README.md",
|
|
49
|
+
"LICENCE",
|
|
50
|
+
"lib",
|
|
51
|
+
"dist"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"build": "node build.js",
|
|
55
|
+
"test": "node --test lib/longform.test.ts",
|
|
56
|
+
"serve": "node ./serve.ts"
|
|
57
|
+
}
|
|
58
|
+
}
|