@valbuild/react 0.12.0 → 0.13.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/jest.config.js ADDED
@@ -0,0 +1,5 @@
1
+ /** @type {import("jest").Config} */
2
+ module.exports = {
3
+ preset: "../../jest.preset",
4
+ testEnvironment: "./jest-environment.mjs",
5
+ };
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@valbuild/react",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "sideEffects": false,
5
5
  "scripts": {
6
6
  "typecheck": "tsc --noEmit",
7
7
  "test": "jest"
8
8
  },
9
9
  "dependencies": {
10
- "@valbuild/core": "~0.12.0",
11
- "@valbuild/ui": "~0.12.0",
10
+ "@valbuild/core": "~0.13.0",
11
+ "@valbuild/ui": "~0.13.0",
12
12
  "base64-arraybuffer": "^1.0.2",
13
13
  "react-shadow": "^20.0.0",
14
14
  "style-to-object": "^0.4.1"
package/src/ValApi.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { SerializedModule } from "@valbuild/core";
2
+ import { PatchJSON } from "@valbuild/core/patch";
3
+
4
+ export class ValApi {
5
+ constructor(readonly host: string) {}
6
+
7
+ async getModule(sourcePath: string): Promise<SerializedModule> {
8
+ const res = await fetch(`${this.host}/ids${sourcePath}`);
9
+ if (res.ok) {
10
+ const serializedVal = await res.json(); // TODO: validate
11
+ return serializedVal;
12
+ } else {
13
+ throw Error(
14
+ `Failed to get content of module "${sourcePath}". Status: ${
15
+ res.status
16
+ }. Error: ${await res.text()}`
17
+ );
18
+ }
19
+ }
20
+
21
+ async patchModuleContent(
22
+ moduleId: string,
23
+ patch: PatchJSON
24
+ ): Promise<SerializedModule> {
25
+ const res = await fetch(`${this.host}/ids${moduleId}`, {
26
+ method: "PATCH",
27
+ headers: {
28
+ "Content-Type": "application/json-patch+json",
29
+ },
30
+ body: JSON.stringify(patch),
31
+ });
32
+ if (res.ok) {
33
+ return res.json(); // TODO: validate
34
+ } else {
35
+ throw Error(
36
+ `Failed to patch content of module "${moduleId}". Error: ${await res.text()}`
37
+ );
38
+ }
39
+ }
40
+
41
+ async commit(): Promise<void> {
42
+ const res = await fetch(`${this.host}/commit`, {
43
+ method: "POST",
44
+ });
45
+ if (res.ok) {
46
+ return;
47
+ } else {
48
+ throw Error(`Failed to commit. Error: ${await res.text()}`);
49
+ }
50
+ }
51
+
52
+ getSession() {
53
+ return fetch(`${this.host}/session`);
54
+ }
55
+
56
+ loginUrl() {
57
+ return `${this.host}/authorize?redirect_to=${encodeURIComponent(
58
+ location.href
59
+ )}`;
60
+ }
61
+
62
+ logout() {
63
+ return fetch(`${this.host}/logout`);
64
+ }
65
+ }
@@ -0,0 +1,424 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import React, { useContext, useEffect, useMemo, useState } from "react";
3
+ import { ValApi } from "./ValApi";
4
+ import { ValStore } from "./ValStore";
5
+ import { Inputs, Style, ValOverlay } from "@valbuild/ui";
6
+ import root from "react-shadow"; // TODO: remove dependency on react-shadow here?
7
+ import {
8
+ FileSource,
9
+ FILE_REF_PROP,
10
+ Internal,
11
+ RichText,
12
+ SourcePath,
13
+ VAL_EXTENSION,
14
+ } from "@valbuild/core";
15
+ import { PatchJSON } from "@valbuild/core/patch";
16
+ import { ImageMetadata } from "@valbuild/core/src/schema/image";
17
+
18
+ export function useValStore() {
19
+ return useContext(ValContext).valStore;
20
+ }
21
+ export function useValApi() {
22
+ return useContext(ValContext).valApi;
23
+ }
24
+
25
+ export type ValContext = {
26
+ readonly valStore: ValStore;
27
+ readonly valApi: ValApi;
28
+ };
29
+
30
+ export const ValContext = React.createContext<ValContext>({
31
+ get valStore(): never {
32
+ throw Error(
33
+ "Val context not found. Ensure components are wrapped by ValProvider!"
34
+ );
35
+ },
36
+ get valApi(): never {
37
+ throw Error(
38
+ "Val context not found. Ensure components are wrapped by ValProvider!"
39
+ );
40
+ },
41
+ });
42
+
43
+ export type ValProviderProps = {
44
+ host?: string;
45
+ children?: React.ReactNode;
46
+ };
47
+
48
+ type AuthStatus =
49
+ | {
50
+ status:
51
+ | "not-asked"
52
+ | "authenticated"
53
+ | "unauthenticated"
54
+ | "loading"
55
+ | "local";
56
+ }
57
+ | {
58
+ status: "error";
59
+ message: string;
60
+ };
61
+
62
+ export function ValProvider({ host = "/api/val", children }: ValProviderProps) {
63
+ const [selectedSources, setSelectedSources] = useState<string[]>([]);
64
+ const [editMode, setEditMode] = useState(false);
65
+ const [editFormPosition, setEditFormPosition] = useState<{
66
+ left: number;
67
+ top: number;
68
+ } | null>(null);
69
+
70
+ const [authentication, setAuthentication] = useState<AuthStatus>({
71
+ status: "not-asked",
72
+ });
73
+ const valApi = useMemo(() => new ValApi(host), [host]);
74
+ const valStore = useMemo(() => new ValStore(valApi), [valApi]);
75
+
76
+ useEffect(() => {
77
+ if (editMode) {
78
+ valStore.updateAll();
79
+ }
80
+ }, [editMode]);
81
+ useEffect(() => {
82
+ let openValFormListener: ((e: MouseEvent) => void) | undefined = undefined;
83
+ let styleElement: HTMLStyleElement | undefined = undefined;
84
+ const editButtonClickOptions = {
85
+ capture: true,
86
+ passive: true,
87
+ };
88
+ if (editMode) {
89
+ // highlight val element by appending a new style
90
+ styleElement = document.createElement("style");
91
+ styleElement.id = "val-edit-highlight";
92
+ styleElement.innerHTML = `
93
+ .val-edit-mode >* [data-val-path] {
94
+ outline: black solid 2px;
95
+ outline-offset: 4px;
96
+ cursor: pointer;
97
+ }
98
+ `;
99
+ document.body.appendChild(styleElement);
100
+
101
+ // capture event clicks on data-val-path elements
102
+ openValFormListener = (e: MouseEvent) => {
103
+ if (e.target instanceof Element) {
104
+ let parent = e.target;
105
+ while (parent && parent !== document.body) {
106
+ if (parent.getAttribute("data-val-path")) {
107
+ break;
108
+ }
109
+ if (parent.parentElement) {
110
+ parent = parent.parentElement;
111
+ } else {
112
+ break;
113
+ }
114
+ }
115
+ const valSources = parent?.getAttribute("data-val-path");
116
+ if (valSources) {
117
+ e.stopPropagation();
118
+ setSelectedSources(
119
+ valSources.split(
120
+ ","
121
+ ) /* TODO: just split on commas will not work if path contains , */
122
+ );
123
+ setEditFormPosition({
124
+ left: e.pageX,
125
+ top: e.pageY,
126
+ });
127
+ // } else if (!isValElement(e.target)) {
128
+ // console.log("click outside", e.target);
129
+ // setEditFormPosition(null);
130
+ // setSelectedSources([]);
131
+ }
132
+ }
133
+ };
134
+ document.addEventListener(
135
+ "click",
136
+ openValFormListener,
137
+ editButtonClickOptions
138
+ );
139
+ }
140
+ return () => {
141
+ if (openValFormListener) {
142
+ document.removeEventListener(
143
+ "click",
144
+ openValFormListener,
145
+ editButtonClickOptions
146
+ );
147
+ }
148
+ styleElement?.remove();
149
+ };
150
+ }, [editMode]);
151
+
152
+ // useEffect(() => {
153
+ // const requestAuth = !(
154
+ // authentication.status === "authenticated" ||
155
+ // authentication.status === "local"
156
+ // );
157
+ // if (requestAuth) {
158
+ // setSelectedSources([]);
159
+ // console.log("request auth");
160
+ // setEditFormPosition(null);
161
+ // }
162
+ // if (!editMode) {
163
+ // // reset state when disabled
164
+ // setSelectedSources([]);
165
+ // console.log("reset state");
166
+ // setEditFormPosition(null);
167
+ // }
168
+ // }, [editMode, selectedSources.length, authentication.status]);
169
+
170
+ useEffect(() => {
171
+ if (editMode) {
172
+ document.body.classList.add("val-edit-mode");
173
+ } else {
174
+ document.body.classList.remove("val-edit-mode");
175
+ }
176
+
177
+ if (editMode) {
178
+ if (authentication.status !== "authenticated") {
179
+ valApi
180
+ .getSession()
181
+ .then(async (res) => {
182
+ if (res.status === 401) {
183
+ setAuthentication({
184
+ status: "unauthenticated",
185
+ });
186
+ } else if (res.ok) {
187
+ const data = await res.json();
188
+ if (data.mode === "local") {
189
+ setAuthentication({ status: "local" });
190
+ } else if (data.mode === "proxy") {
191
+ setAuthentication({
192
+ status: "authenticated",
193
+ });
194
+ } else {
195
+ setAuthentication({
196
+ status: "error",
197
+ message: "Unknown authentication mode",
198
+ });
199
+ }
200
+ } else {
201
+ let message = "Unknown error";
202
+ try {
203
+ message = await res.text();
204
+ } catch {
205
+ // ignore
206
+ }
207
+ setAuthentication({
208
+ status: "error",
209
+ message,
210
+ });
211
+ }
212
+ })
213
+ .catch((err) => {
214
+ console.error("Failed to fetch session", err);
215
+ setAuthentication({
216
+ status: "error",
217
+ message: "Unknown authentication mode",
218
+ });
219
+ });
220
+ }
221
+ } else {
222
+ if (authentication.status === "error") {
223
+ setAuthentication({
224
+ status: "not-asked",
225
+ });
226
+ }
227
+ }
228
+ }, [editMode, authentication.status]);
229
+
230
+ const [showEditButton, setShowEditButton] = useState(false);
231
+ useEffect(() => {
232
+ setShowEditButton(true);
233
+ }, []);
234
+
235
+ const [inputs, setInputs] = useState<Inputs>({});
236
+
237
+ useEffect(() => {
238
+ setInputs({});
239
+ for (const path of selectedSources) {
240
+ valApi.getModule(path).then((serializedModule) => {
241
+ let input: Inputs[string] | undefined;
242
+ if (
243
+ serializedModule.schema.type === "string" &&
244
+ typeof serializedModule.source === "string"
245
+ ) {
246
+ input = {
247
+ status: "completed",
248
+ type: "text",
249
+ data: serializedModule.source,
250
+ };
251
+ } else if (
252
+ serializedModule.schema.type === "richtext" &&
253
+ typeof serializedModule.source === "object"
254
+ ) {
255
+ input = {
256
+ status: "completed",
257
+ type: "richtext",
258
+ data: serializedModule.source as RichText, // TODO: validate
259
+ };
260
+ } else if (
261
+ serializedModule.schema.type === "image" &&
262
+ serializedModule.source &&
263
+ typeof serializedModule.source === "object" &&
264
+ FILE_REF_PROP in serializedModule.source &&
265
+ typeof serializedModule.source[FILE_REF_PROP] === "string" &&
266
+ VAL_EXTENSION in serializedModule.source &&
267
+ typeof serializedModule.source[VAL_EXTENSION] === "string"
268
+ ) {
269
+ input = {
270
+ status: "completed",
271
+ type: "image",
272
+ data: Internal.convertImageSource(
273
+ serializedModule.source as FileSource<ImageMetadata>
274
+ ),
275
+ };
276
+ }
277
+ console.log("input path", path);
278
+ console.log("serialized path", serializedModule.path);
279
+ if (!input) {
280
+ throw new Error(
281
+ `Unsupported module type: ${serializedModule.schema.type}`
282
+ );
283
+ }
284
+ setInputs((inputs) => {
285
+ return {
286
+ ...inputs,
287
+ [serializedModule.path]: input,
288
+ } as Inputs;
289
+ });
290
+ });
291
+ }
292
+ }, [selectedSources.join(",")]);
293
+ return (
294
+ <ValContext.Provider
295
+ value={{
296
+ valApi,
297
+ valStore,
298
+ }}
299
+ >
300
+ {children}
301
+ {showEditButton && (
302
+ <root.div>
303
+ {/* TODO: */}
304
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
305
+ <link
306
+ rel="preconnect"
307
+ href="https://fonts.gstatic.com"
308
+ crossOrigin="anonymous"
309
+ />
310
+ <link
311
+ href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,400;1,700&display=swap"
312
+ rel="stylesheet"
313
+ />
314
+ <Style />
315
+ <div data-mode="dark">
316
+ <ValOverlay
317
+ editMode={editMode}
318
+ setEditMode={setEditMode}
319
+ closeValWindow={() => {
320
+ setEditFormPosition(null);
321
+ setSelectedSources([]);
322
+ setInputs({});
323
+ }}
324
+ valWindow={
325
+ (editFormPosition && {
326
+ position: editFormPosition,
327
+ inputs,
328
+ onSubmit: (inputs) => {
329
+ Promise.all(
330
+ Object.entries(inputs).map(([path, input]) => {
331
+ if (input.status === "completed") {
332
+ const [moduleId, modulePath] =
333
+ Internal.splitModuleIdAndModulePath(
334
+ path as SourcePath
335
+ );
336
+ if (input.type === "text") {
337
+ const patch: PatchJSON = [
338
+ {
339
+ value: input.data,
340
+ op: "replace",
341
+ path: `/${modulePath
342
+ .split(".")
343
+ .map((p) => JSON.parse(p))
344
+ .join("/")}`,
345
+ },
346
+ ];
347
+ return valApi.patchModuleContent(moduleId, patch);
348
+ } else if (input.type === "image") {
349
+ const pathParts = modulePath
350
+ .split(".")
351
+ .map((p) => JSON.parse(p));
352
+
353
+ if (!input?.data || !("src" in input.data)) {
354
+ // TODO: We probably need to have an Output type that is different from the Input: we have a union of both cases in Input right now, and we believe we do not want that
355
+ console.warn(
356
+ "No .src on input provided - this might mean no changes was made"
357
+ );
358
+ return;
359
+ }
360
+ const patch: PatchJSON = [
361
+ {
362
+ value: input.data.src,
363
+ op: "replace",
364
+ path: `/${pathParts.slice(0, -1).join("/")}/$${
365
+ pathParts[pathParts.length - 1]
366
+ }`,
367
+ },
368
+ ];
369
+ if (input.data.metadata) {
370
+ if (input.data.addMetadata) {
371
+ patch.push({
372
+ value: input.data.metadata,
373
+ op: "add",
374
+ path: `/${pathParts.join("/")}/metadata`,
375
+ });
376
+ } else {
377
+ patch.push({
378
+ value: input.data.metadata,
379
+ op: "replace",
380
+ path: `/${pathParts.join("/")}/metadata`,
381
+ });
382
+ }
383
+ }
384
+ console.log("patch", patch);
385
+ return valApi.patchModuleContent(moduleId, patch);
386
+ } else if (input.type === "richtext") {
387
+ const patch: PatchJSON = [
388
+ {
389
+ value: input.data,
390
+ op: "replace",
391
+ path: `/${modulePath
392
+ .split(".")
393
+ .map((p) => JSON.parse(p))
394
+ .join("/")}`,
395
+ },
396
+ ];
397
+ return valApi.patchModuleContent(moduleId, patch);
398
+ }
399
+ throw new Error(
400
+ `Unsupported input type: ${(input as any).type}`
401
+ );
402
+ } else {
403
+ console.error(
404
+ "Submitted incomplete input, ignoring..."
405
+ );
406
+ return Promise.resolve();
407
+ }
408
+ })
409
+ ).then(() => {
410
+ setEditFormPosition(null);
411
+ setSelectedSources([]);
412
+ setInputs({});
413
+ });
414
+ },
415
+ }) ??
416
+ undefined
417
+ }
418
+ />
419
+ </div>
420
+ </root.div>
421
+ )}
422
+ </ValContext.Provider>
423
+ );
424
+ }
@@ -0,0 +1,141 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import {
3
+ HeadingNode,
4
+ ListItemNode,
5
+ ListNode,
6
+ ParagraphNode,
7
+ RichText,
8
+ TextNode,
9
+ Val,
10
+ } from "@valbuild/core";
11
+ import { Internal } from "@valbuild/core";
12
+ import { createElement } from "react";
13
+ import parse from "style-to-object";
14
+
15
+ const getValPath = Internal.getValPath;
16
+ export function ValRichText({ children }: { children: Val<RichText> }) {
17
+ return (
18
+ <div data-val-path={getValPath(children)}>
19
+ {children.children.map((child) => {
20
+ switch (child.type.val) {
21
+ case "heading":
22
+ return (
23
+ <HeadingNodeComponent
24
+ key={getValPath(child)}
25
+ node={child as Val<HeadingNode>}
26
+ />
27
+ );
28
+ case "paragraph":
29
+ return (
30
+ <ParagraphNodeComponent
31
+ key={getValPath(child)}
32
+ node={child as Val<ParagraphNode>}
33
+ />
34
+ );
35
+ case "list":
36
+ return (
37
+ <ListNodeComponent
38
+ key={getValPath(child)}
39
+ node={child as Val<ListNode>}
40
+ />
41
+ );
42
+ default:
43
+ throw Error("Unknown node type: " + (child as any)?.type);
44
+ }
45
+ })}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ function TextNodeComponent({ node }: { node: Val<TextNode> }) {
51
+ const actualVal = node.val;
52
+ const styleProps = actualVal.style ? parse(actualVal.style) ?? {} : {};
53
+ // TODO: Ugly! We should do this before serializing instead
54
+ if (styleProps["font-family"]) {
55
+ styleProps["fontFamily"] = styleProps["font-family"];
56
+ delete styleProps["font-family"];
57
+ }
58
+ if (styleProps["font-size"]) {
59
+ styleProps["fontSize"] = styleProps["font-size"];
60
+ delete styleProps["font-size"];
61
+ }
62
+ const bitmask = actualVal.format.toString(2);
63
+ const bitmaskOffset = bitmask.length - 1;
64
+ function isBitOne(bit: number) {
65
+ return (
66
+ bitmask.length >= bitmaskOffset - bit &&
67
+ bitmask[bitmaskOffset - bit] === "1"
68
+ );
69
+ }
70
+ if (isBitOne(0)) {
71
+ styleProps["fontWeight"] = "bold";
72
+ }
73
+ if (isBitOne(1)) {
74
+ styleProps["fontStyle"] = "italic";
75
+ }
76
+ if (isBitOne(2)) {
77
+ if (!styleProps["textDecoration"]) {
78
+ styleProps["textDecoration"] = "line-through";
79
+ } else {
80
+ styleProps["textDecoration"] += " line-through";
81
+ }
82
+ }
83
+ if (isBitOne(3)) {
84
+ if (!styleProps["textDecoration"]) {
85
+ styleProps["textDecoration"] = "underline";
86
+ } else {
87
+ styleProps["textDecoration"] += " underline";
88
+ }
89
+ }
90
+ return <span style={styleProps}>{actualVal.text}</span>;
91
+ }
92
+
93
+ function HeadingNodeComponent({ node }: { node: Val<HeadingNode> }) {
94
+ return createElement(
95
+ node.tag.val,
96
+ {},
97
+ node.children.map((child) => (
98
+ <TextNodeComponent key={getValPath(child)} node={child} />
99
+ ))
100
+ );
101
+ }
102
+
103
+ function ParagraphNodeComponent({ node }: { node: Val<ParagraphNode> }) {
104
+ return (
105
+ <p>
106
+ {node.children.map((child) => {
107
+ switch (child.type.val) {
108
+ case "text":
109
+ return <TextNodeComponent key={getValPath(child)} node={child} />;
110
+ default:
111
+ throw Error("Unknown node type: " + (child as any)?.type);
112
+ }
113
+ })}
114
+ </p>
115
+ );
116
+ }
117
+
118
+ function ListNodeComponent({ node }: { node: Val<ListNode> }) {
119
+ return createElement(
120
+ node.val.tag,
121
+ {},
122
+ node.children.map((child) => (
123
+ <ListItemComponent key={getValPath(child)} node={child} />
124
+ ))
125
+ );
126
+ }
127
+
128
+ function ListItemComponent({ node }: { node: Val<ListItemNode> }) {
129
+ return (
130
+ <li>
131
+ {node.children.map((child, i) => {
132
+ switch (child.val.type) {
133
+ case "text":
134
+ return <TextNodeComponent key={i} node={child} />;
135
+ default:
136
+ throw Error("Unknown node type: " + (child as any)?.type);
137
+ }
138
+ })}
139
+ </li>
140
+ );
141
+ }
@@ -0,0 +1,62 @@
1
+ import { ValModule, SelectorSource } from "@valbuild/core";
2
+ import { ValApi } from "./ValApi";
3
+
4
+ export class ValStore {
5
+ private readonly vals: Map<string, ValModule<SelectorSource>>;
6
+ private readonly listeners: { [moduleId: string]: (() => void)[] };
7
+
8
+ constructor(private readonly api: ValApi) {
9
+ this.vals = new Map();
10
+ this.listeners = {};
11
+ }
12
+
13
+ async updateAll() {
14
+ await Promise.all(
15
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
16
+ Object.keys(this.listeners).map(async (moduleId) => {
17
+ // this.set(
18
+ // moduleId,
19
+ // await this.api.getModule(moduleId)
20
+ // // ModuleContent.deserialize(await this.api.getModule(moduleId))
21
+ // );
22
+ })
23
+ );
24
+ }
25
+
26
+ subscribe = (moduleId: string) => (listener: () => void) => {
27
+ const listeners = (this.listeners[moduleId] =
28
+ moduleId in this.listeners ? this.listeners[moduleId] : []);
29
+ listeners.push(listener);
30
+ return () => {
31
+ listeners.splice(listeners.indexOf(listener), 1);
32
+ if (listeners.length === 0) {
33
+ delete this.listeners[moduleId];
34
+ }
35
+ };
36
+ };
37
+
38
+ set(moduleId: string, val: ValModule<SelectorSource>) {
39
+ this.vals.set(moduleId, val);
40
+ this.emitChange(moduleId);
41
+ }
42
+
43
+ get(moduleId: string) {
44
+ return this.vals.get(moduleId);
45
+ }
46
+
47
+ emitChange(moduleId: string) {
48
+ const listeners = this.listeners[moduleId];
49
+ if (typeof listeners === "undefined") return;
50
+ for (const listener of listeners) {
51
+ listener();
52
+ }
53
+ }
54
+
55
+ getSnapshot = (moduleId: string) => () => {
56
+ return this.vals.get(moduleId);
57
+ };
58
+
59
+ getServerSnapshot = (moduleId: string) => () => {
60
+ return this.vals.get(moduleId);
61
+ };
62
+ }
package/src/assets.ts ADDED
@@ -0,0 +1,124 @@
1
+ import * as base64 from "base64-arraybuffer";
2
+
3
+ function dataUrl(mimeType: string, data: string): string {
4
+ return `data:${mimeType};base64,${base64.encode(
5
+ new TextEncoder().encode(data)
6
+ )}`;
7
+ }
8
+
9
+ // TODO: stroke should be currentColor
10
+ export const editIcon = (size: number, stroke: string) =>
11
+ dataUrl(
12
+ "image/svg+xml",
13
+ `
14
+ <svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
15
+ `
16
+ );
17
+
18
+ export const logo = dataUrl(
19
+ "image/svg+xml",
20
+ `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
21
+ <!-- Created with Inkscape (http://www.inkscape.org/) -->
22
+
23
+ <svg
24
+ width="101.83195mm"
25
+ height="103.55328mm"
26
+ viewBox="0 0 101.83195 103.55328"
27
+ version="1.1"
28
+ id="svg974"
29
+ inkscape:export-filename="logo.svg"
30
+ inkscape:export-xdpi="96"
31
+ inkscape:export-ydpi="96"
32
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
33
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
34
+ xmlns="http://www.w3.org/2000/svg"
35
+ xmlns:svg="http://www.w3.org/2000/svg">
36
+ <sodipodi:namedview
37
+ id="namedview976"
38
+ pagecolor="#ffffff"
39
+ bordercolor="#000000"
40
+ borderopacity="0.25"
41
+ inkscape:showpageshadow="2"
42
+ inkscape:pageopacity="0.0"
43
+ inkscape:pagecheckerboard="0"
44
+ inkscape:deskcolor="#d1d1d1"
45
+ inkscape:document-units="mm"
46
+ showgrid="false"
47
+ inkscape:zoom="1.4040232"
48
+ inkscape:cx="39.173141"
49
+ inkscape:cy="503.90904"
50
+ inkscape:window-width="3832"
51
+ inkscape:window-height="2087"
52
+ inkscape:window-x="0"
53
+ inkscape:window-y="69"
54
+ inkscape:window-maximized="1"
55
+ inkscape:current-layer="layer1" />
56
+ <defs
57
+ id="defs971" />
58
+ <g
59
+ inkscape:label="Layer 1"
60
+ inkscape:groupmode="layer"
61
+ id="layer1"
62
+ transform="translate(-46.162121,-16.863144)">
63
+ <ellipse
64
+ style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:4.41854;stroke-dasharray:none;stroke-opacity:1"
65
+ id="path2242"
66
+ ry="49.567371"
67
+ rx="48.706703"
68
+ cy="68.639786"
69
+ cx="97.078094" />
70
+ <path
71
+ style="fill:#000000;fill-opacity:1;stroke:#f2f2f2;stroke-width:9.9912;stroke-dasharray:none;stroke-opacity:1"
72
+ d="m 65.105895,44.462411 18.85692,45.934668 13.064363,-39.90188 15.829132,39.947835 23.07956,-0.1822"
73
+ id="path4245"
74
+ sodipodi:nodetypes="ccccc" />
75
+ <path
76
+ style="fill:#000000;fill-opacity:1;stroke:#f2f2f2;stroke-width:4.85097;stroke-dasharray:none;stroke-opacity:1"
77
+ d="M 108.18755,79.963752 C 101.58768,84.940963 94.021144,82.50121 86.406627,79.693345"
78
+ id="path4249"
79
+ sodipodi:nodetypes="cc" />
80
+ </g>
81
+ </svg>`
82
+ );
83
+
84
+ export const valcmsLogo = dataUrl(
85
+ "image/svg+xml",
86
+ `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
87
+ <svg
88
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
89
+ xmlns:cc="http://creativecommons.org/ns#"
90
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
91
+ xmlns:svg="http://www.w3.org/2000/svg"
92
+ xmlns="http://www.w3.org/2000/svg"
93
+ width="20"
94
+ height="20"
95
+ viewBox="0 0 52.916665 52.916668"
96
+ version="1.1"
97
+ id="svg8">
98
+ <defs
99
+ id="defs2" />
100
+ <metadata
101
+ id="metadata5">
102
+ <rdf:RDF>
103
+ <cc:Work
104
+ rdf:about="">
105
+ <dc:format>image/svg+xml</dc:format>
106
+ <dc:type
107
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
108
+ <dc:title></dc:title>
109
+ </cc:Work>
110
+ </rdf:RDF>
111
+ </metadata>
112
+ <g
113
+ id="layer1">
114
+ <path
115
+ style="fill:none;stroke:white;stroke-width:5.00377;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
116
+ d="M 3.9337727,9.883415 14.718441,43.556958 25.597715,9.9678121 35.530965,43.388176 h 15.798599 v 0 h 0.09461"
117
+ id="path10" />
118
+ <path
119
+ style="fill:none;stroke:white;stroke-width:5.00377;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
120
+ d="m 19.826972,27.859518 11.257682,0.0844 v 0 0"
121
+ id="path837" />
122
+ </g>
123
+ </svg>`
124
+ );
@@ -0,0 +1,57 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
2
+ import { useVal } from "./useVal";
3
+ import { initVal, Val } from "@valbuild/core";
4
+ import { renderHook } from "@testing-library/react";
5
+ import { ValContext } from "../ValProvider";
6
+ import { ReactElement } from "react";
7
+ import { ValStore } from "../ValStore";
8
+ import { ValApi } from "../ValApi";
9
+
10
+ const valApi = new ValApi("mock");
11
+ const valStore = new ValStore(valApi);
12
+
13
+ const Providers = ({ children }: { children: ReactElement }) => (
14
+ <ValContext.Provider
15
+ value={{
16
+ valStore,
17
+ valApi,
18
+ }}
19
+ >
20
+ {children}
21
+ </ValContext.Provider>
22
+ );
23
+
24
+ // const { s, val } = initVal();
25
+
26
+ describe("useVal", () => {
27
+ test.skip("extracts ValString from string", () => {
28
+ // const mod = val.content("foo", s.string(), "bar");
29
+ // const { result } = renderHook(() => useVal(mod, "en_US"), {
30
+ // wrapper: Providers,
31
+ // });
32
+ // expect(result.current).toStrictEqual<Val<string>>({
33
+ // val: "bar",
34
+ // valSrc: "foo?en_US?",
35
+ // });
36
+ });
37
+
38
+ test.skip("extracts ValString from ValObject", () => {
39
+ // const mod = val.content("baz", s.object({ foo: s.string() }), {
40
+ // foo: "bar",
41
+ // });
42
+ // const { result } = renderHook(() => useVal(mod, "en_US"), {
43
+ // wrapper: Providers,
44
+ // });
45
+ // const vo: Val<{ foo: string }> = result.current;
46
+ // expect(vo.foo).toStrictEqual<Val<string>>({
47
+ // valSrc: `baz?en_US?."foo"`,
48
+ // val: "bar",
49
+ // });
50
+ // expect(val).toStrictEqual<ValObject<{ foo: string }>>({
51
+ // foo: {
52
+ // id: "baz.foo",
53
+ // val: "bar",
54
+ // },
55
+ // });
56
+ });
57
+ });
@@ -0,0 +1,35 @@
1
+ import {
2
+ SelectorSource,
3
+ SelectorOf,
4
+ GenericSelector,
5
+ Val,
6
+ Internal,
7
+ } from "@valbuild/core";
8
+ import { JsonOfSource } from "@valbuild/core/src/val";
9
+
10
+ export function useVal<T extends SelectorSource>(
11
+ selector: T,
12
+ locale?: string
13
+ ): SelectorOf<T> extends GenericSelector<infer S>
14
+ ? Val<JsonOfSource<S>>
15
+ : never {
16
+ // const mod = selectable.getModule();
17
+ // const valStore = useValStore();
18
+ // const remoteContent = useSyncExternalStore(
19
+ // valStore.subscribe(mod.id),
20
+ // valStore.getSnapshot(mod.id),
21
+ // valStore.getServerSnapshot(mod.id)
22
+ // );
23
+ // if (remoteContent) {
24
+ // return selectable.getVal(remoteContent.source as S, locale);
25
+ // }
26
+ // const content = mod.content;
27
+ // const validationError = content.validate();
28
+ // if (validationError) {
29
+ // throw new Error(
30
+ // `Invalid source value. Errors:\n${validationError.join("\n")}`
31
+ // );
32
+ // }
33
+
34
+ return Internal.getVal(selector, locale);
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ /* export { useContent } from "./useContent";
2
+ export { useText } from "./useText";
3
+ export { WithVal } from "./WithVal"; */
4
+ export { ValProvider } from "./ValProvider";
5
+ export { useVal } from "./hooks/useVal";
6
+ export { ValRichText } from "./ValRichText";
@@ -0,0 +1,47 @@
1
+ import { Internal } from "@valbuild/core";
2
+ import * as ReactJSXRuntimeDev from "react/jsx-dev-runtime";
3
+ export * from "react/jsx-dev-runtime";
4
+
5
+ const isIntrinsicElement = (type) => {
6
+ // TODO: think this is not correct, but good enough for now?
7
+ return typeof type === "string";
8
+ };
9
+
10
+ const devalProps = (type, props) => {
11
+ const valSources = [];
12
+
13
+ if (isIntrinsicElement(type)) {
14
+ for (const [key, value] of Object.entries(props)) {
15
+ if (typeof value === "object" && value !== null && "val" in value) {
16
+ const valPath = Internal.getValPath(value);
17
+ if (valPath) {
18
+ valSources.push(valPath);
19
+ if (typeof value.val === "string" || value.val === null) {
20
+ props[key] = value.val;
21
+ } else {
22
+ throw Error("TODO: unhandled value type");
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }
28
+
29
+ if (valSources.length > 0) {
30
+ props["data-val-path"] = valSources.join(",");
31
+ }
32
+ };
33
+
34
+ export function jsxDEV(type, props, key, isStaticChildren, source, self) {
35
+ // console.log("jsxDEV", type, props, key, isStaticChildren, source, self);
36
+
37
+ devalProps(type, props);
38
+
39
+ return ReactJSXRuntimeDev.jsxDEV(
40
+ type,
41
+ props,
42
+ key,
43
+ isStaticChildren,
44
+ source,
45
+ self
46
+ );
47
+ }
@@ -0,0 +1,46 @@
1
+ import { Source, Val } from "@valbuild/core";
2
+
3
+ // unpack all here to avoid infinite self-referencing when defining our own JSX namespace
4
+ type ReactJSXElement = JSX.Element;
5
+ type ReactJSXElementClass = JSX.ElementClass;
6
+ type ReactJSXElementAttributesProperty = JSX.ElementAttributesProperty;
7
+ type ReactJSXElementChildrenAttribute = JSX.ElementChildrenAttribute;
8
+ type ReactJSXLibraryManagedAttributes<C, P> = JSX.LibraryManagedAttributes<
9
+ C,
10
+ P
11
+ >;
12
+ type ReactJSXIntrinsicAttributes = JSX.IntrinsicAttributes;
13
+ type ReactJSXIntrinsicClassAttributes<T> = JSX.IntrinsicClassAttributes<T>;
14
+ type ReactJSXIntrinsicElements = JSX.IntrinsicElements;
15
+
16
+ type MaybeVal<T> = T extends Source ? Val<T> | T : T;
17
+ type WithVal<T extends object> = {
18
+ [K in keyof T]: K extends "key" | "ref" | "className"
19
+ ? T[K]
20
+ : K extends "style"
21
+ ? WithVal<React.CSSProperties>
22
+ : T[K] extends object
23
+ ? T[K]
24
+ : MaybeVal<T[K]>;
25
+ };
26
+
27
+ export namespace ValJSX {
28
+ export type Element = ReactJSXElement;
29
+ export type ElementClass = ReactJSXElementClass;
30
+ export type ElementAttributesProperty = ReactJSXElementAttributesProperty;
31
+ export type ElementChildrenAttribute = ReactJSXElementChildrenAttribute;
32
+
33
+ export type LibraryManagedAttributes<C, P> = ReactJSXLibraryManagedAttributes<
34
+ C,
35
+ P
36
+ >;
37
+
38
+ export type IntrinsicAttributes = ReactJSXIntrinsicAttributes;
39
+ export type IntrinsicClassAttributes<T> = ReactJSXIntrinsicClassAttributes<T>;
40
+
41
+ export type IntrinsicElements = {
42
+ [K in keyof ReactJSXIntrinsicElements]: WithVal<
43
+ ReactJSXIntrinsicElements[K]
44
+ >;
45
+ };
46
+ }
@@ -0,0 +1 @@
1
+ export { ValJSX as JSX } from "./jsx-namespace";
@@ -0,0 +1 @@
1
+ export { ValJSX as JSX } from "./jsx-namespace";
@@ -0,0 +1,52 @@
1
+ import { Internal } from "@valbuild/core";
2
+ import * as ReactJSXRuntime from "react/jsx-runtime";
3
+ export * from "react/jsx-runtime";
4
+
5
+ const isIntrinsicElement = (type) => {
6
+ // TODO: think this is not correct, but good enough for now?
7
+ return typeof type === "string";
8
+ };
9
+
10
+ const devalProps = (type, props) => {
11
+ const valSources = [];
12
+
13
+ if (isIntrinsicElement(type)) {
14
+ for (const [key, value] of Object.entries(props)) {
15
+ if (typeof value === "object" && value !== null && "val" in value) {
16
+ const valPath = Internal.getValPath(value);
17
+ if (valPath) {
18
+ valSources.push(valPath);
19
+ if (typeof value.val === "string" || value.val === null) {
20
+ props[key] = value.val;
21
+ } else {
22
+ throw Error("TODO: unhandled value type");
23
+ }
24
+ }
25
+ }
26
+ }
27
+ }
28
+
29
+ if (valSources.length > 0) {
30
+ props["data-val-path"] = valSources.join(",");
31
+ }
32
+ };
33
+
34
+ export function jsx(type, props, key) {
35
+ // console.log("jsx", type, props, key);
36
+
37
+ devalProps(type, props);
38
+
39
+ return ReactJSXRuntime.jsx(type, props, key);
40
+ }
41
+
42
+ export function jsxs(type, props, key) {
43
+ // console.log("jsxs", type, props, key);
44
+
45
+ if (key === "key") {
46
+ console.log("jsxDEV", type, props, key, self);
47
+ }
48
+
49
+ devalProps(type, props);
50
+
51
+ return ReactJSXRuntime.jsxs(type, props, key);
52
+ }