@valbuild/ui 0.20.2 → 0.21.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.
@@ -0,0 +1,424 @@
1
+ import { initVal } from "@valbuild/core";
2
+ import { parseRichTextSource } from "./parseRichTextSource";
3
+
4
+ const { val } = initVal();
5
+
6
+ //MD to HTML
7
+ describe("richtext", () => {
8
+ test("basic h1", () => {
9
+ const r = val.richtext`# Title 1`;
10
+ expect(parseRichTextSource(r).children).toStrictEqual([
11
+ { tag: "h1", children: ["Title 1"] },
12
+ ]);
13
+ });
14
+
15
+ test("basic complete", () => {
16
+ const r = val.richtext`# Title 1
17
+ ## Title 2
18
+
19
+ Paragraph 1 2 3 4 5. Words *italic* **bold**
20
+ `;
21
+ expect(parseRichTextSource(r).children).toStrictEqual([
22
+ { tag: "h1", children: ["Title 1"] },
23
+ { tag: "h2", children: ["Title 2"] },
24
+ {
25
+ tag: "p",
26
+ children: [
27
+ "Paragraph 1 2 3 4 5. Words ",
28
+ { tag: "span", classes: ["italic"], children: ["italic"] },
29
+ " ",
30
+ { tag: "span", classes: ["bold"], children: ["bold"] },
31
+ ],
32
+ },
33
+ ]);
34
+ });
35
+
36
+ test.skip("strong and emphasis merged spans", () => {
37
+ // TODO: currently we do not merge
38
+ const r = val.richtext`Which classes?
39
+ ***All of them!***
40
+ `;
41
+ expect(parseRichTextSource(r).children).toStrictEqual([
42
+ {
43
+ tag: "p",
44
+ children: [
45
+ "Which classes?\n",
46
+ {
47
+ tag: "span",
48
+ classes: ["italic", "bold"],
49
+ children: ["All of them!"],
50
+ },
51
+ ],
52
+ },
53
+ ]);
54
+ });
55
+
56
+ test("line through", () => {
57
+ // TODO: currently we do not merge
58
+ const r = val.richtext`~~line through~~`;
59
+ expect(parseRichTextSource(r).children).toStrictEqual([
60
+ {
61
+ tag: "p",
62
+ children: [
63
+ {
64
+ tag: "span",
65
+ classes: ["line-through"],
66
+ children: ["line through"],
67
+ },
68
+ ],
69
+ },
70
+ ]);
71
+ });
72
+
73
+ test("2 paragraphs", () => {
74
+ const r = val.richtext`# Title 1
75
+
76
+ First paragraph
77
+
78
+ Second paragraph
79
+ `;
80
+ expect(parseRichTextSource(r).children).toStrictEqual([
81
+ { tag: "h1", children: ["Title 1"] },
82
+ { tag: "p", children: ["First paragraph"] },
83
+ { tag: "p", children: ["Second paragraph"] },
84
+ ]);
85
+ });
86
+
87
+ test("basic lists", () => {
88
+ const r = val.richtext`A bullet list:
89
+
90
+ - bullet 1
91
+ - bullet 2
92
+ `;
93
+ expect(parseRichTextSource(r).children).toStrictEqual([
94
+ { tag: "p", children: ["A bullet list:"] },
95
+ {
96
+ tag: "ul",
97
+ children: [
98
+ { tag: "li", children: ["bullet 1"] },
99
+ { tag: "li", children: ["bullet 2"] },
100
+ ],
101
+ },
102
+ ]);
103
+ });
104
+
105
+ test("lists with line breaks", () => {
106
+ const r = val.richtext`A bullet list:
107
+
108
+ - bullet 1
109
+ - bullet 2
110
+ break this line
111
+ `;
112
+ expect(parseRichTextSource(r).children).toStrictEqual([
113
+ { tag: "p", children: ["A bullet list:"] },
114
+ {
115
+ tag: "ul",
116
+ children: [
117
+ {
118
+ tag: "li",
119
+ children: ["bullet 1"],
120
+ },
121
+ {
122
+ tag: "li",
123
+ children: [
124
+ "bullet 2",
125
+ { tag: "br", children: [] },
126
+ "break this line",
127
+ ],
128
+ },
129
+ ],
130
+ },
131
+ ]);
132
+ });
133
+
134
+ test("special chars", () => {
135
+ const r = val.richtext`# "Title 1"
136
+
137
+ Beautiful "quotes" and 'single quotes'
138
+
139
+ Some crocodiles: < >
140
+
141
+ Ampersand: &
142
+
143
+ `;
144
+ expect(parseRichTextSource(r).children).toStrictEqual([
145
+ { tag: "h1", children: ['"Title 1"'] },
146
+ { tag: "p", children: [`Beautiful "quotes" and 'single quotes'`] },
147
+ { tag: "p", children: ["Some crocodiles: < >"] },
148
+ { tag: "p", children: ["Ampersand: &"] },
149
+ ]);
150
+ });
151
+
152
+ test("lists", () => {
153
+ const r = val.richtext`# Title 1
154
+
155
+ A paragraph
156
+
157
+ A bullet list:
158
+ - bullet 1
159
+ - bullet 2
160
+
161
+ A numbered list:
162
+ 1. number 1
163
+ 2. number 2
164
+
165
+ A nested list:
166
+ - bullet 1:
167
+ 1. number 1.1
168
+ 2. number 1.2
169
+ - bullet 2:
170
+ - bullet 2.1
171
+ - bullet 2.2
172
+ `;
173
+ expect(parseRichTextSource(r).children).toStrictEqual([
174
+ { tag: "h1", children: ["Title 1"] },
175
+ { tag: "p", children: ["A paragraph"] },
176
+ { tag: "p", children: ["A bullet list:"] },
177
+ {
178
+ tag: "ul",
179
+ children: [
180
+ { tag: "li", children: ["bullet 1"] },
181
+ { tag: "li", children: ["bullet 2"] },
182
+ ],
183
+ },
184
+ { tag: "p", children: ["A numbered list:"] },
185
+ {
186
+ tag: "ol",
187
+ children: [
188
+ { tag: "li", children: ["number 1"] },
189
+ { tag: "li", children: ["number 2"] },
190
+ ],
191
+ },
192
+ { tag: "p", children: ["A nested list:"] },
193
+ {
194
+ tag: "ul",
195
+ children: [
196
+ {
197
+ tag: "li",
198
+ children: [
199
+ "bullet 1:",
200
+ {
201
+ tag: "ol",
202
+ children: [
203
+ { tag: "li", children: ["number 1.1"] },
204
+ { tag: "li", children: ["number 1.2"] },
205
+ ],
206
+ },
207
+ ],
208
+ },
209
+ {
210
+ tag: "li",
211
+ children: [
212
+ "bullet 2:",
213
+ {
214
+ tag: "ul",
215
+ children: [
216
+ { tag: "li", children: ["bullet 2.1"] },
217
+ { tag: "li", children: ["bullet 2.2"] },
218
+ ],
219
+ },
220
+ ],
221
+ },
222
+ ],
223
+ },
224
+ ]);
225
+ });
226
+
227
+ test("image", () => {
228
+ const r = val.richtext`# Title 1
229
+
230
+ Below we have an image block:
231
+
232
+ ${val.file("/public/foo.png", {
233
+ width: 100,
234
+ height: 100,
235
+ sha256: "123",
236
+ })}`;
237
+ expect(parseRichTextSource(r).children).toStrictEqual([
238
+ { tag: "h1", children: ["Title 1"] },
239
+ { tag: "p", children: ["Below we have an image block:"] },
240
+ {
241
+ tag: "p",
242
+ children: [
243
+ {
244
+ src: "/foo.png?sha256=123",
245
+ tag: "img",
246
+ width: 100,
247
+ height: 100,
248
+ children: [],
249
+ },
250
+ ],
251
+ },
252
+ ]);
253
+ });
254
+
255
+ test("markdown link", () => {
256
+ const r = val.richtext`# Title 1
257
+
258
+ Below we have a url: [google](https://google.com)`;
259
+ expect(parseRichTextSource(r).children).toStrictEqual([
260
+ { tag: "h1", children: ["Title 1"] },
261
+ {
262
+ tag: "p",
263
+ children: [
264
+ "Below we have a url: ",
265
+ {
266
+ href: "https://google.com",
267
+ tag: "a",
268
+ children: ["google"],
269
+ },
270
+ ],
271
+ },
272
+ ]);
273
+ });
274
+
275
+ test("block link", () => {
276
+ const r = val.richtext`# Title 1
277
+
278
+ Below we have a url:
279
+
280
+ ${val.link("google", { href: "https://google.com" })}`;
281
+ expect(parseRichTextSource(r).children).toStrictEqual([
282
+ { tag: "h1", children: ["Title 1"] },
283
+ { tag: "p", children: ["Below we have a url:"] },
284
+ {
285
+ tag: "p",
286
+ children: [
287
+ {
288
+ tag: "a",
289
+ href: "https://google.com",
290
+ children: ["google"],
291
+ },
292
+ ],
293
+ },
294
+ ]);
295
+ });
296
+
297
+ test("inline link", () => {
298
+ const r = val.richtext`# Title 1
299
+
300
+ Below we have a url: ${val.link("google", { href: "https://google.com" })}`;
301
+ expect(parseRichTextSource(r).children).toStrictEqual([
302
+ { tag: "h1", children: ["Title 1"] },
303
+ {
304
+ tag: "p",
305
+ children: [
306
+ "Below we have a url: ",
307
+ {
308
+ href: "https://google.com",
309
+ tag: "a",
310
+ children: ["google"],
311
+ },
312
+ ],
313
+ },
314
+ ]);
315
+ });
316
+
317
+ test("inline link with bold", () => {
318
+ const r = val.richtext`# Title 1
319
+
320
+ Inline link -> ${val.link("**google**", { href: "https://google.com" })}`;
321
+
322
+ // source:
323
+ expect(parseRichTextSource(r).children).toStrictEqual([
324
+ { tag: "h1", children: ["Title 1"] },
325
+ {
326
+ tag: "p",
327
+ children: [
328
+ "Inline link -> ",
329
+ {
330
+ href: "https://google.com",
331
+ tag: "a",
332
+ children: [
333
+ {
334
+ tag: "span",
335
+ classes: ["bold"],
336
+ children: ["google"],
337
+ },
338
+ ],
339
+ },
340
+ ],
341
+ },
342
+ ]);
343
+ });
344
+
345
+ test("https:// in link description", () => {
346
+ const r = val.richtext`# Title 1
347
+
348
+ Inline link -> ${val.link("https://google.com", {
349
+ href: "https://google.com",
350
+ })}`;
351
+
352
+ expect(parseRichTextSource(r).children).toStrictEqual([
353
+ { tag: "h1", children: ["Title 1"] },
354
+ {
355
+ tag: "p",
356
+ children: [
357
+ "Inline link -> ",
358
+ {
359
+ tag: "a",
360
+ href: "https://google.com",
361
+ children: ["https://google.com"],
362
+ },
363
+ ],
364
+ },
365
+ ]);
366
+ });
367
+
368
+ test("auto link does nothing", () => {
369
+ const r = val.richtext`# Title 1
370
+
371
+ No transform here -> https://google.com
372
+
373
+ Transform this:
374
+ [https://google.com](https://google.com)
375
+ `;
376
+
377
+ expect(parseRichTextSource(r).children).toStrictEqual([
378
+ { tag: "h1", children: ["Title 1"] },
379
+ {
380
+ tag: "p",
381
+ children: ["No transform here -> ", "https://google.com"],
382
+ },
383
+ {
384
+ tag: "p",
385
+ children: [
386
+ "Transform this:\n",
387
+ {
388
+ tag: "a",
389
+ href: "https://google.com",
390
+ children: ["https://google.com"],
391
+ },
392
+ ],
393
+ },
394
+ ]);
395
+ });
396
+
397
+ test("breaks", () => {
398
+ const r = val.richtext`
399
+ # Title 1
400
+
401
+ Foo
402
+
403
+ <br>
404
+
405
+ Bar
406
+ `;
407
+ // source:
408
+ expect(parseRichTextSource(r).children).toStrictEqual([
409
+ { tag: "h1", children: ["Title 1"] },
410
+ {
411
+ tag: "p",
412
+ children: ["Foo"],
413
+ },
414
+ {
415
+ tag: "br",
416
+ children: [],
417
+ },
418
+ {
419
+ tag: "p",
420
+ children: ["Bar"],
421
+ },
422
+ ]);
423
+ });
424
+ });
@@ -0,0 +1,228 @@
1
+ import * as marked from "marked";
2
+ import {
3
+ OrderedListNode,
4
+ AnyRichTextOptions,
5
+ UnorderedListNode,
6
+ LinkSource,
7
+ RichTextNode,
8
+ SpanNode,
9
+ LinkNode,
10
+ ListItemNode,
11
+ RichTextOptions,
12
+ RichTextSource,
13
+ RichText,
14
+ VAL_EXTENSION,
15
+ FileSource,
16
+ ParagraphNode,
17
+ HeadingNode,
18
+ Internal,
19
+ } from "@valbuild/core";
20
+
21
+ const VAL_START_TAG_PREFIX = '<val value="';
22
+ const VAL_START_TAG_SUFFIX = '">';
23
+ const VAL_END_TAG = "</val>";
24
+
25
+ type AnyListChildren =
26
+ | OrderedListNode<AnyRichTextOptions>["children"]
27
+ | UnorderedListNode<AnyRichTextOptions>["children"];
28
+
29
+ type ImageSource = FileSource<{
30
+ width: number;
31
+ height: number;
32
+ sha256: string;
33
+ }>;
34
+
35
+ function parseTokens(
36
+ tokens: marked.Token[],
37
+ sourceNodes: (ImageSource | LinkSource)[],
38
+ cursor: number,
39
+ insideList = false
40
+ ): { children: RichTextNode<AnyRichTextOptions>[]; cursor: number } {
41
+ const children: RichTextNode<AnyRichTextOptions>[] = [];
42
+ while (cursor < tokens.length) {
43
+ const token = tokens[cursor];
44
+ if (token.type === "heading") {
45
+ children.push({
46
+ tag: `h${token.depth as 1 | 2 | 3 | 4 | 5 | 6}`,
47
+ children: parseTokens(token.tokens ? token.tokens : [], sourceNodes, 0)
48
+ .children as HeadingNode<AnyRichTextOptions>["children"],
49
+ });
50
+ } else if (token.type === "paragraph") {
51
+ children.push({
52
+ tag: "p",
53
+ children: parseTokens(token.tokens ? token.tokens : [], sourceNodes, 0)
54
+ .children as ParagraphNode<AnyRichTextOptions>["children"],
55
+ });
56
+ } else if (token.type === "strong") {
57
+ children.push({
58
+ tag: "span",
59
+ classes: ["bold"],
60
+ children: parseTokens(token.tokens ? token.tokens : [], sourceNodes, 0)
61
+ .children as SpanNode<AnyRichTextOptions>["children"],
62
+ });
63
+ } else if (token.type === "em") {
64
+ children.push({
65
+ tag: "span",
66
+ classes: ["italic"],
67
+ children: parseTokens(token.tokens ? token.tokens : [], sourceNodes, 0)
68
+ .children as SpanNode<AnyRichTextOptions>["children"],
69
+ });
70
+ } else if (token.type === "del") {
71
+ children.push({
72
+ tag: "span",
73
+ classes: ["line-through"],
74
+ children: parseTokens(token.tokens ? token.tokens : [], sourceNodes, 0)
75
+ .children as SpanNode<AnyRichTextOptions>["children"],
76
+ });
77
+ } else if (token.type === "text") {
78
+ if ("tokens" in token && Array.isArray(token.tokens)) {
79
+ children.push(
80
+ ...parseTokens(token.tokens, sourceNodes, cursor, insideList).children
81
+ );
82
+ } else {
83
+ if (insideList && typeof token.raw === "string") {
84
+ const lines = token.raw.split("\n");
85
+ const tags: RichTextNode<AnyRichTextOptions>[] = lines.flatMap(
86
+ (line, i) => {
87
+ if (i === lines.length - 1) return [line];
88
+ return [line, { tag: "br", children: [] }];
89
+ }
90
+ );
91
+ children.push(...tags);
92
+ } else {
93
+ children.push(token.raw);
94
+ }
95
+ }
96
+ } else if (token.type === "list") {
97
+ children.push({
98
+ tag: token.ordered ? "ol" : "ul",
99
+ children: parseTokens(token.items, sourceNodes, 0)
100
+ .children as AnyListChildren,
101
+ });
102
+ } else if (token.type === "list_item") {
103
+ children.push({
104
+ tag: "li",
105
+ children: parseTokens(
106
+ token.tokens ? token.tokens : [],
107
+ sourceNodes,
108
+ 0,
109
+ true
110
+ ).children as ListItemNode<AnyRichTextOptions>["children"],
111
+ });
112
+ } else if (token.type === "space") {
113
+ // do nothing
114
+ } else if (token.type === "html") {
115
+ if (token.text === VAL_END_TAG) {
116
+ return { children, cursor };
117
+ }
118
+ const suffixIndex = token.text.indexOf(VAL_START_TAG_SUFFIX);
119
+ if (token.text.startsWith(VAL_START_TAG_PREFIX) && suffixIndex > -1) {
120
+ const number = Number(
121
+ token.text.slice(VAL_START_TAG_PREFIX.length, suffixIndex)
122
+ );
123
+ if (Number.isNaN(number)) {
124
+ throw Error(
125
+ `Illegal val intermediate node: ${JSON.stringify(token)}`
126
+ );
127
+ }
128
+ const { children: subChildren, cursor: subCursor } = parseTokens(
129
+ tokens.map((token) => {
130
+ if (token.type === "link" || token.type === "list") {
131
+ return {
132
+ type: "text",
133
+ raw: token.raw,
134
+ text: token.raw,
135
+ };
136
+ }
137
+ return token;
138
+ }),
139
+ sourceNodes,
140
+ cursor + 1
141
+ );
142
+ const sourceNode = sourceNodes[number];
143
+ if (sourceNode._type === "link") {
144
+ children.push({
145
+ tag: "a",
146
+ href: sourceNode.href,
147
+ children: subChildren as LinkNode<AnyRichTextOptions>["children"],
148
+ });
149
+ } else if (sourceNode._type === "file") {
150
+ children.push({
151
+ tag: "img",
152
+ src: Internal.convertFileSource(sourceNode).url,
153
+ width: sourceNode.metadata?.width,
154
+ height: sourceNode.metadata?.height,
155
+ children: [],
156
+ });
157
+ }
158
+
159
+ cursor = subCursor;
160
+ }
161
+ const br_html_regex = /<br\s*\/?>/gi; // matches <br>, <br/>, <br />; case insensitive
162
+ if (token.text.trim().match(br_html_regex)) {
163
+ children.push({
164
+ tag: "br",
165
+ children: [],
166
+ });
167
+ }
168
+ } else if (token.type === "link") {
169
+ if (token.raw === token.href) {
170
+ // avoid auto-linking (provided by github flavoured markdown, but we want strikethrough so keep it enabled)
171
+ children.push(token.raw);
172
+ } else {
173
+ children.push({
174
+ tag: "a",
175
+ href: token.href,
176
+ children: parseTokens(
177
+ token.tokens ? token.tokens : [],
178
+ sourceNodes,
179
+ 0
180
+ ).children as LinkNode<AnyRichTextOptions>["children"],
181
+ });
182
+ }
183
+ } else {
184
+ console.error(
185
+ `Could not parse markdown: unsupported token type: ${token.type}. Found: ${token.raw}`
186
+ );
187
+ }
188
+ cursor++;
189
+ }
190
+ return { children, cursor };
191
+ }
192
+
193
+ export function parseRichTextSource<O extends RichTextOptions>({
194
+ templateStrings,
195
+ exprs: nodes,
196
+ }: RichTextSource<O>): RichText<O> {
197
+ // TODO: validate that templateStrings does not contain VAL_NODE_PREFIX
198
+ const inputText = templateStrings
199
+ .flatMap((templateString, i) => {
200
+ const node = nodes[i];
201
+ if (node) {
202
+ if (node[VAL_EXTENSION] === "link") {
203
+ return templateString.concat(
204
+ `${VAL_START_TAG_PREFIX}${i}${VAL_START_TAG_SUFFIX}${node.children[0]}${VAL_END_TAG}`
205
+ );
206
+ } else {
207
+ return templateString.concat(
208
+ `${VAL_START_TAG_PREFIX}${i}${VAL_START_TAG_SUFFIX}${VAL_END_TAG}`
209
+ );
210
+ }
211
+ }
212
+ return templateString;
213
+ })
214
+ .join("");
215
+ const tokenList = marked.lexer(inputText, {
216
+ gfm: true,
217
+ });
218
+ const { children, cursor } = parseTokens(tokenList, nodes, 0);
219
+ if (cursor !== tokenList.length) {
220
+ throw Error(
221
+ "Unexpectedly terminated markdown parsing. Possible reason: unclosed html tag?"
222
+ );
223
+ }
224
+ return {
225
+ [VAL_EXTENSION]: "richtext",
226
+ children,
227
+ } as RichText<O>;
228
+ }