Skip to content
Merged

Demo #46

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
5 changes: 4 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
"changelog": ["@changesets/changelog-github", { "repo": "oxa-dev/oxa" }],
"commit": false,
"fixed": [["@oxa/core", "oxa"]],
"fixed": [
["@oxa/core", "oxa"],
["@oxa/demo", "@oxa/react"]
],
"linked": [],
"access": "public",
"baseBranch": "main",
Expand Down
6 changes: 6 additions & 0 deletions .changeset/light-glasses-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@oxa/react": patch
"@oxa/demo": patch
---

Initial release of demo and react libraries for oxa
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "demo",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@oxa/demo", "dev"],
"port": 5173
}
]
}
3 changes: 3 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ A foundation for interoperable, structured scientific content.
The **Open Exchange Architecture (OXA)** is a specification for representing scientific documents and their components as structured JSON objects.
It’s designed to enable **exchange, interoperability, and long-term preservation** of scientific knowledge, while remaining compatible with modern web and data standards.

:::{anywidget} https://cdn.jsdelivr.net/npm/@oxa/demo/dist/anywidget.js
:::

OXA provides schemas and examples for representing:

- Executable and interactive research components
Expand Down
12 changes: 12 additions & 0 deletions packages/oxa-demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OXA Demo</title>
</head>
<body style="margin: 0; height: 100vh;">
<div id="root" style="height: 100%;"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
46 changes: 46 additions & 0 deletions packages/oxa-demo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "@oxa/demo",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./anywidget": "./dist/anywidget.js"
},
"files": [
"dist"
],
"scripts": {
"dev": "vite",
"build": "vite build && vite build --config vite.config.anywidget.ts",
"build:lib": "vite build",
"build:anywidget": "vite build --config vite.config.anywidget.ts",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@oxa/core": "workspace:*",
"@oxa/react": "workspace:*",
"@uiw/react-codemirror": "^4.23.10",
"codemirror": "^6.0.1",
"js-yaml": "^4.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@codemirror/state": "^6.6.0",
"@tailwindcss/vite": "^4.1.10",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"tailwindcss": "^4.1.10",
"vite": "^6.3.5",
"vite-plugin-dts": "^4.5.4"
}
}
132 changes: 132 additions & 0 deletions packages/oxa-demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useState, useMemo, useCallback } from "react";
import yaml from "js-yaml";
import { Editor } from "./components/Editor";
import { FormatToggle, type Format } from "./components/FormatToggle";
import { ExamplePicker } from "./components/ExamplePicker";
import { TabBar, type Tab } from "./components/TabBar";
import { DemoView } from "./components/DemoView";
import { AtprotoView } from "./components/AtprotoView";
import { ValidationBadge } from "./components/ValidationBadge";
import { validate } from "./validate";
import { examples } from "./examples";

interface AppProps {
initialExample?: string;
fullscreen?: boolean;
}

function serializeDocument(
doc: Record<string, unknown>,
format: Format,
): string {
if (format === "json") {
return JSON.stringify(doc, null, 2);
}
return yaml.dump(doc, { indent: 2, lineWidth: 80, noRefs: true });
}

function convertFormat(
source: string,
from: Format,
to: Format,
): string | null {
try {
const parsed =
from === "json"
? JSON.parse(source)
: (yaml.load(source) as Record<string, unknown>);
return serializeDocument(parsed, to);
} catch {
return null;
}
}

export function App({
initialExample = "rfc0003",
fullscreen = false,
}: AppProps) {
const initialDoc =
examples.find((e) => e.id === initialExample) ?? examples[0];

const [source, setSource] = useState(() =>
serializeDocument(initialDoc.document, "json"),
);
const [format, setFormat] = useState<Format>("json");
const [activeTab, setActiveTab] = useState<Tab>("demo");
const [selectedExample, setSelectedExample] = useState(initialDoc.id);

const validation = useMemo(() => validate(source, format), [source, format]);

const handleFormatChange = useCallback(
(newFormat: Format) => {
if (newFormat === format) return;
const converted = convertFormat(source, format, newFormat);
if (converted !== null) {
setSource(converted);
setFormat(newFormat);
}
},
[source, format],
);

const handleExampleChange = useCallback(
(id: string) => {
const example = examples.find((e) => e.id === id);
if (!example) return;
setSelectedExample(id);
setSource(serializeDocument(example.document, format));
},
[format],
);

const outerClass = fullscreen
? "flex flex-col w-full h-full border border-slate-200 rounded-xl overflow-hidden bg-white shadow-sm"
: "flex flex-col h-[500px] border border-slate-200 rounded-xl overflow-hidden bg-white shadow-sm";

return (
<div className={outerClass}>
{/* Toolbar */}
<div className="flex items-center gap-3 px-4 py-2 bg-slate-50 border-b border-slate-200">
<ExamplePicker
selected={selectedExample}
onChange={handleExampleChange}
/>
<FormatToggle format={format} onChange={handleFormatChange} />
<ValidationBadge valid={validation.valid} errors={validation.errors} />
<div className="ml-auto">
<TabBar active={activeTab} onChange={setActiveTab} />
</div>
</div>

{/* Validation errors */}
{!validation.valid &&
validation.errors &&
validation.errors.length > 0 && (
<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">
{validation.errors.map((error, i) => (
<div key={i} className="py-0.5">
{error}
</div>
))}
</div>
)}

{/* Split panels */}
<div className="flex flex-1 min-h-0 overflow-hidden">
{/* Left: Editor */}
<div className="relative flex-1 min-w-0 border-r border-slate-200">
<Editor value={source} onChange={setSource} format={format} />
</div>

{/* Right: Output */}
<div className="relative flex-1 min-w-0">
{activeTab === "demo" ? (
<DemoView source={source} format={format} />
) : (
<AtprotoView source={source} format={format} />
)}
</div>
</div>
</div>
);
}
30 changes: 30 additions & 0 deletions packages/oxa-demo/src/anywidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import cssText from "./styles.css?inline";

interface AnywidgetModel {
get(key: string): unknown;
set(key: string, value: unknown): void;
on(event: string, callback: () => void): void;
}

function render({ model, el }: { model: AnywidgetModel; el: HTMLElement }) {
// Inject Tailwind styles into the widget container
const style = document.createElement("style");
style.textContent = cssText;
el.appendChild(style);

const container = document.createElement("div");
el.appendChild(container);

const initialExample =
(model.get("example") as string | undefined) ?? "rfc0003";
const fullscreen = (model.get("fullscreen") as boolean | undefined) ?? false;

const root = createRoot(container);
root.render(<App initialExample={initialExample} fullscreen={fullscreen} />);

return () => root.unmount();
}

export default { render };
50 changes: 50 additions & 0 deletions packages/oxa-demo/src/components/AtprotoView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useMemo } from "react";
import yaml from "js-yaml";
import { oxaToAtproto, type Document, type Session } from "@oxa/core";
import { Editor } from "./Editor";

const session: Session = { log: console };

interface AtprotoViewProps {
source: string;
format: "json" | "yaml";
}

export function AtprotoView({ source, format }: AtprotoViewProps) {
const result = useMemo(() => {
try {
const parsed =
format === "json"
? JSON.parse(source)
: (yaml.load(source) as Record<string, unknown>);

if (!parsed || parsed.type !== "Document") {
return {
error: "Source must be a Document node (type: 'Document').",
};
}

const atproto = oxaToAtproto(session, parsed as Document);
return { data: JSON.stringify(atproto, null, 2) };
} catch (e) {
return { error: String(e) };
}
}, [source, format]);

if (result.error) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 font-mono whitespace-pre-wrap">
{result.error}
</div>
);
}

return (
<Editor
value={result.data!}
onChange={() => {}}
format="json"
readOnly
/>
);
}
44 changes: 44 additions & 0 deletions packages/oxa-demo/src/components/DemoView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useMemo } from "react";
import yaml from "js-yaml";
import { OxaProvider, OXA, defaultRenderers } from "@oxa/react";
import type { OxaNode } from "@oxa/react";

interface DemoViewProps {
source: string;
format: "json" | "yaml";
}

export function DemoView({ source, format }: DemoViewProps) {
const parsed = useMemo(() => {
try {
const data =
format === "json"
? JSON.parse(source)
: (yaml.load(source) as Record<string, unknown>);

if (!data || typeof data !== "object" || !("type" in data)) {
return { error: "Source must have a 'type' field." };
}

return { node: data as OxaNode };
} catch (e) {
return { error: String(e) };
}
}, [source, format]);

if (parsed.error) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700 font-mono whitespace-pre-wrap">
{parsed.error}
</div>
);
}

return (
<div className="absolute inset-0 overflow-auto p-4 prose prose-slate max-w-none">
<OxaProvider renderers={defaultRenderers}>
<OXA ast={parsed.node} />
</OxaProvider>
</div>
);
}
Loading
Loading