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
1 change: 1 addition & 0 deletions apps/outreach/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"@douglasneuroinformatics/libui": "catalog:",
"@opendatacapture/licenses": "workspace:*",
"@opendatacapture/runtime-v1": "workspace:*",
"@opendatacapture/schemas": "workspace:*",
"clsx": "^2.1.1",
"lodash-es": "workspace:lodash-es__4.x@*",
Expand Down
2 changes: 1 addition & 1 deletion apps/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,35 @@ 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();

// 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]);

Expand Down
2 changes: 1 addition & 1 deletion apps/playground/src/pages/IndexPage.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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';
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({
Expand Down
46 changes: 0 additions & 46 deletions apps/playground/src/utils/encode.ts

This file was deleted.

46 changes: 46 additions & 0 deletions packages/playground-url/README.md
Original file line number Diff line number Diff line change
@@ -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 <label>` | Label for the shared instrument (defaults to the directory name). |
| `-f, --fullscreen` | Share a read-only fullscreen preview instead of the editor. |
| `-b, --base-url <url>` | Playground origin to link to (defaults to the hosted playground). |
| `-o, --open` | Open the generated link in your default browser. |

Only text source files (`.css`, `.html`, `.js`, `.jsx`, `.json`, `.ts`, `.tsx`) are embedded; binary assets cannot be represented in a share URL and are skipped with a warning.
40 changes: 40 additions & 0 deletions packages/playground-url/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@opendatacapture/playground-url",
"type": "module",
"version": "2.0.0",
"license": "Apache-2.0",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/DouglasNeuroInformatics/OpenDataCapture.git",
"directory": "packages/playground-url"
},
"exports": {
".": "./src/index.ts"
},
"bin": {
"playground-url": "./dist/cli.js"
},
"files": [
"!/src/**/*.test.ts",
"/dist",
"/src"
],
"scripts": {
"build": "node scripts/build.js",
"format": "prettier --write src",
"lint": "tsc && eslint --fix src",
"test": "vitest"
},
"dependencies": {
"chalk": "^5.6.2",
"commander": "catalog:",
"lz-string": "^1.5.0",
"zod": "workspace:zod__3.x@*"
},
"devDependencies": {
"esbuild": "catalog:"
}
}
34 changes: 34 additions & 0 deletions packages/playground-url/scripts/build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as fs from 'node:fs';
import * as path from 'node:path';

import * as esbuild from 'esbuild';

const outdir = path.resolve(import.meta.dirname, '../dist');

await fs.promises.rm(outdir, { force: true, recursive: true });

// Bundle dependencies into a self-contained CLI so it runs via `npx` without a
// node_modules resolution step, and so esbuild handles the CJS/ESM interop of
// dependencies like lz-string. Node built-ins are left external automatically.
await esbuild.build({
banner: {
// Shim `require`/`__dirname`/`__filename` so bundled CommonJS dependencies
// (e.g. commander) work in the ESM output.
js: [
'#!/usr/bin/env node',
'import { createRequire as __createRequire } from "node:module";',
'Object.defineProperties(globalThis, {',
' __dirname: { value: import.meta.dirname, writable: false },',
' __filename: { value: import.meta.filename, writable: false },',
' require: { value: __createRequire(import.meta.url), writable: false }',
'});'
].join('\n')
},
bundle: true,
entryPoints: [path.resolve(import.meta.dirname, '../src/cli.ts')],
format: 'esm',
minify: false,
outdir,
platform: 'node',
target: ['node22', 'es2022']
});
108 changes: 108 additions & 0 deletions packages/playground-url/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { spawn } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';

import chalk from 'chalk';
import { Command, InvalidArgumentError } from 'commander';

import { name, version } from '../package.json';
import { DEFAULT_PLAYGROUND_URL, encodeShareURL } from './share-url.js';

import type { EditorFile } from './models.js';

/** Text files the playground can load from a share URL. */
const TEXT_FILE_EXT_REGEX = /\.(css|html|js|jsx|json|ts|tsx)$/i;
/** Bundler assets that exist on disk but cannot be embedded in a (string-only) share URL. */
const BINARY_FILE_EXT_REGEX = /\.(jpeg|jpg|mp3|mp4|png|svg|webp)$/i;

function parseTarget(target: string): string {
const resolved = path.resolve(target);
if (!fs.existsSync(resolved)) {
throw new InvalidArgumentError('Directory does not exist');
}
if (!fs.lstatSync(resolved).isDirectory()) {
throw new InvalidArgumentError('Not a directory');
}
return resolved;
}

function parseBaseURL(value: string): string {
try {
return new URL(value).origin;
} catch {
throw new InvalidArgumentError(`Not a valid URL: ${value}`);
}
}

/** Read every URL-shareable text file directly within `target`, warning about skipped assets. */
function readInstrumentFiles(target: string): EditorFile[] {
const files: EditorFile[] = [];
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
if (!entry.isFile()) {
continue;
}
const filepath = path.join(target, entry.name);
if (TEXT_FILE_EXT_REGEX.test(entry.name)) {
files.push({ content: fs.readFileSync(filepath, 'utf-8'), name: entry.name });
} else if (BINARY_FILE_EXT_REGEX.test(entry.name)) {
process.stderr.write(
chalk.yellow(`⚠ Skipping '${entry.name}': binary assets cannot be embedded in a share URL\n`)
);
}
}
return files;
}

/** Open a URL in the user's default browser, cross-platform, without extra dependencies. */
function openInBrowser(url: string): void {
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
spawn(command, [url], { detached: true, shell: process.platform === 'win32', stdio: 'ignore' }).unref();
}

const program = new Command();

program
.name(name)
.description('Generate an Open Data Capture playground link from an instrument directory')
.version(version)
.allowExcessArguments(false)
.argument('<target>', 'the directory containing the instrument source files', parseTarget)
.option('-l, --label <label>', 'the label for the shared instrument (defaults to the directory name)')
.option('-f, --fullscreen', 'share a read-only fullscreen preview rather than the editor')
.option('-b, --base-url <url>', 'the playground origin to link to', parseBaseURL, DEFAULT_PLAYGROUND_URL)
.option('-o, --open', 'open the generated link in your default browser')
.action((target: string) => {
const { baseUrl, fullscreen, label, open } = program.opts<{
baseUrl: string;
fullscreen?: boolean;
label?: string;
open?: boolean;
}>();

const files = readInstrumentFiles(target);
if (files.length === 0) {
process.stderr.write(chalk.red(`✘ No shareable source files found in ${target}\n`));
process.exitCode = 1;
return;
}

const shareURL = encodeShareURL({
baseURL: baseUrl,
files,
fullscreen,
label: label ?? path.basename(target)
});

// Status goes to stderr so the URL on stdout stays clean and pipeable.
process.stderr.write(
chalk.green('✓') +
chalk.dim(` Encoded ${files.length} file${files.length === 1 ? '' : 's'} (${shareURL.size} bytes)\n`)
);
process.stdout.write(shareURL.href + '\n');

if (open) {
openInBrowser(shareURL.href);
}
});

program.parse();
10 changes: 10 additions & 0 deletions packages/playground-url/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export { $EditorFile, $EditorFiles } from './models.js';
export type { EditorFile, PlaygroundInstrument } from './models.js';
export {
decodeShareURL,
DEFAULT_PLAYGROUND_URL,
encodeShareURL,
generatePlaygroundURL,
isFullscreenShareURL
} from './share-url.js';
export type { EncodeShareURLOptions, ShareURL } from './share-url.js';
20 changes: 20 additions & 0 deletions packages/playground-url/src/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from 'zod/v4';

/**
* A single source file of a playground instrument. The content must be a UTF-8
* string, mirroring the playground editor's own file model — binary assets
* (images, audio, video) cannot be represented in a share URL.
*/
export type EditorFile = z.infer<typeof $EditorFile>;
export const $EditorFile = z.object({
content: z.string(),
name: z.string()
});

export const $EditorFiles = z.array($EditorFile);

/** The minimal description of an instrument needed to (de)serialize a share URL. */
export type PlaygroundInstrument = {
files: EditorFile[];
label: string;
};
Loading
Loading