Skip to content

Commit 1bf1642

Browse files
committed
✨ Demo
1 parent eb98d97 commit 1bf1642

45 files changed

Lines changed: 3038 additions & 48 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/launch.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"version": "0.0.1",
3+
"configurations": [
4+
{
5+
"name": "demo",
6+
"runtimeExecutable": "pnpm",
7+
"runtimeArgs": ["--filter", "@oxa/demo", "dev"],
8+
"port": 5173
9+
}
10+
]
11+
}

docs/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ A foundation for interoperable, structured scientific content.
1818
The **Open Exchange Architecture (OXA)** is a specification for representing scientific documents and their components as structured JSON objects.
1919
It’s designed to enable **exchange, interoperability, and long-term preservation** of scientific knowledge, while remaining compatible with modern web and data standards.
2020

21+
:::{anywidget} https://cdn.jsdelivr.net/npm/@oxa/demo/dist/anywidget.js
22+
:::
23+
2124
OXA provides schemas and examples for representing:
2225

2326
- Executable and interactive research components

packages/oxa-demo/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>OXA Demo</title>
7+
</head>
8+
<body style="margin: 0; height: 100vh;">
9+
<div id="root" style="height: 100%;"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>

packages/oxa-demo/package.json

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "@oxa/demo",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"exports": {
6+
".": {
7+
"types": "./dist/index.d.ts",
8+
"import": "./dist/index.js"
9+
},
10+
"./anywidget": "./dist/anywidget.js"
11+
},
12+
"files": [
13+
"dist"
14+
],
15+
"scripts": {
16+
"dev": "vite",
17+
"build": "vite build && vite build --config vite.config.anywidget.ts",
18+
"build:lib": "vite build",
19+
"build:anywidget": "vite build --config vite.config.anywidget.ts",
20+
"typecheck": "tsc --noEmit",
21+
"clean": "rm -rf dist"
22+
},
23+
"dependencies": {
24+
"@codemirror/lang-json": "^6.0.1",
25+
"@codemirror/lang-yaml": "^6.1.2",
26+
"@codemirror/language": "^6.11.0",
27+
"@oxa/core": "workspace:*",
28+
"@oxa/react": "workspace:*",
29+
"@uiw/react-codemirror": "^4.23.10",
30+
"codemirror": "^6.0.1",
31+
"js-yaml": "^4.1.0",
32+
"react": "^19.1.0",
33+
"react-dom": "^19.1.0"
34+
},
35+
"devDependencies": {
36+
"@codemirror/state": "^6.6.0",
37+
"@tailwindcss/vite": "^4.1.10",
38+
"@types/js-yaml": "^4.0.9",
39+
"@types/react": "^19.1.8",
40+
"@types/react-dom": "^19.1.6",
41+
"@vitejs/plugin-react": "^4.5.2",
42+
"tailwindcss": "^4.1.10",
43+
"vite": "^6.3.5",
44+
"vite-plugin-dts": "^4.5.4"
45+
}
46+
}

packages/oxa-demo/src/App.tsx

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { useState, useMemo, useCallback } from "react";
2+
import yaml from "js-yaml";
3+
import { Editor } from "./components/Editor";
4+
import { FormatToggle, type Format } from "./components/FormatToggle";
5+
import { ExamplePicker } from "./components/ExamplePicker";
6+
import { TabBar, type Tab } from "./components/TabBar";
7+
import { DemoView } from "./components/DemoView";
8+
import { AtprotoView } from "./components/AtprotoView";
9+
import { ValidationBadge } from "./components/ValidationBadge";
10+
import { validate } from "./validate";
11+
import { examples } from "./examples";
12+
13+
interface AppProps {
14+
initialExample?: string;
15+
fullscreen?: boolean;
16+
}
17+
18+
function serializeDocument(
19+
doc: Record<string, unknown>,
20+
format: Format,
21+
): string {
22+
if (format === "json") {
23+
return JSON.stringify(doc, null, 2);
24+
}
25+
return yaml.dump(doc, { indent: 2, lineWidth: 80, noRefs: true });
26+
}
27+
28+
function convertFormat(
29+
source: string,
30+
from: Format,
31+
to: Format,
32+
): string | null {
33+
try {
34+
const parsed =
35+
from === "json"
36+
? JSON.parse(source)
37+
: (yaml.load(source) as Record<string, unknown>);
38+
return serializeDocument(parsed, to);
39+
} catch {
40+
return null;
41+
}
42+
}
43+
44+
export function App({
45+
initialExample = "rfc0003",
46+
fullscreen = false,
47+
}: AppProps) {
48+
const initialDoc =
49+
examples.find((e) => e.id === initialExample) ?? examples[0];
50+
51+
const [source, setSource] = useState(() =>
52+
serializeDocument(initialDoc.document, "json"),
53+
);
54+
const [format, setFormat] = useState<Format>("json");
55+
const [activeTab, setActiveTab] = useState<Tab>("demo");
56+
const [selectedExample, setSelectedExample] = useState(initialDoc.id);
57+
58+
const validation = useMemo(() => validate(source, format), [source, format]);
59+
60+
const handleFormatChange = useCallback(
61+
(newFormat: Format) => {
62+
if (newFormat === format) return;
63+
const converted = convertFormat(source, format, newFormat);
64+
if (converted !== null) {
65+
setSource(converted);
66+
setFormat(newFormat);
67+
}
68+
},
69+
[source, format],
70+
);
71+
72+
const handleExampleChange = useCallback(
73+
(id: string) => {
74+
const example = examples.find((e) => e.id === id);
75+
if (!example) return;
76+
setSelectedExample(id);
77+
setSource(serializeDocument(example.document, format));
78+
},
79+
[format],
80+
);
81+
82+
const outerClass = fullscreen
83+
? "flex flex-col w-full h-full border border-slate-200 rounded-xl overflow-hidden bg-white shadow-sm"
84+
: "flex flex-col h-[500px] border border-slate-200 rounded-xl overflow-hidden bg-white shadow-sm";
85+
86+
return (
87+
<div className={outerClass}>
88+
{/* Toolbar */}
89+
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50 border-b border-slate-200">
90+
<ExamplePicker
91+
selected={selectedExample}
92+
onChange={handleExampleChange}
93+
/>
94+
<FormatToggle format={format} onChange={handleFormatChange} />
95+
<ValidationBadge valid={validation.valid} errors={validation.errors} />
96+
<div className="ml-auto">
97+
<TabBar active={activeTab} onChange={setActiveTab} />
98+
</div>
99+
</div>
100+
101+
{/* Validation errors */}
102+
{!validation.valid &&
103+
validation.errors &&
104+
validation.errors.length > 0 && (
105+
<div className="px-4 py-2 bg-red-50 border-b border-red-200 text-sm text-red-700 font-mono overflow-auto max-h-32">
106+
{validation.errors.map((error, i) => (
107+
<div key={i} className="py-0.5">
108+
{error}
109+
</div>
110+
))}
111+
</div>
112+
)}
113+
114+
{/* Split panels */}
115+
<div className="flex flex-1 min-h-0 overflow-hidden">
116+
{/* Left: Editor */}
117+
<div className="relative flex-1 min-w-0 border-r border-slate-200">
118+
<Editor value={source} onChange={setSource} format={format} />
119+
</div>
120+
121+
{/* Right: Output */}
122+
<div className="relative flex-1 min-w-0">
123+
{activeTab === "demo" ? (
124+
<DemoView source={source} format={format} />
125+
) : (
126+
<AtprotoView source={source} format={format} />
127+
)}
128+
</div>
129+
</div>
130+
</div>
131+
);
132+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { createRoot } from "react-dom/client";
2+
import { App } from "./App";
3+
import cssText from "./styles.css?inline";
4+
5+
interface AnywidgetModel {
6+
get(key: string): unknown;
7+
set(key: string, value: unknown): void;
8+
on(event: string, callback: () => void): void;
9+
}
10+
11+
function render({ model, el }: { model: AnywidgetModel; el: HTMLElement }) {
12+
// Inject Tailwind styles into the widget container
13+
const style = document.createElement("style");
14+
style.textContent = cssText;
15+
el.appendChild(style);
16+
17+
const container = document.createElement("div");
18+
el.appendChild(container);
19+
20+
const initialExample =
21+
(model.get("example") as string | undefined) ?? "rfc0003";
22+
const fullscreen = (model.get("fullscreen") as boolean | undefined) ?? false;
23+
24+
const root = createRoot(container);
25+
root.render(<App initialExample={initialExample} fullscreen={fullscreen} />);
26+
27+
return () => root.unmount();
28+
}
29+
30+
export default { render };
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useMemo } from "react";
2+
import yaml from "js-yaml";
3+
import { oxaToAtproto, type Document, type Session } from "@oxa/core";
4+
import { Editor } from "./Editor";
5+
6+
const session: Session = { log: console };
7+
8+
interface AtprotoViewProps {
9+
source: string;
10+
format: "json" | "yaml";
11+
}
12+
13+
export function AtprotoView({ source, format }: AtprotoViewProps) {
14+
const result = useMemo(() => {
15+
try {
16+
const parsed =
17+
format === "json"
18+
? JSON.parse(source)
19+
: (yaml.load(source) as Record<string, unknown>);
20+
21+
if (!parsed || parsed.type !== "Document") {
22+
return {
23+
error: "Source must be a Document node (type: 'Document').",
24+
};
25+
}
26+
27+
const atproto = oxaToAtproto(session, parsed as Document);
28+
return { data: JSON.stringify(atproto, null, 2) };
29+
} catch (e) {
30+
return { error: String(e) };
31+
}
32+
}, [source, format]);
33+
34+
if (result.error) {
35+
return (
36+
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 font-mono whitespace-pre-wrap">
37+
{result.error}
38+
</div>
39+
);
40+
}
41+
42+
return (
43+
<Editor
44+
value={result.data!}
45+
onChange={() => {}}
46+
format="json"
47+
readOnly
48+
/>
49+
);
50+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { useMemo } from "react";
2+
import yaml from "js-yaml";
3+
import { OxaProvider, OXA, defaultRenderers } from "@oxa/react";
4+
import type { OxaNode } from "@oxa/react";
5+
6+
interface DemoViewProps {
7+
source: string;
8+
format: "json" | "yaml";
9+
}
10+
11+
export function DemoView({ source, format }: DemoViewProps) {
12+
const parsed = useMemo(() => {
13+
try {
14+
const data =
15+
format === "json"
16+
? JSON.parse(source)
17+
: (yaml.load(source) as Record<string, unknown>);
18+
19+
if (!data || typeof data !== "object" || !("type" in data)) {
20+
return { error: "Source must have a 'type' field." };
21+
}
22+
23+
return { node: data as OxaNode };
24+
} catch (e) {
25+
return { error: String(e) };
26+
}
27+
}, [source, format]);
28+
29+
if (parsed.error) {
30+
return (
31+
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 font-mono whitespace-pre-wrap">
32+
{parsed.error}
33+
</div>
34+
);
35+
}
36+
37+
return (
38+
<div className="absolute inset-0 overflow-auto p-4 prose prose-slate max-w-none">
39+
<OxaProvider renderers={defaultRenderers}>
40+
<OXA ast={parsed.node} />
41+
</OxaProvider>
42+
</div>
43+
);
44+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useMemo } from "react";
2+
import CodeMirror from "@uiw/react-codemirror";
3+
import { json } from "@codemirror/lang-json";
4+
import { yaml } from "@codemirror/lang-yaml";
5+
import { foldGutter } from "@codemirror/language";
6+
import type { Extension } from "@codemirror/state";
7+
8+
interface EditorProps {
9+
value: string;
10+
onChange: (value: string) => void;
11+
format?: "json" | "yaml";
12+
readOnly?: boolean;
13+
}
14+
15+
export function Editor({
16+
value,
17+
onChange,
18+
format = "json",
19+
readOnly = false,
20+
}: EditorProps) {
21+
const extensions = useMemo<Extension[]>(() => {
22+
const exts: Extension[] = [foldGutter()];
23+
if (format === "json") {
24+
exts.push(json());
25+
} else {
26+
exts.push(yaml());
27+
}
28+
return exts;
29+
}, [format]);
30+
31+
return (
32+
<CodeMirror
33+
value={value}
34+
onChange={readOnly ? undefined : onChange}
35+
extensions={extensions}
36+
readOnly={readOnly}
37+
theme="dark"
38+
basicSetup={{
39+
lineNumbers: true,
40+
foldGutter: false,
41+
bracketMatching: true,
42+
closeBrackets: !readOnly,
43+
indentOnInput: !readOnly,
44+
highlightActiveLine: !readOnly,
45+
tabSize: 2,
46+
}}
47+
height="100%"
48+
style={{ position: "absolute", inset: 0 }}
49+
/>
50+
);
51+
}

0 commit comments

Comments
 (0)