@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valbuild/ui",
3
- "version": "0.20.2",
3
+ "version": "0.21.0",
4
4
  "sideEffects": false,
5
5
  "scripts": {
6
6
  "typecheck": "tsc --noEmit",
@@ -9,6 +9,7 @@
9
9
  "build-storybook": "storybook build"
10
10
  },
11
11
  "dependencies": {
12
+ "@lexical/link": "^0.10.0",
12
13
  "@lexical/list": "^0.10.0",
13
14
  "@lexical/react": "^0.10.0",
14
15
  "@lexical/rich-text": "^0.10.0",
@@ -16,10 +17,11 @@
16
17
  "@lexical/utils": "^0.10.0",
17
18
  "@types/express": "^4.17.17",
18
19
  "@types/react": "^18.0.26",
19
- "@valbuild/core": "~0.20.2",
20
+ "@valbuild/core": "~0.21.0",
20
21
  "classnames": "^2.3.2",
21
22
  "esbuild": "^0.17.19",
22
23
  "lexical": "^0.10.0",
24
+ "marked": "^9.0.3",
23
25
  "react-feather": "^2.0.10",
24
26
  "react-resizable": "^3.0.5",
25
27
  "rollup-plugin-peer-deps-external": "^2.2.4",
@@ -8,8 +8,6 @@ import {
8
8
 
9
9
  export type ImagePayload = {
10
10
  src: string;
11
- sha256?: string;
12
- fileExt?: string;
13
11
  altText?: string;
14
12
  height?: number;
15
13
  width?: number;
@@ -32,11 +30,9 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
32
30
  static clone(node: ImageNode): ImageNode {
33
31
  return new ImageNode({
34
32
  src: node.__src,
35
- sha256: node.__sha256,
36
33
  altText: node.__altText,
37
34
  width: node.__width,
38
35
  height: node.__height,
39
- fileExt: node.__fileExt,
40
36
  });
41
37
  }
42
38
 
@@ -46,8 +42,6 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
46
42
  this.__altText = payload.altText;
47
43
  this.__width = payload.width;
48
44
  this.__height = payload.height;
49
- this.__imageFileExt = payload.fileExt;
50
- this.__sha256 = payload.sha256;
51
45
  }
52
46
 
53
47
  exportJSON(): SerializedImageNode {
@@ -57,9 +51,7 @@ export class ImageNode extends DecoratorNode<JSX.Element> {
57
51
  src: this.__src,
58
52
  type: "image",
59
53
  version: 1,
60
- fileExt: this.__imageFileExt,
61
54
  width: this.__width,
62
- sha256: this.__sha256,
63
55
  };
64
56
  }
65
57
 
@@ -0,0 +1,58 @@
1
+ import { $isLinkNode } from "@lexical/link";
2
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
3
+ import { $isAtNodeEnd } from "@lexical/selection";
4
+ import { $findMatchingParent, mergeRegister } from "@lexical/utils";
5
+ import {
6
+ $getSelection,
7
+ $isRangeSelection,
8
+ CLICK_COMMAND,
9
+ COMMAND_PRIORITY_LOW,
10
+ ElementNode,
11
+ RangeSelection,
12
+ TextNode,
13
+ } from "lexical";
14
+ import { useEffect } from "react";
15
+
16
+ export function getSelectedNode(
17
+ selection: RangeSelection
18
+ ): TextNode | ElementNode {
19
+ const anchor = selection.anchor;
20
+ const focus = selection.focus;
21
+ const anchorNode = selection.anchor.getNode();
22
+ const focusNode = selection.focus.getNode();
23
+ if (anchorNode === focusNode) {
24
+ return anchorNode;
25
+ }
26
+ const isBackward = selection.isBackward();
27
+ if (isBackward) {
28
+ return $isAtNodeEnd(focus) ? anchorNode : focusNode;
29
+ } else {
30
+ return $isAtNodeEnd(anchor) ? anchorNode : focusNode;
31
+ }
32
+ }
33
+
34
+ export default function LinkEditorPlugin() {
35
+ const [editor] = useLexicalComposerContext();
36
+
37
+ useEffect(() => {
38
+ return mergeRegister(
39
+ editor.registerCommand(
40
+ CLICK_COMMAND,
41
+ (payload) => {
42
+ const selection = $getSelection();
43
+ if ($isRangeSelection(selection)) {
44
+ const node = getSelectedNode(selection);
45
+ const linkNode = $findMatchingParent(node, $isLinkNode);
46
+ if ($isLinkNode(linkNode) && (payload.metaKey || payload.ctrlKey)) {
47
+ window.open(linkNode.getURL(), "_blank");
48
+ return true;
49
+ }
50
+ }
51
+ return false;
52
+ },
53
+ COMMAND_PRIORITY_LOW
54
+ )
55
+ );
56
+ }, []);
57
+ return null;
58
+ }
@@ -53,6 +53,9 @@ import Dropdown from "../../Dropdown";
53
53
  import UploadModal from "../../UploadModal";
54
54
  import { INSERT_IMAGE_COMMAND } from "./ImagePlugin";
55
55
  import { readImage } from "../../../utils/readImage";
56
+ import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
57
+ import { getSelectedNode } from "./LinkEditorPlugin";
58
+ import { Check, Link, X } from "react-feather";
56
59
 
57
60
  export interface ToolbarSettingsProps {
58
61
  fontsFamilies?: string[];
@@ -91,7 +94,12 @@ const Toolbar: FC<ToolbarSettingsProps> = ({
91
94
  const [inputUrl, setInputUrl] = useState<boolean>(false);
92
95
  const [uploadMode, setUploadMode] = useState<"url" | "file">("url");
93
96
  const [file, setFile] = useState<File | null>(null);
94
- const [url, setUrl] = useState<string>("");
97
+
98
+ const [url, setUrl] = useState<string | null>(null);
99
+
100
+ const dispatchLinkChange = (url: string | null) => {
101
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
102
+ };
95
103
 
96
104
  const blockTypes: { [key: string]: string } = {
97
105
  paragraph: "Normal",
@@ -126,6 +134,20 @@ const Toolbar: FC<ToolbarSettingsProps> = ({
126
134
  element = anchorNode.getTopLevelElementOrThrow();
127
135
  }
128
136
 
137
+ // LINK STUFF
138
+ const node = getSelectedNode(selection);
139
+ const linkParent = $findMatchingParent(
140
+ node,
141
+ $isLinkNode
142
+ ) as LinkNode | null;
143
+ if (linkParent !== null) {
144
+ const href = linkParent.getURL();
145
+ setUrl(href);
146
+ } else {
147
+ setUrl(null);
148
+ }
149
+ // ====================
150
+
129
151
  const elementKey = element.getKey();
130
152
  const elementDOM = activeEditor.getElementByKey(elementKey);
131
153
  setIsBold(selection.hasFormat("bold"));
@@ -278,7 +300,7 @@ const Toolbar: FC<ToolbarSettingsProps> = ({
278
300
  };
279
301
 
280
302
  return (
281
- <div className="sticky top-0 border-b bg-base border-highlight">
303
+ <div className="sticky top-0 border-b bg-base border-highlight flex flex-col">
282
304
  <div className="flex flex-row gap-1">
283
305
  <Dropdown
284
306
  options={Object.values(blockTypes)}
@@ -316,6 +338,21 @@ const Toolbar: FC<ToolbarSettingsProps> = ({
316
338
  }}
317
339
  icon={<Italic className={`${isItalic && "stroke-[3px]"}`} />}
318
340
  />
341
+ <Button
342
+ active={url !== null}
343
+ onClick={(ev) => {
344
+ ev.preventDefault();
345
+ setUrl("");
346
+ dispatchLinkChange("");
347
+ }}
348
+ icon={
349
+ <Link
350
+ width={12}
351
+ height={12}
352
+ className={`${url !== null && "stroke-[3px]"}`}
353
+ />
354
+ }
355
+ />
319
356
  <label className="flex items-center justify-center">
320
357
  <ImageIcon />
321
358
  <input
@@ -337,6 +374,37 @@ const Toolbar: FC<ToolbarSettingsProps> = ({
337
374
  />
338
375
  </label>
339
376
  </div>
377
+ {url !== null && (
378
+ <div className="flex flex-row p-2">
379
+ <input
380
+ type="text"
381
+ placeholder="Enter URL"
382
+ className="w-1/3 text-primary bg-base px-2"
383
+ value={url}
384
+ onChange={(ev) => {
385
+ ev.preventDefault();
386
+ setUrl(ev.target.value);
387
+ }}
388
+ ></input>
389
+ <Button
390
+ variant="primary"
391
+ onClick={(ev) => {
392
+ ev.preventDefault();
393
+ // empty url will remove link
394
+ dispatchLinkChange(url || null);
395
+ }}
396
+ icon={<Check size={14} />}
397
+ />
398
+ <Button
399
+ variant="primary"
400
+ onClick={(ev) => {
401
+ ev.preventDefault();
402
+ dispatchLinkChange(null);
403
+ }}
404
+ icon={<X size={14} />}
405
+ />
406
+ </div>
407
+ )}
340
408
  </div>
341
409
  );
342
410
  };
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
4
  import { LexicalEditor } from "lexical";
3
5
  import { ListItemNode, ListNode } from "@lexical/list";
@@ -6,6 +8,7 @@ import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
6
8
  import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
7
9
  import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
8
10
  import { ListPlugin } from "@lexical/react/LexicalListPlugin";
11
+ import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
9
12
  import { FC } from "react";
10
13
  import LexicalContentEditable from "./ContentEditable";
11
14
  import { ImageNode } from "./Nodes/ImageNode";
@@ -14,8 +17,11 @@ import ImagesPlugin from "./Plugins/ImagePlugin";
14
17
  import Toolbar from "./Plugins/Toolbar";
15
18
  import { AnyRichTextOptions, RichTextSource } from "@valbuild/core";
16
19
  import { HeadingNode } from "@lexical/rich-text";
17
- import { toLexical } from "./conversion";
20
+ import { richTextSourceToLexical } from "../../richtext/conversion/richTextSourceToLexical";
18
21
  import { useValOverlayContext } from "../ValOverlayContext";
22
+ import { parseRichTextSource } from "../../exports";
23
+ import { LinkNode } from "@lexical/link";
24
+ import LinkEditorPlugin from "./Plugins/LinkEditorPlugin";
19
25
 
20
26
  export interface RichTextEditorProps {
21
27
  richtext: RichTextSource<AnyRichTextOptions>;
@@ -35,13 +41,15 @@ export const RichTextEditor: FC<RichTextEditorProps> = ({
35
41
  const { windowSize } = useValOverlayContext();
36
42
  const prePopulatedState = (editor: LexicalEditor) => {
37
43
  editor.setEditorState(
38
- editor.parseEditorState({ root: toLexical(richtext) })
44
+ editor.parseEditorState({
45
+ root: richTextSourceToLexical(parseRichTextSource(richtext)),
46
+ })
39
47
  );
40
48
  };
41
49
  const initialConfig = {
42
50
  namespace: "val",
43
51
  editorState: prePopulatedState,
44
- nodes: [HeadingNode, ImageNode, ListNode, ListItemNode],
52
+ nodes: [HeadingNode, ImageNode, ListNode, ListItemNode, LinkNode],
45
53
  theme: {
46
54
  text: {
47
55
  bold: "font-semibold",
@@ -61,8 +69,9 @@ export const RichTextEditor: FC<RichTextEditorProps> = ({
61
69
  h3: "text-2xl font-bold",
62
70
  h4: "text-xl font-bold",
63
71
  h5: "text-lg font-bold",
64
- h6: "text-base font-bold",
72
+ h6: "text-md font-bold",
65
73
  },
74
+ link: "text-highlight underline",
66
75
  },
67
76
  onError,
68
77
  };
@@ -86,6 +95,8 @@ export const RichTextEditor: FC<RichTextEditorProps> = ({
86
95
  placeholder={<div className="">Enter some text...</div>}
87
96
  ErrorBoundary={LexicalErrorBoundary}
88
97
  />
98
+ <LinkPlugin />
99
+ <LinkEditorPlugin />
89
100
  <ListPlugin />
90
101
  <ImagesPlugin />
91
102
  <HistoryPlugin />
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  Dispatch,
3
5
  SetStateAction,
@@ -21,7 +23,6 @@ import {
21
23
  AnyRichTextOptions,
22
24
  FileSource,
23
25
  Internal,
24
- RichText,
25
26
  RichTextSource,
26
27
  SerializedSchema,
27
28
  SourcePath,
@@ -31,9 +32,10 @@ import { Modules, resolvePath } from "../utils/resolvePath";
31
32
  import { ValApi } from "@valbuild/core";
32
33
  import { RichTextEditor } from "../exports";
33
34
  import { LexicalEditor } from "lexical";
34
- import { LexicalRootNode, fromLexical } from "./RichTextEditor/conversion";
35
+ import { LexicalRootNode } from "../richtext/conversion/richTextSourceToLexical";
35
36
  import { PatchJSON } from "@valbuild/core/patch";
36
37
  import { readImage } from "../utils/readImage";
38
+ import { lexicalToRichTextSource } from "../richtext/conversion/lexicalToRichTextSource";
37
39
 
38
40
  export type ValOverlayProps = {
39
41
  defaultTheme?: "dark" | "light";
@@ -60,7 +62,7 @@ export function ValOverlay({ defaultTheme, api }: ValOverlayProps) {
60
62
  );
61
63
 
62
64
  const [state, setState] = useState<{
63
- [path: SourcePath]: () => PatchJSON;
65
+ [path: SourcePath]: () => Promise<PatchJSON>;
64
66
  }>({});
65
67
  const initPatchCallback = useCallback((currentPath: SourcePath | null) => {
66
68
  return (callback: PatchCallback) => {
@@ -169,16 +171,22 @@ export function ValOverlay({ defaultTheme, api }: ValOverlayProps) {
169
171
  const [moduleId] = Internal.splitModuleIdAndModulePath(
170
172
  windowTarget.path
171
173
  );
172
- const patch = state[windowTarget.path]();
173
- console.log("Submitting", patch);
174
- api
175
- .postPatches(moduleId, patch)
176
- .then((res) => {
177
- console.log(res);
178
- })
179
- .finally(() => {
180
- console.log("done");
181
- });
174
+ const res = state[windowTarget.path]();
175
+ if (res) {
176
+ res
177
+ .then((patch) => {
178
+ console.log("Submitting", patch);
179
+ return api.postPatches(moduleId, patch);
180
+ })
181
+ .then((res) => {
182
+ console.log(res);
183
+ })
184
+ .finally(() => {
185
+ console.log("done");
186
+ });
187
+ } else {
188
+ console.error("No patch");
189
+ }
182
190
  }
183
191
  }}
184
192
  />
@@ -190,7 +198,7 @@ export function ValOverlay({ defaultTheme, api }: ValOverlayProps) {
190
198
  );
191
199
  }
192
200
 
193
- type PatchCallback = (modulePath: string) => PatchJSON;
201
+ type PatchCallback = (modulePath: string) => Promise<PatchJSON>;
194
202
 
195
203
  function ImageField({
196
204
  defaultValue,
@@ -207,7 +215,7 @@ function ImageField({
207
215
  } | null>(null);
208
216
  const url = defaultValue && Internal.convertFileSource(defaultValue).url;
209
217
  useEffect(() => {
210
- registerPatchCallback((path) => {
218
+ registerPatchCallback(async (path) => {
211
219
  const pathParts = path.split("/");
212
220
  if (!data) {
213
221
  return [];
@@ -273,22 +281,26 @@ function RichTextField({
273
281
  const [editor, setEditor] = useState<LexicalEditor | null>(null);
274
282
  useEffect(() => {
275
283
  if (editor) {
276
- registerPatchCallback((path) => {
277
- const { node, files } = editor?.toJSON()?.editorState
278
- ? fromLexical(editor?.toJSON()?.editorState.root as LexicalRootNode)
279
- : {
280
- node: {
281
- [VAL_EXTENSION]: "richtext",
282
- children: [],
283
- } as RichText<AnyRichTextOptions>,
284
+ registerPatchCallback(async (path) => {
285
+ const { templateStrings, exprs, files } = editor?.toJSON()?.editorState
286
+ ? await lexicalToRichTextSource(
287
+ editor?.toJSON()?.editorState.root as LexicalRootNode
288
+ )
289
+ : ({
290
+ [VAL_EXTENSION]: "richtext",
291
+ templateStrings: [""],
292
+ exprs: [],
284
293
  files: {},
285
- };
294
+ } as RichTextSource<AnyRichTextOptions> & {
295
+ files: Record<string, string>;
296
+ });
286
297
  return [
287
298
  {
288
- op: "replace",
299
+ op: "replace" as const,
289
300
  path,
290
301
  value: {
291
- ...node,
302
+ templateStrings,
303
+ exprs,
292
304
  [VAL_EXTENSION]: "richtext",
293
305
  },
294
306
  },
@@ -334,7 +346,7 @@ function TextField({
334
346
  // to avoid registering a new callback every time the value changes
335
347
  const ref = useRef<HTMLTextAreaElement>(null);
336
348
  useEffect(() => {
337
- registerPatchCallback((path) => {
349
+ registerPatchCallback(async (path) => {
338
350
  return [
339
351
  {
340
352
  op: "replace",
@@ -1,8 +1,4 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import {
3
- AnyRichTextOptions,
4
- RichTextSource as RichTextSourceType,
5
- } from "@valbuild/core";
6
2
  import { RichTextEditor } from "../exports";
7
3
  import { FormContainer } from "./forms/FormContainer";
8
4
  import { ImageForm } from "./forms/ImageForm";
@@ -78,56 +74,11 @@ export const RichText: Story = {
78
74
  }}
79
75
  >
80
76
  <RichTextEditor
81
- richtext={
82
- {
83
- _type: "richtext",
84
- children: [
85
- { tag: "h1", children: ["Title 1"] },
86
- { tag: "h2", children: ["Title 2"] },
87
- { tag: "h3", children: ["Title 3"] },
88
- { tag: "h4", children: ["Title 4"] },
89
- { tag: "h5", children: ["Title 5"] },
90
- { tag: "h6", children: ["Title 6"] },
91
- {
92
- tag: "p",
93
- children: [
94
- {
95
- tag: "span",
96
- classes: ["bold", "italic", "line-through"],
97
- children: ["Formatted span"],
98
- },
99
- ],
100
- },
101
- {
102
- tag: "ul",
103
- children: [
104
- {
105
- tag: "li",
106
- children: [
107
- {
108
- tag: "ol",
109
- dir: "rtl",
110
- children: [
111
- {
112
- tag: "li",
113
- children: [
114
- {
115
- tag: "span",
116
- classes: ["italic"],
117
- children: ["number 1.1"],
118
- },
119
- ],
120
- },
121
- { tag: "li", children: ["number 1.2"] },
122
- ],
123
- },
124
- ],
125
- },
126
- ],
127
- },
128
- ],
129
- } as RichTextSourceType<AnyRichTextOptions>
130
- }
77
+ richtext={{
78
+ _type: "richtext",
79
+ templateStrings: ["# Title 1"],
80
+ exprs: [],
81
+ }}
131
82
  />
132
83
  </FormContainer>
133
84
  ),
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { SerializedModule } from "@valbuild/core";
2
4
  import { Json } from "@valbuild/core/src/Json";
3
5
  import { ValApi } from "@valbuild/core";
package/src/exports.ts CHANGED
@@ -2,3 +2,4 @@ export { RichTextEditor } from "./components/RichTextEditor/RichTextEditor";
2
2
  export { ValOverlay } from "./components/ValOverlay";
3
3
  export { type Inputs } from "./components/forms/Form";
4
4
  export { ValDashboard } from "./components/dashboard/ValDashboard";
5
+ export { parseRichTextSource } from "./richtext/conversion/parseRichTextSource";
@@ -0,0 +1,146 @@
1
+ import {
2
+ initVal,
3
+ RichTextSource,
4
+ AnyRichTextOptions,
5
+ FILE_REF_PROP,
6
+ } from "@valbuild/core";
7
+ import { richTextSourceToLexical } from "./richTextSourceToLexical";
8
+ import { lexicalToRichTextSource } from "./lexicalToRichTextSource";
9
+ import { parseRichTextSource } from "./parseRichTextSource";
10
+
11
+ const { val } = initVal();
12
+ const cases: {
13
+ description: string;
14
+ input: RichTextSource<AnyRichTextOptions>;
15
+ }[] = [
16
+ {
17
+ description: "basic",
18
+ input: val.richtext`
19
+ # Title 1
20
+
21
+ ## Title 2
22
+
23
+ ### Title 3
24
+
25
+ #### Title 4
26
+
27
+ ##### Title 5
28
+
29
+ ###### Title 6
30
+
31
+ Some paragraph. Another sentence.
32
+
33
+ Another paragraph.
34
+
35
+ Formatting: **bold**, _italic_, ~~line-through~~, ***bold and italic***.
36
+
37
+ - List 1
38
+ 1. List 1.1
39
+ 1. List 1.2
40
+ `,
41
+ },
42
+ {
43
+ description: "all features",
44
+ input: val.richtext`
45
+ # Title 1
46
+
47
+ Title 1 content.
48
+
49
+ ## Title 2
50
+
51
+ Title 2 content.
52
+
53
+ ### Title 3
54
+
55
+ Title 3 content.
56
+
57
+ #### Title 4
58
+
59
+ Title 4 content.
60
+
61
+ ##### Title 5
62
+
63
+ ###### Title 6
64
+
65
+ Some paragraph. Another sentence.
66
+
67
+ Another paragraph.
68
+
69
+ Formatting: **bold**, _italic_, ~~line-through~~, ***bold and italic***.
70
+
71
+ - List 1
72
+ 1. List 1.1
73
+ 1. List 1.2
74
+
75
+ Inline link: ${val.link("**link**", { href: "https://link.com" })}
76
+
77
+ <br />
78
+
79
+ Block link:
80
+
81
+ ${val.link("**link**", { href: "https://link.com" })}
82
+
83
+ <br />
84
+
85
+ Block Image:
86
+
87
+ ${val.file("/public/test.jpg", {
88
+ width: 100,
89
+ height: 100,
90
+ sha256: "123",
91
+ })}
92
+
93
+ <br />
94
+
95
+ <br />
96
+
97
+ - List 1
98
+ 1. List 1.1
99
+ 1. List 1.2
100
+ - List 2
101
+ - List 3
102
+ 1. Formatted **list**
103
+ Test 123
104
+ `,
105
+ },
106
+ ];
107
+
108
+ describe("isomorphic richtext <-> conversion", () => {
109
+ test.each(cases)("$description", async ({ input }) => {
110
+ const inputSource = input;
111
+
112
+ const res = await lexicalToRichTextSource(
113
+ richTextSourceToLexical(parseRichTextSource(inputSource))
114
+ );
115
+
116
+ const output = stringifyRichTextSource(res);
117
+ // console.log("EOF>>" + output + "<<EOF");
118
+ expect(stringifyRichTextSource(inputSource)).toStrictEqual(output);
119
+ });
120
+ });
121
+
122
+ function stringifyRichTextSource({
123
+ templateStrings,
124
+ exprs,
125
+ }: RichTextSource<AnyRichTextOptions>): string {
126
+ let lines = "";
127
+ for (let i = 0; i < templateStrings.length; i++) {
128
+ const line = templateStrings[i];
129
+ const expr = exprs[i];
130
+ lines += line;
131
+ if (expr) {
132
+ if (expr._type === "file") {
133
+ lines += `\${val.file("${expr[FILE_REF_PROP]}", ${JSON.stringify(
134
+ expr.metadata
135
+ )})}`;
136
+ } else if (expr._type === "link") {
137
+ lines += `\${val.link("${expr.children[0]}", ${JSON.stringify({
138
+ href: expr.href,
139
+ })})}`;
140
+ } else {
141
+ throw Error("Unknown expr: " + JSON.stringify(expr, null, 2));
142
+ }
143
+ }
144
+ }
145
+ return lines;
146
+ }