diff --git a/.gitignore b/.gitignore index 33946f6..7cc67a7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules dist *.tsbuildinfo +examples/**/dist + .DS_Store Thumbs.db @@ -11,4 +13,3 @@ Thumbs.db !.yarn/releases !.yarn/sdks !.yarn/versions -.pnp.* diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/README.md b/README.md index d7ae673..4ac519a 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,23 @@ yarn typecheck # type-only check Outputs land in `dist/`: ESM (`.js`), CJS (`.cjs`), declarations (`.d.ts`), and the editor stylesheet. +## Demo app + +A tiny Vite playground lives in [`examples/demo`](./examples/demo). It mounts +`` against a stub adapter with a minimal XRD + Composition so +you can drive the UI end-to-end without a host application. It's wired up as a +Yarn workspace, so `yarn install` at the repo root sets it up alongside the +library. + +```bash +yarn install # installs the library + demo workspace +yarn demo # starts the Vite dev server +yarn demo:build # production build of the demo +``` + +The demo imports the library straight from `../../src`, so editing files under +`src/` hot-reloads inside the demo. + ## License MIT diff --git a/eslint.config.mjs b/eslint.config.mjs index 3589ef7..173421e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,13 +6,20 @@ import globals from 'globals'; export default tseslint.config( { - ignores: ['dist/**', 'node_modules/**', '.yarn/**', '.claude/**'], + ignores: [ + 'dist/**', + 'node_modules/**', + '.yarn/**', + '.claude/**', + 'examples/**/dist/**', + 'examples/**/node_modules/**', + ], }, js.configs.recommended, ...tseslint.configs.recommended, react.configs.flat.recommended, { - files: ['src/**/*.{ts,tsx}'], + files: ['src/**/*.{ts,tsx}', 'examples/**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 'latest', sourceType: 'module', diff --git a/examples/demo/README.md b/examples/demo/README.md new file mode 100644 index 0000000..52abf5e --- /dev/null +++ b/examples/demo/README.md @@ -0,0 +1,46 @@ +# composer-demo + +Tiny Vite + React app for poking at `@overlock-studio/composer` without a host. +It's a Yarn workspace under the parent repo and imports the library directly +from `../../src`, so edits to the library hot-reload. + +## Run + +From the repo root: + +```bash +yarn install # installs the workspace, hoists everything to ./node_modules +yarn demo # vite dev server, opens http://localhost:5173 +yarn demo:build +``` + +Or from inside this folder: + +```bash +yarn dev +yarn build +``` + +## What it does + +- Loads a minimal Crossplane bundle (`crossplane.yaml` + XRD + one Composition with a `nop` managed resource). +- Mounts `` with a stubbed `EditorDataAdapter`. +- Captures the `onSave` payload and renders it in a side panel for inspection. +- A header button calls the imperative `editorRef.current?.save()` to exercise the ref handle. + +## Editing the library + +Source resolution goes through Vite aliases in `vite.config.ts`: + +| Import | Resolves to | +| ----------------------------------------------- | ------------------------------------ | +| `@overlock-studio/composer` | `../../src/index.ts` | +| `@overlock-studio/composer/styles/editor.css` | `../../src/styles/editor.css` | + +So any change in `composer/src/**` triggers HMR in the demo. + +## Tailwind + +The library ships utility classes. Tailwind is configured locally +(`tailwind.config.ts`) to scan both demo and library sources, and +`src/index.css` defines the shadcn-style HSL CSS variables the library expects. diff --git a/examples/demo/index.html b/examples/demo/index.html new file mode 100644 index 0000000..d146106 --- /dev/null +++ b/examples/demo/index.html @@ -0,0 +1,12 @@ + + + + + + Composer Demo + + +
+ + + diff --git a/examples/demo/package.json b/examples/demo/package.json new file mode 100644 index 0000000..f2c4869 --- /dev/null +++ b/examples/demo/package.json @@ -0,0 +1,27 @@ +{ + "name": "@overlock-studio/composer-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@overlock-studio/composer": "workspace:*", + "@xyflow/react": "^12.10.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.1.10", + "@types/react-dom": "^19.0.2", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.6.3", + "vite": "^6.0.7" + } +} diff --git a/examples/demo/postcss.config.js b/examples/demo/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/examples/demo/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/examples/demo/src/App.tsx b/examples/demo/src/App.tsx new file mode 100644 index 0000000..b3b45b9 --- /dev/null +++ b/examples/demo/src/App.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Button, + ComposerEditor, + type ComposerEditorHandle, + type ComposerSavePayload, +} from '@overlock-studio/composer'; +import '@overlock-studio/composer/styles/editor.css'; +import '@xyflow/react/dist/style.css'; + +import { demoAdapter } from './adapter'; +import { sampleFiles, sampleHashes, sampleLayout } from './sample'; + +type Theme = 'light' | 'dark'; + +export default function App() { + const editorRef = useRef(null); + const [lastSave, setLastSave] = useState(null); + const [showPayload, setShowPayload] = useState(false); + const [theme, setTheme] = useState(() => + document.documentElement.classList.contains('dark') ? 'dark' : 'light', + ); + + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark'); + localStorage.setItem('composer-demo-theme', theme); + }, [theme]); + + const handleSave = (payload: ComposerSavePayload) => { + setLastSave(payload); + setShowPayload(true); + console.log('[composer-demo] onSave payload', payload); + }; + + return ( +
+
+
+

Composer demo

+ + Mounts ComposerEditor with a minimal XRD + Composition. + +
+
+ + + +
+
+ +
+
+ +
+ {showPayload && ( + + )} +
+
+ ); +} diff --git a/examples/demo/src/adapter.ts b/examples/demo/src/adapter.ts new file mode 100644 index 0000000..8f8c8cc --- /dev/null +++ b/examples/demo/src/adapter.ts @@ -0,0 +1,79 @@ +import { + crossplaneCoreBlockTypes, + isCrossplaneCoreUrl, + type BlockType, + type ConfigurationDB, + type CrossplaneProviderDB, + type EditorDataAdapter, +} from '@overlock-studio/composer'; + +import { sampleDependencies } from './sample'; + +const providers: CrossplaneProviderDB[] = sampleDependencies + .filter((d) => d.kind === 'provider') + .map((d, idx) => ({ + _id: `${idx + 1}`, + title: d.package.split('/').pop() ?? d.package, + description: `Demo provider entry parsed from crossplane.yaml (${d.package})`, + icon: '', + family: d.package.split('/').slice(-2, -1)[0] ?? 'provider', + familyName: d.package.split('/').slice(-2, -1)[0] ?? undefined, + url: d.package, + version: d.version.replace(/^[>=<~^ ]+/, '') || undefined, + })); + +const configuration: ConfigurationDB = { + _id: 'composer', + name: 'demo-configuration', + providers: providers.map((p) => p._id), + functions: [], + deployId: null, +}; + +const fetchFromServer = async (url: string): Promise => { + const res = await fetch( + `/api/blocktypes?url=${encodeURIComponent(url)}`, + ); + if (!res.ok) { + const body = await res.text(); + throw new Error( + `/api/blocktypes ${res.status}: ${body || res.statusText}`, + ); + } + return (await res.json()) as BlockType[]; +}; + +export const demoAdapter: EditorDataAdapter = { + getBlocks: async () => [], + updateBlocks: async () => true, + getBlockTypes: async (url) => { + if (isCrossplaneCoreUrl(url)) { + return crossplaneCoreBlockTypes; + } + try { + return await fetchFromServer(url); + } catch (err) { + console.warn(`[composer-demo] getBlockTypes(${url}) failed:`, err); + return []; + } + }, + getConfiguration: async (id) => + id === configuration._id ? configuration : null, + getTemplate: async () => null, + listCrossplaneProviders: async () => ({ + crossplaneProviders: providers, + totalCount: providers.length, + }), + getConfigurationData: async () => ({ + compositions: [], + xrdBlockType: [], + providerUrls: providers.map((p) => + p.version ? `${p.url}:${p.version}` : p.url, + ), + functionUrls: [], + }), + createConfiguration: async () => configuration._id, + updateConfiguration: async () => undefined, + createProvidersFromUrls: async (urls) => urls, + createFunctionsFromUrls: async (urls) => urls, +}; diff --git a/examples/demo/src/index.css b/examples/demo/src/index.css new file mode 100644 index 0000000..989a1b0 --- /dev/null +++ b/examples/demo/src/index.css @@ -0,0 +1,78 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 240 5.9% 10%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 240 5.9% 10%; + --radius: 0.5rem; + + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 5.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 5.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; + + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + html, + body, + #root { + height: 100%; + } + + body { + @apply bg-background text-foreground; + } +} diff --git a/examples/demo/src/main.tsx b/examples/demo/src/main.tsx new file mode 100644 index 0000000..3abae10 --- /dev/null +++ b/examples/demo/src/main.tsx @@ -0,0 +1,19 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './index.css'; + +const rootEl = document.getElementById('root'); +if (!rootEl) { + throw new Error('Missing #root element'); +} + +const stored = localStorage.getItem('composer-demo-theme'); +const initialTheme = stored === 'light' ? 'light' : 'dark'; +document.documentElement.classList.toggle('dark', initialTheme === 'dark'); + +createRoot(rootEl).render( + + + , +); diff --git a/examples/demo/src/sample.ts b/examples/demo/src/sample.ts new file mode 100644 index 0000000..3386754 --- /dev/null +++ b/examples/demo/src/sample.ts @@ -0,0 +1,29 @@ +import { + parseCrossplaneDependencies, + type CrossplaneFile, + type LayoutByComposition, + type PackageDependency, +} from '@overlock-studio/composer'; + +import crossplaneYaml from './samples/crossplane.yaml?raw'; +import xrdYaml from './samples/xrd.yaml?raw'; +import compositionYaml from './samples/composition.yaml?raw'; +import layoutJson from './samples/layout.json'; + +export const sampleFiles: CrossplaneFile[] = [ + { name: 'crossplane.yaml', content: crossplaneYaml }, + { name: 'xrd.yaml', content: xrdYaml }, + { name: 'composition.yaml', content: compositionYaml }, +]; + +export const sampleLayout: LayoutByComposition = + layoutJson as LayoutByComposition; + +export const sampleHashes: Record = { + 'crossplane.yaml': 'h-crossplane-1', + 'xrd.yaml': 'h-xrd-1', + 'composition.yaml': 'h-composition-1', +}; + +export const sampleDependencies: PackageDependency[] = + parseCrossplaneDependencies(crossplaneYaml); diff --git a/examples/demo/src/samples/composition.yaml b/examples/demo/src/samples/composition.yaml new file mode 100644 index 0000000..46075bb --- /dev/null +++ b/examples/demo/src/samples/composition.yaml @@ -0,0 +1,23 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: demo-app-composition +spec: + compositeTypeRef: + apiVersion: demo.example.org/v1alpha1 + kind: XDemoApp + resources: + - name: nop-resource + base: + apiVersion: nop.crossplane.io/v1alpha1 + kind: NopResource + spec: + forProvider: + conditionAfter: + - conditionType: Ready + conditionStatus: "True" + time: 5s + patches: + - type: FromCompositeFieldPath + fromFieldPath: spec.size + toFieldPath: spec.forProvider.fields.size diff --git a/examples/demo/src/samples/crossplane.yaml b/examples/demo/src/samples/crossplane.yaml new file mode 100644 index 0000000..9667d67 --- /dev/null +++ b/examples/demo/src/samples/crossplane.yaml @@ -0,0 +1,12 @@ +apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: demo-configuration +spec: + crossplane: + version: ">=v1.14.0" + dependsOn: + - provider: xpkg.upbound.io/crossplane-contrib/provider-nop + version: ">=v0.4.0" + - provider: xpkg.upbound.io/upbound/provider-family-gcp + version: ">=v2.5.3" diff --git a/examples/demo/src/samples/layout.json b/examples/demo/src/samples/layout.json new file mode 100644 index 0000000..10d447c --- /dev/null +++ b/examples/demo/src/samples/layout.json @@ -0,0 +1,6 @@ +{ + "demo-app-composition": { + "_self": { "x": 80, "y": 80, "width": 480, "height": 360 }, + "nop-resource": { "x": 40, "y": 80 } + } +} diff --git a/examples/demo/src/samples/xrd.yaml b/examples/demo/src/samples/xrd.yaml new file mode 100644 index 0000000..ce818a0 --- /dev/null +++ b/examples/demo/src/samples/xrd.yaml @@ -0,0 +1,28 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xdemoapps.demo.example.org +spec: + group: demo.example.org + names: + kind: XDemoApp + plural: xdemoapps + claimNames: + kind: DemoApp + plural: demoapps + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + size: + type: string + description: t-shirt size of the app + required: + - size diff --git a/examples/demo/src/vite-env.d.ts b/examples/demo/src/vite-env.d.ts new file mode 100644 index 0000000..8e9fc56 --- /dev/null +++ b/examples/demo/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// + +declare module '*.yaml?raw' { + const content: string; + export default content; +} diff --git a/examples/demo/tailwind.config.ts b/examples/demo/tailwind.config.ts new file mode 100644 index 0000000..7554d20 --- /dev/null +++ b/examples/demo/tailwind.config.ts @@ -0,0 +1,67 @@ +import type { Config } from 'tailwindcss'; + +const config: Config = { + darkMode: ['class'], + content: [ + './index.html', + './src/**/*.{ts,tsx}', + '../../src/**/*.{ts,tsx}', + ], + theme: { + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + sidebar: { + DEFAULT: 'hsl(var(--sidebar-background))', + foreground: 'hsl(var(--sidebar-foreground))', + primary: 'hsl(var(--sidebar-primary))', + 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', + accent: 'hsl(var(--sidebar-accent))', + 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', + border: 'hsl(var(--sidebar-border))', + ring: 'hsl(var(--sidebar-ring))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + }, + }, + plugins: [], +}; + +export default config; diff --git a/examples/demo/tsconfig.json b/examples/demo/tsconfig.json new file mode 100644 index 0000000..4c13859 --- /dev/null +++ b/examples/demo/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "allowImportingTsExtensions": false, + "baseUrl": ".", + "paths": { + "@overlock-studio/composer": ["../../src/index.ts"], + "@overlock-studio/composer/styles/editor.css": [ + "../../src/styles/editor.css" + ], + "@/*": ["../../src/*"] + } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/demo/vite.config.ts b/examples/demo/vite.config.ts new file mode 100644 index 0000000..5af653a --- /dev/null +++ b/examples/demo/vite.config.ts @@ -0,0 +1,64 @@ +import { defineConfig, type Plugin } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; + +const composerSrc = path.resolve(__dirname, '../../src'); + +// Server-side OCI fetch endpoint. The library's fetchBlockTypes uses Node +// modules (zlib, tar-stream), so it can't run in the browser. We expose it +// here as /api/blocktypes?url=... so the demo's adapter can hit it. +function blocksApi(): Plugin { + return { + name: 'composer-demo-blocks-api', + configureServer(server) { + server.middlewares.use('/api/blocktypes', async (req, res) => { + const url = new URL(req.url ?? '', 'http://localhost'); + const image = url.searchParams.get('url'); + if (!image) { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'missing url query param' })); + return; + } + try { + const { fetchBlockTypes } = await server.ssrLoadModule( + path.join(composerSrc, 'oci/client.ts'), + ); + const blockTypes = await fetchBlockTypes(image); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(blockTypes)); + } catch (err) { + console.error('[composer-demo] /api/blocktypes failed', err); + res.statusCode = 500; + res.end( + JSON.stringify({ + error: err instanceof Error ? err.message : String(err), + }), + ); + } + }); + }, + }; +} + +export default defineConfig({ + plugins: [blocksApi(), react()], + resolve: { + alias: { + '@overlock-studio/composer/styles/editor.css': path.join( + composerSrc, + 'styles/editor.css', + ), + '@overlock-studio/composer/lib/parser': path.join( + composerSrc, + 'lib/parser.ts', + ), + '@overlock-studio/composer': path.join(composerSrc, 'index.ts'), + '@': composerSrc, + }, + dedupe: ['react', 'react-dom', '@xyflow/react'], + }, + server: { + port: 5173, + open: true, + }, +}); diff --git a/package.json b/package.json index da76812..97220bf 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "dist", "src" ], + "workspaces": [ + "examples/*" + ], "sideEffects": [ "*.css" ], @@ -43,7 +46,9 @@ "clean": "rm -rf dist", "typecheck": "tsc --noEmit", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "demo": "yarn workspace @overlock-studio/composer-demo dev", + "demo:build": "yarn workspace @overlock-studio/composer-demo build" }, "peerDependencies": { "@xyflow/react": "^12.10.0", diff --git a/src/components/Editor/ComposerEditor/ComposerEditor.tsx b/src/components/Editor/ComposerEditor/ComposerEditor.tsx index 94a1339..7162447 100644 --- a/src/components/Editor/ComposerEditor/ComposerEditor.tsx +++ b/src/components/Editor/ComposerEditor/ComposerEditor.tsx @@ -253,7 +253,7 @@ function ComposerEditorBody({ return ( -
+