Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions typescript/packages/common-charm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@commontools/charm",
"version": "0.1.0",
"description": "charm",
"main": "src/index.js",
"type": "module",
"packageManager": "pnpm@10.0.0",
"engines": {
"npm": "please-use-pnpm",
"yarn": "please-use-pnpm",
"pnpm": ">= 10.0.0",
"node": "20.11.0"
},
"scripts": {
"test": "vitest --exclude=lib/**/*.test.js",
"format": "prettier --write . --ignore-path ../../../.prettierignore",
"lint": "eslint .",
"build": "tsc && vite build"
},
"author": "",
"license": "UNLICENSED",
"dependencies": {
"@commontools/builder": "workspace:*",
"@commontools/html": "workspace:*",
"@commontools/memory": "workspace:*",
"@commontools/runner": "workspace:*",
"merkle-reference": "^2.0.1",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/node": "^22.10.10",
"eslint": "^9.19.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vite-plugin-dts": "^4.5.0",
"vitest": "^3.0.4"
}
}
93 changes: 93 additions & 0 deletions typescript/packages/common-charm/src/charm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Module, NAME, Recipe, TYPE, UI } from "@commontools/builder";
import { getDoc, type DocLink, DocImpl, EntityId, idle, createRef, getRecipe, isDoc, isDocLink, run } from "@commontools/runner";
import { createStorage } from "./storage.js";

export type Charm = {
[NAME]?: string;
[UI]?: any;
[TYPE]?: string;
[key: string]: any;
};

export const storage = createStorage((import.meta as any).env.VITE_STORAGE_TYPE ?? "memory");
export const charms = getDoc<DocLink[]>([], "charms");
(window as any).charms = charms;

export async function addCharms(newCharms: DocImpl<any>[]) {
await storage.syncCell(charms);

await idle();

const currentCharmsIds = charms.get().map(({ cell }) => JSON.stringify(cell.entityId));
const charmsToAdd = newCharms.filter(
(cell) => !currentCharmsIds.includes(JSON.stringify(cell.entityId)),
);

if (charmsToAdd.length > 0) {
charms.send([
...charms.get(),
...charmsToAdd.map((cell) => ({ cell, path: [] }) satisfies DocLink),
]);
}
}

export function removeCharm(id: EntityId) {
const newCharms = charms.get().filter(({ cell }) => cell.entityId !== id);
if (newCharms.length !== charms.get().length) charms.send(newCharms);
}

export async function runPersistent(
recipe: Recipe | Module,
inputs?: any,
cause?: any,
): Promise<DocImpl<any>> {
await idle();

// Fill in missing parameters from other charms. It's a simple match on
// hashtags: For each top-level argument prop that has a hashtag in the
// description, look for a charm that has a top-level output prop with the
// same hashtag in the description, or has the hashtag in its own description.
// If there is a match, assign the first one to the input property.

// TODO: This should really be extracted into a full-fledged query builder.
if (
!isDoc(inputs) && // Adding to a cell input is not supported yet
!isDocLink(inputs) && // Neither for cell reference
recipe.argumentSchema &&
(recipe.argumentSchema as any).type === "object"
) {
const properties = (recipe.argumentSchema as any).properties;
const inputProperties =
typeof inputs === "object" && inputs !== null ? Object.keys(inputs) : [];
for (const key in properties) {
if (!(key in inputProperties) && properties[key].description?.includes("#")) {
const hashtag = properties[key].description.match(/#(\w+)/)?.[1];
if (hashtag) {
charms.get().forEach(({ cell }) => {
const type = cell.sourceCell?.get()?.[TYPE];
const recipe = getRecipe(type);
const charmProperties = (recipe?.resultSchema as any)?.properties as any;
const matchingProperty = Object.keys(charmProperties ?? {}).find((property) =>
charmProperties[property].description?.includes(`#${hashtag}`),
);
if (matchingProperty) {
inputs = {
...inputs,
[key]: { $alias: { cell, path: [matchingProperty] } },
};
}
});
}
}
}
}

return run(recipe, inputs, await storage.syncCell(createRef({ recipe, inputs }, cause)));
}

export async function syncCharm(
entityId: string | EntityId | DocImpl<any>,
waitForStorage: boolean = false,
): Promise<DocImpl<Charm>> {
return storage.syncCell(entityId, waitForStorage);
}
1 change: 1 addition & 0 deletions typescript/packages/common-charm/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { runPersistent, type Charm, addCharms, removeCharm, storage, syncCharm, charms } from "./charm.js";
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { EntityId, Cancel } from "@commontools/runner";
import { log } from "./storage.js";
import { fromJSON, fromBytes, refer, type Reference } from "merkle-reference";
import { fromJSON, refer, type Reference } from "merkle-reference";
import z from "zod";
import type {
State,
Expand All @@ -9,7 +9,6 @@ import type {
Entity,
Unclaimed,
Selector,
Claim,
Fact,
ConflictError,
TransactionError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
InMemoryStorageProvider,
RemoteStorageProvider,
} from "./storage-providers.js";
import { debug } from "@commontools/html";
import { debug } from "@commontools/html"; // FIXME(ja): can we move debug to somewhere else?

export function log(...args: any[]) {
// Get absolute time in milliseconds since Unix epoch
Expand Down
16 changes: 16 additions & 0 deletions typescript/packages/common-charm/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"moduleResolution": "nodenext",
"compilerOptions": {
"lib": ["es2022", "esnext.array", "esnext", "dom"],
"outDir": "./lib",
"declaration": true,
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
"experimentalDecorators": true,
"useDefineForClassFields": false
},
"include": ["src/**/*.ts", "test/**/*.ts", "src/**/*.tsx", "vite-env.d.ts"],
"exclude": []
}
12 changes: 12 additions & 0 deletions typescript/packages/common-charm/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import { resolve } from "path";
import dts from "vite-plugin-dts";

// https://vitejs.dev/config/
export default defineConfig({
build: {
lib: { entry: resolve(__dirname, "src/index.ts"), formats: ["es"] },
},
resolve: { alias: { src: resolve("src/") } },
plugins: [dts()],
});
9 changes: 8 additions & 1 deletion typescript/packages/jumble/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@
},
"homepage": "https://github.com/commontoolsinc/labs#readme",
"dependencies": {
"@commontools/ui": "workspace:*",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/view": "^6.36.2",
"@commontools/ui": "workspace:*",
"@commontools/builder": "workspace:*",
"@commontools/runner": "workspace:*",
"@commontools/html": "workspace:*",
"@commontools/charm": "workspace:*",
"@commontools/iframe-sandbox": "workspace:*",
"@react-spring/web": "^9.7.5",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.0.1",
Expand Down
53 changes: 53 additions & 0 deletions typescript/packages/jumble/src/components/CharmRunner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React, { useRef, useEffect } from "react";
import { effect } from "@commontools/runner";
import type { DocImpl } from "@commontools/runner";
import { createRoot, Root } from "react-dom/client";
import { UI } from "@commontools/builder";

export interface CharmRunnerProps {
// Accept either a full reactive DocImpl or a plain charm with a ui prop.
charm: DocImpl<any> | { ui: React.ReactNode };
}

export default function CharmRunner({ charm }: CharmRunnerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<Root | null>(null);

useEffect(() => {
// Helper: updates the container with the new view, re-using the root.
const updateContainer = (view: any) => {
if (containerRef.current) {
if (!rootRef.current) {
rootRef.current = createRoot(containerRef.current);
}
if (React.isValidElement(view)) {
rootRef.current.render(view);
} else {
// If view is raw html, update innerHTML
containerRef.current.innerHTML = view;
}
}
};

// If the charm doesn't have asCell (i.e. not reactive), immediately update.
if (typeof (charm as any).asCell !== "function") {
updateContainer((charm as { ui: React.ReactNode }).ui);
return;
}

// If charm is reactive, subscribe to its UI cell.
const unsubscribe = effect((charm as DocImpl<any>).asCell(UI), (view: any) => {
if (!view) {
console.warn("No UI for charm", charm);
return;
}
updateContainer(view);
});

return () => {
unsubscribe();
};
}, [charm]);

return <div ref={containerRef} />;
}
35 changes: 35 additions & 0 deletions typescript/packages/jumble/src/components/CharmWindow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useEffect } from "react";
import CharmRunner from "@/components/CharmRunner";
import type { DocImpl } from "@commontools/runner";
import { getEntityId } from "@commontools/runner";

export interface CharmWindowProps {
charm: DocImpl<any>;
onClose: (charmId: string) => void;
}

export default function CharmWindow({ charm, onClose }: CharmWindowProps) {
// Use charm.entityId (converted to string) as a unique ID.
const charmId = JSON.stringify(getEntityId(charm));

return (
<div className="window" data-charm-id={charmId}>
<div className="window-toolbar">
<h1
className="window-title"
onClick={() => {
// Set focus on this charm if needed.
}}
>
{charm.getAsQueryResult()?.name || "Untitled"}
</h1>
<button className="close-button" onClick={() => onClose(charmId)}>
x
</button>
</div>
<div className="charm">
<CharmRunner charm={charm} />
</div>
</div>
);
}
24 changes: 24 additions & 0 deletions typescript/packages/jumble/src/components/RunnerWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useRef, useEffect } from "react";
import { createRoot } from "react-dom/client";

// We now assume the recipe's render method returns a React element.
export interface RecipeFactory {
render: () => React.ReactElement;
}

type RunnerWrapperProps = {
recipeFactory: RecipeFactory;
};

export default function RunnerWrapper({ recipeFactory }: RunnerWrapperProps) {
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (containerRef.current) {
const element = recipeFactory.render();
createRoot(containerRef.current).render(element);
}
}, [recipeFactory]);

return <div ref={containerRef} />;
}
Empty file.
52 changes: 52 additions & 0 deletions typescript/packages/jumble/src/contexts/CharmsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { createContext, useContext, useState, useCallback } from "react";
import { type Charm } from "@commontools/lookslike-high-level";

export type CharmsContextType = {
charms: Charm[];
focusedCharm: Charm | null;
addCharm: (charm: Charm) => void;
removeCharm: (entityId: string) => void;
runCharm: (charm: Charm) => Promise<void>;
};

const CharmsContext = createContext<CharmsContextType>(null!);

export const CharmsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [charms, setCharms] = useState<Charm[]>([]);
const [focusedCharm, setFocusedCharm] = useState<Charm | null>(null);

const addCharm = useCallback((charm: Charm) => {
console.log("addCharm", charm);
setCharms((prev) => {
if (prev.some((c) => c.entityId === charm.entityId)) return prev;
return [...prev, charm];
});
setFocusedCharm(charm);
}, []);

const removeCharm = useCallback((entityId: string) => {
setCharms((prev) => prev.filter((c) => c.entityId !== entityId));
setFocusedCharm((prev) => (prev?.entityId === entityId ? null : prev));
}, []);

const runCharm = useCallback(
async (charm: Charm) => {
// Stub: runs charm asynchronously and then adds it
return new Promise<void>((resolve) => {
setTimeout(() => {
addCharm(charm);
resolve();
}, 300);
});
},
[addCharm],
);

return (
<CharmsContext.Provider value={{ charms, focusedCharm, addCharm, removeCharm, runCharm }}>
{children}
</CharmsContext.Provider>
);
};

export const useCharms = () => useContext(CharmsContext);
Loading