From 5621b0d2ddfdd8d40ab2115ef99744076bf991c0 Mon Sep 17 00:00:00 2001 From: joshunrau Date: Fri, 12 Jun 2026 14:19:24 -0400 Subject: [PATCH 1/2] feat(playground-url): add package to generate playground share links Add @opendatacapture/playground-url, which encodes an instrument's source files into a shareable playground link (lz-string compressed, matching the playground's existing URL scheme). Exposes a library (generatePlaygroundURL, encodeShareURL, decodeShareURL, isFullscreenShareURL) and a `playground-url` CLI that points at an instrument directory and emits the link. Move the share-URL encode/decode logic out of apps/playground into the new package so the app and CLI share one implementation, and have the playground consume it (passing baseURL: window.location.origin to preserve dev behavior). Co-Authored-By: Claude Opus 4.8 --- apps/playground/package.json | 2 +- .../Header/ShareButton/ShareButton.tsx | 15 ++- apps/playground/src/pages/IndexPage.tsx | 2 +- apps/playground/src/utils/encode.ts | 46 -------- packages/playground-url/README.md | 46 ++++++++ packages/playground-url/package.json | 40 +++++++ packages/playground-url/scripts/build.js | 34 ++++++ packages/playground-url/src/cli.ts | 108 ++++++++++++++++++ packages/playground-url/src/index.ts | 10 ++ packages/playground-url/src/models.ts | 20 ++++ packages/playground-url/src/share-url.test.ts | 60 ++++++++++ packages/playground-url/src/share-url.ts | 73 ++++++++++++ packages/playground-url/tsconfig.json | 7 ++ packages/playground-url/vitest.config.ts | 13 +++ pnpm-lock.yaml | 25 +++- 15 files changed, 447 insertions(+), 54 deletions(-) delete mode 100644 apps/playground/src/utils/encode.ts create mode 100644 packages/playground-url/README.md create mode 100644 packages/playground-url/package.json create mode 100644 packages/playground-url/scripts/build.js create mode 100644 packages/playground-url/src/cli.ts create mode 100644 packages/playground-url/src/index.ts create mode 100644 packages/playground-url/src/models.ts create mode 100644 packages/playground-url/src/share-url.test.ts create mode 100644 packages/playground-url/src/share-url.ts create mode 100644 packages/playground-url/tsconfig.json create mode 100644 packages/playground-url/vitest.config.ts diff --git a/apps/playground/package.json b/apps/playground/package.json index a5133a021..5125538d0 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -17,6 +17,7 @@ "@douglasneuroinformatics/libui": "catalog:", "@monaco-editor/react": "^4.7.0", "@opendatacapture/instrument-bundler": "workspace:*", + "@opendatacapture/playground-url": "workspace:*", "@opendatacapture/react-core": "workspace:*", "@opendatacapture/runtime-core": "workspace:*", "@opendatacapture/runtime-v1": "workspace:*", @@ -31,7 +32,6 @@ "jwt-decode": "^4.0.0", "lodash-es": "workspace:lodash-es__4.x@*", "lucide-react": "^0.503.0", - "lz-string": "^1.5.0", "monaco-editor": "^0.52.2", "motion": "catalog:", "neverthrow": "catalog:", diff --git a/apps/playground/src/components/Header/ShareButton/ShareButton.tsx b/apps/playground/src/components/Header/ShareButton/ShareButton.tsx index 1e21f3731..7f2430539 100644 --- a/apps/playground/src/components/Header/ShareButton/ShareButton.tsx +++ b/apps/playground/src/components/Header/ShareButton/ShareButton.tsx @@ -3,18 +3,20 @@ import { useEffect, useState } from 'react'; import { formatByteSize } from '@douglasneuroinformatics/libjs'; import { Heading, Input, Label, Popover, Tooltip } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { encodeShareURL } from '@opendatacapture/playground-url'; import { CopyButton } from '@opendatacapture/react-core'; import { CircleHelpIcon, Share2Icon } from 'lucide-react'; import { useFilesRef } from '@/hooks/useFilesRef'; import { useAppStore } from '@/store'; -import { encodeShareURL } from '@/utils/encode'; export const ShareButton = () => { const label = useAppStore((store) => store.selectedInstrument.label); const editorFilesRef = useFilesRef(); const [isFullscreen, setIsFullscreen] = useState(false); - const [shareURL, setShareURL] = useState(encodeShareURL({ files: editorFilesRef.current, label })); + const [shareURL, setShareURL] = useState( + encodeShareURL({ baseURL: window.location.origin, files: editorFilesRef.current, label }) + ); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isTooltipOpen, setIsTooltipOpen] = useState(false); const { t } = useTranslation(); @@ -22,7 +24,14 @@ export const ShareButton = () => { // The user cannot modify the editor without closing the popover useEffect(() => { if (isPopoverOpen) { - setShareURL(encodeShareURL({ files: editorFilesRef.current, fullscreen: isFullscreen, label })); + setShareURL( + encodeShareURL({ + baseURL: window.location.origin, + files: editorFilesRef.current, + fullscreen: isFullscreen, + label + }) + ); } }, [isFullscreen, isPopoverOpen, label]); diff --git a/apps/playground/src/pages/IndexPage.tsx b/apps/playground/src/pages/IndexPage.tsx index c8428ea64..c0fe61ace 100644 --- a/apps/playground/src/pages/IndexPage.tsx +++ b/apps/playground/src/pages/IndexPage.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { LanguageToggle, Separator, ThemeToggle } from '@douglasneuroinformatics/libui/components'; +import { decodeShareURL, isFullscreenShareURL } from '@opendatacapture/playground-url'; import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'; import { Header } from '@/components/Header'; @@ -8,7 +9,6 @@ import { MainContent } from '@/components/MainContent'; import { Viewer } from '@/components/Viewer'; import type { InstrumentRepository } from '@/models/instrument-repository.model'; import { useAppStore } from '@/store'; -import { decodeShareURL, isFullscreenShareURL } from '@/utils/encode'; const { initialize } = await import('esbuild-wasm'); await initialize({ diff --git a/apps/playground/src/utils/encode.ts b/apps/playground/src/utils/encode.ts deleted file mode 100644 index 4705155c5..000000000 --- a/apps/playground/src/utils/encode.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'; -import { z } from 'zod/v4'; - -import { $EditorFile } from '@/models/editor-file.model'; -import type { EditorFile } from '@/models/editor-file.model'; -import type { InstrumentRepository } from '@/models/instrument-repository.model'; - -type ShareURL = URL & { size: number }; - -const $EditorFiles = z.array($EditorFile); - -function decodeFiles(encodedFiles: string): EditorFile[] { - return $EditorFiles.parse(JSON.parse(decompressFromEncodedURIComponent(encodedFiles))); -} - -function encodeFiles(files: EditorFile[]): string { - return compressToEncodedURIComponent(JSON.stringify($EditorFiles.parse(files))); -} - -export function encodeShareURL({ - files, - fullscreen, - label -}: Pick & { fullscreen?: boolean }): ShareURL { - const url = new URL(location.origin) as ShareURL; - url.searchParams.append('files', encodeFiles(files)); - url.searchParams.append('label', compressToEncodedURIComponent(label)); - if (fullscreen) { - url.searchParams.append('fullscreen', '1'); - } - url.size = new TextEncoder().encode(url.href).length; - return url; -} - -export function isFullscreenShareURL(url: URL): boolean { - return url.searchParams.get('fullscreen') === '1'; -} - -export function decodeShareURL(url: URL): null | Pick { - const encodedFiles = url.searchParams.get('files'); - const encodedLabel = url.searchParams.get('label'); - if (!(encodedFiles && encodedLabel)) { - return null; - } - return { files: decodeFiles(encodedFiles), label: decompressFromEncodedURIComponent(encodedLabel) }; -} diff --git a/packages/playground-url/README.md b/packages/playground-url/README.md new file mode 100644 index 000000000..c9a232d56 --- /dev/null +++ b/packages/playground-url/README.md @@ -0,0 +1,46 @@ +# @opendatacapture/playground-url + +Generate shareable [Open Data Capture playground](https://playground.opendatacapture.org) links from instrument source files. + +A playground link embeds a snapshot of an instrument's source files directly in the URL (lz-string compressed). Anyone who opens the link gets that instrument loaded into the playground — no server or account required. + +## Library + +```ts +import { generatePlaygroundURL } from '@opendatacapture/playground-url'; + +const url = generatePlaygroundURL({ + files: [{ name: 'index.ts', content: 'export default { /* ... */ };' }], + label: 'My Instrument' +}); +// => https://playground.opendatacapture.org/?files=...&label=... +``` + +| Export | Description | +| -------------------------------- | ----------------------------------------------------------- | +| `generatePlaygroundURL(options)` | Returns the share link as a string. | +| `encodeShareURL(options)` | Returns a `URL` annotated with the encoded `size` in bytes. | +| `decodeShareURL(url)` | Decodes an instrument from a share URL (or `null`). | +| `isFullscreenShareURL(url)` | Whether the link opens the read-only fullscreen preview. | +| `DEFAULT_PLAYGROUND_URL` | Origin of the hosted playground. | + +Options: `files`, `label`, optional `fullscreen` (read-only preview) and `baseURL` (defaults to the hosted playground). + +## CLI + +Point it at a directory of instrument source files: + +```sh +npx @opendatacapture/playground-url ./my-instrument +``` + +The status line is written to stderr and the link to stdout, so it pipes cleanly. + +| Option | Description | +| ---------------------- | ----------------------------------------------------------------- | +| `-l, --label