Skip to content

Commit b8c7a23

Browse files
React exploration & Common-Charm extraction (#302)
a first pass at a react shell extracting charm logic from lookslike-high-level to commontools/charm package --------- Co-authored-by: jakedahn <jake@common.tools>
1 parent 26f9797 commit b8c7a23

31 files changed

+876
-253
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@commontools/charm",
3+
"version": "0.1.0",
4+
"description": "charm",
5+
"main": "src/index.js",
6+
"type": "module",
7+
"packageManager": "pnpm@10.0.0",
8+
"engines": {
9+
"npm": "please-use-pnpm",
10+
"yarn": "please-use-pnpm",
11+
"pnpm": ">= 10.0.0",
12+
"node": "20.11.0"
13+
},
14+
"scripts": {
15+
"test": "vitest --exclude=lib/**/*.test.js",
16+
"format": "prettier --write . --ignore-path ../../../.prettierignore",
17+
"lint": "eslint .",
18+
"build": "tsc && vite build"
19+
},
20+
"author": "",
21+
"license": "UNLICENSED",
22+
"dependencies": {
23+
"@commontools/builder": "workspace:*",
24+
"@commontools/html": "workspace:*",
25+
"@commontools/memory": "workspace:*",
26+
"@commontools/runner": "workspace:*",
27+
"merkle-reference": "^2.0.1",
28+
"zod": "^3.24.1",
29+
"zod-to-json-schema": "^3.24.1"
30+
},
31+
"devDependencies": {
32+
"@eslint/js": "^9.19.0",
33+
"@types/node": "^22.10.10",
34+
"eslint": "^9.19.0",
35+
"eslint-config-prettier": "^10.0.1",
36+
"eslint-plugin-prettier": "^5.2.3",
37+
"globals": "^15.14.0",
38+
"prettier": "^3.4.2",
39+
"typescript": "^5.7.3",
40+
"vite": "^6.0.11",
41+
"vite-plugin-dts": "^4.5.0",
42+
"vitest": "^3.0.4"
43+
}
44+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { Module, NAME, Recipe, TYPE, UI } from "@commontools/builder";
2+
import { getDoc, type DocLink, DocImpl, EntityId, idle, createRef, getRecipe, isDoc, isDocLink, run } from "@commontools/runner";
3+
import { createStorage } from "./storage.js";
4+
5+
export type Charm = {
6+
[NAME]?: string;
7+
[UI]?: any;
8+
[TYPE]?: string;
9+
[key: string]: any;
10+
};
11+
12+
export const storage = createStorage((import.meta as any).env.VITE_STORAGE_TYPE ?? "memory");
13+
export const charms = getDoc<DocLink[]>([], "charms");
14+
(window as any).charms = charms;
15+
16+
export async function addCharms(newCharms: DocImpl<any>[]) {
17+
await storage.syncCell(charms);
18+
19+
await idle();
20+
21+
const currentCharmsIds = charms.get().map(({ cell }) => JSON.stringify(cell.entityId));
22+
const charmsToAdd = newCharms.filter(
23+
(cell) => !currentCharmsIds.includes(JSON.stringify(cell.entityId)),
24+
);
25+
26+
if (charmsToAdd.length > 0) {
27+
charms.send([
28+
...charms.get(),
29+
...charmsToAdd.map((cell) => ({ cell, path: [] }) satisfies DocLink),
30+
]);
31+
}
32+
}
33+
34+
export function removeCharm(id: EntityId) {
35+
const newCharms = charms.get().filter(({ cell }) => cell.entityId !== id);
36+
if (newCharms.length !== charms.get().length) charms.send(newCharms);
37+
}
38+
39+
export async function runPersistent(
40+
recipe: Recipe | Module,
41+
inputs?: any,
42+
cause?: any,
43+
): Promise<DocImpl<any>> {
44+
await idle();
45+
46+
// Fill in missing parameters from other charms. It's a simple match on
47+
// hashtags: For each top-level argument prop that has a hashtag in the
48+
// description, look for a charm that has a top-level output prop with the
49+
// same hashtag in the description, or has the hashtag in its own description.
50+
// If there is a match, assign the first one to the input property.
51+
52+
// TODO: This should really be extracted into a full-fledged query builder.
53+
if (
54+
!isDoc(inputs) && // Adding to a cell input is not supported yet
55+
!isDocLink(inputs) && // Neither for cell reference
56+
recipe.argumentSchema &&
57+
(recipe.argumentSchema as any).type === "object"
58+
) {
59+
const properties = (recipe.argumentSchema as any).properties;
60+
const inputProperties =
61+
typeof inputs === "object" && inputs !== null ? Object.keys(inputs) : [];
62+
for (const key in properties) {
63+
if (!(key in inputProperties) && properties[key].description?.includes("#")) {
64+
const hashtag = properties[key].description.match(/#(\w+)/)?.[1];
65+
if (hashtag) {
66+
charms.get().forEach(({ cell }) => {
67+
const type = cell.sourceCell?.get()?.[TYPE];
68+
const recipe = getRecipe(type);
69+
const charmProperties = (recipe?.resultSchema as any)?.properties as any;
70+
const matchingProperty = Object.keys(charmProperties ?? {}).find((property) =>
71+
charmProperties[property].description?.includes(`#${hashtag}`),
72+
);
73+
if (matchingProperty) {
74+
inputs = {
75+
...inputs,
76+
[key]: { $alias: { cell, path: [matchingProperty] } },
77+
};
78+
}
79+
});
80+
}
81+
}
82+
}
83+
}
84+
85+
return run(recipe, inputs, await storage.syncCell(createRef({ recipe, inputs }, cause)));
86+
}
87+
88+
export async function syncCharm(
89+
entityId: string | EntityId | DocImpl<any>,
90+
waitForStorage: boolean = false,
91+
): Promise<DocImpl<Charm>> {
92+
return storage.syncCell(entityId, waitForStorage);
93+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { runPersistent, type Charm, addCharms, removeCharm, storage, syncCharm, charms } from "./charm.js";

typescript/packages/lookslike-high-level/src/storage-providers.ts renamed to typescript/packages/common-charm/src/storage-providers.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { EntityId, Cancel } from "@commontools/runner";
22
import { log } from "./storage.js";
3-
import { fromJSON, fromBytes, refer, type Reference } from "merkle-reference";
3+
import { fromJSON, refer, type Reference } from "merkle-reference";
44
import z from "zod";
55
import type {
66
State,
@@ -9,7 +9,6 @@ import type {
99
Entity,
1010
Unclaimed,
1111
Selector,
12-
Claim,
1312
Fact,
1413
ConflictError,
1514
TransactionError,

typescript/packages/lookslike-high-level/src/storage.ts renamed to typescript/packages/common-charm/src/storage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
InMemoryStorageProvider,
2121
RemoteStorageProvider,
2222
} from "./storage-providers.js";
23-
import { debug } from "@commontools/html";
23+
import { debug } from "@commontools/html"; // FIXME(ja): can we move debug to somewhere else?
2424

2525
export function log(...args: any[]) {
2626
// Get absolute time in milliseconds since Unix epoch
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"extends": "../../tsconfig.base.json",
3+
"moduleResolution": "nodenext",
4+
"compilerOptions": {
5+
"lib": ["es2022", "esnext.array", "esnext", "dom"],
6+
"outDir": "./lib",
7+
"declaration": true,
8+
"jsx": "preserve",
9+
"jsxFactory": "h",
10+
"jsxFragmentFactory": "Fragment",
11+
"experimentalDecorators": true,
12+
"useDefineForClassFields": false
13+
},
14+
"include": ["src/**/*.ts", "test/**/*.ts", "src/**/*.tsx", "vite-env.d.ts"],
15+
"exclude": []
16+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineConfig } from "vite";
2+
import { resolve } from "path";
3+
import dts from "vite-plugin-dts";
4+
5+
// https://vitejs.dev/config/
6+
export default defineConfig({
7+
build: {
8+
lib: { entry: resolve(__dirname, "src/index.ts"), formats: ["es"] },
9+
},
10+
resolve: { alias: { src: resolve("src/") } },
11+
plugins: [dts()],
12+
});

typescript/packages/jumble/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,15 @@
2121
},
2222
"homepage": "https://github.com/commontoolsinc/labs#readme",
2323
"dependencies": {
24-
"@commontools/ui": "workspace:*",
24+
"@codemirror/lang-javascript": "^6.2.2",
2525
"@codemirror/lang-markdown": "^6.3.2",
26+
"@codemirror/view": "^6.36.2",
27+
"@commontools/ui": "workspace:*",
28+
"@commontools/builder": "workspace:*",
29+
"@commontools/runner": "workspace:*",
30+
"@commontools/html": "workspace:*",
31+
"@commontools/charm": "workspace:*",
32+
"@commontools/iframe-sandbox": "workspace:*",
2633
"@react-spring/web": "^9.7.5",
2734
"@tailwindcss/typography": "^0.5.16",
2835
"@tailwindcss/vite": "^4.0.1",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React, { useRef, useEffect } from "react";
2+
import { effect } from "@commontools/runner";
3+
import type { DocImpl } from "@commontools/runner";
4+
import { createRoot, Root } from "react-dom/client";
5+
import { UI } from "@commontools/builder";
6+
7+
export interface CharmRunnerProps {
8+
// Accept either a full reactive DocImpl or a plain charm with a ui prop.
9+
charm: DocImpl<any> | { ui: React.ReactNode };
10+
}
11+
12+
export default function CharmRunner({ charm }: CharmRunnerProps) {
13+
const containerRef = useRef<HTMLDivElement>(null);
14+
const rootRef = useRef<Root | null>(null);
15+
16+
useEffect(() => {
17+
// Helper: updates the container with the new view, re-using the root.
18+
const updateContainer = (view: any) => {
19+
if (containerRef.current) {
20+
if (!rootRef.current) {
21+
rootRef.current = createRoot(containerRef.current);
22+
}
23+
if (React.isValidElement(view)) {
24+
rootRef.current.render(view);
25+
} else {
26+
// If view is raw html, update innerHTML
27+
containerRef.current.innerHTML = view;
28+
}
29+
}
30+
};
31+
32+
// If the charm doesn't have asCell (i.e. not reactive), immediately update.
33+
if (typeof (charm as any).asCell !== "function") {
34+
updateContainer((charm as { ui: React.ReactNode }).ui);
35+
return;
36+
}
37+
38+
// If charm is reactive, subscribe to its UI cell.
39+
const unsubscribe = effect((charm as DocImpl<any>).asCell(UI), (view: any) => {
40+
if (!view) {
41+
console.warn("No UI for charm", charm);
42+
return;
43+
}
44+
updateContainer(view);
45+
});
46+
47+
return () => {
48+
unsubscribe();
49+
};
50+
}, [charm]);
51+
52+
return <div ref={containerRef} />;
53+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useEffect } from "react";
2+
import CharmRunner from "@/components/CharmRunner";
3+
import type { DocImpl } from "@commontools/runner";
4+
import { getEntityId } from "@commontools/runner";
5+
6+
export interface CharmWindowProps {
7+
charm: DocImpl<any>;
8+
onClose: (charmId: string) => void;
9+
}
10+
11+
export default function CharmWindow({ charm, onClose }: CharmWindowProps) {
12+
// Use charm.entityId (converted to string) as a unique ID.
13+
const charmId = JSON.stringify(getEntityId(charm));
14+
15+
return (
16+
<div className="window" data-charm-id={charmId}>
17+
<div className="window-toolbar">
18+
<h1
19+
className="window-title"
20+
onClick={() => {
21+
// Set focus on this charm if needed.
22+
}}
23+
>
24+
{charm.getAsQueryResult()?.name || "Untitled"}
25+
</h1>
26+
<button className="close-button" onClick={() => onClose(charmId)}>
27+
x
28+
</button>
29+
</div>
30+
<div className="charm">
31+
<CharmRunner charm={charm} />
32+
</div>
33+
</div>
34+
);
35+
}

0 commit comments

Comments
 (0)