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
3 changes: 2 additions & 1 deletion apps/playground/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ Share links come from `@opendatacapture/playground-url`, which lz-string-compres
the URL fragment. A link that differs only in its fragment does not reload the page, so `IndexPage`
reads the URL through `useLocationHref`, which re-renders on `hashchange`; reading `location.href`
directly means pasting a second share link into an open tab does nothing. Its `$EditorFile` requires
`content` to be a UTF-8 string, so binary assets do not survive a share URL.
`content` to be a UTF-8 string, which binary assets satisfy because the store already holds them as
base64; the package's CLI leaves them out of the links it prints to keep them short.

## Odds and ends

Expand Down
10 changes: 6 additions & 4 deletions packages/playground-url/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ its `publishConfig`, so a change here is a change to a public API, and `version`
workspace. `pnpm build` runs `scripts/build.js`, which esbuilds `src/cli.ts` into `dist/cli.js` and
nothing else — `dist` exists only for the `bin`. A library change needs no build; a CLI change does.

**A share URL can carry UTF-8 text and nothing else.** Files are `JSON.stringify`d and lz-string
compressed into the URL fragment, so images, audio and video cannot be represented at all. The CLI
skips them with a warning; `TEXT_FILE_EXT_REGEX` and `BINARY_FILE_EXT_REGEX` in `src/cli.ts` are the
allowlist and the known-skip list, and both must track what the playground editor accepts.
**A share URL carries every file as a UTF-8 string.** Files are `JSON.stringify`d and lz-string
compressed into the URL fragment. The playground holds images, audio and video as base64 strings, so
its Share button carries them too; the CLI skips them with a warning to keep links short.
`TEXT_FILE_EXT_REGEX` and `BINARY_FILE_EXT_REGEX` in `src/cli.ts` are the allowlist and the
known-skip list. They must track `inferFileType` and `isBase64EncodedFileType` in
`apps/playground/src/utils/file.ts`: an `.svg` is text there, so it is text here.

The encoding is a wire format for links people have already shared. Changing what `encodeFiles`
writes invalidates every existing link, so `decodeShareURL` has to keep reading the old shape.
Expand Down
2 changes: 1 addition & 1 deletion packages/playground-url/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ The status line is written to stderr and the link to stdout, so it pipes cleanly
| `-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.
Only text files (`.css`, `.html`, `.js`, `.jsx`, `.json`, `.svg`, `.ts`, `.tsx`) are embedded. Binary assets (`.jpeg`, `.jpg`, `.mp3`, `.mp4`, `.png`, `.webp`) are skipped with a warning to keep links short; the playground's own Share button does carry them, base64-encoded.
15 changes: 15 additions & 0 deletions packages/playground-url/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,21 @@ describe('cli', () => {
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining("Skipping 'icon.png'"));
});

it('should embed an SVG as the text it is, rather than skip it as a binary asset', async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1" /></svg>\n';
fs.writeFileSync(path.join(tmpDir, 'index.ts'), "import logo from './logo.svg';\nexport default { logo };");
fs.writeFileSync(path.join(tmpDir, 'logo.svg'), svg);
process.argv = ['node', 'cli.js', tmpDir];
const { decodeShareURL } = await import('../share-url.js');
await import('../cli.js');
const href = stdoutSpy.mock.calls
.map(([chunk]: [unknown]) => chunk)
.find((chunk: unknown) => typeof chunk === 'string' && chunk.includes('http'));
const decoded = decodeShareURL(new URL((href as string).trim()));
expect(decoded?.files.find((file) => file.name === 'logo.svg')?.content).toBe(svg);
expect(stderrSpy).not.toHaveBeenCalledWith(expect.stringContaining('Skipping'));
});

it('should ignore a subdirectory rather than read it as a file', async () => {
fs.writeFileSync(path.join(tmpDir, 'index.ts'), 'export default {};');
fs.mkdirSync(path.join(tmpDir, 'nested'));
Expand Down
10 changes: 5 additions & 5 deletions packages/playground-url/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ 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;
/** Text files embedded in the share URL as they are on disk, as the playground holds them. */
const TEXT_FILE_EXT_REGEX = /\.(css|html|js|jsx|json|svg|ts|tsx)$/i;
/** Binary assets the CLI leaves out to keep links short, although the playground could load them. */
const BINARY_FILE_EXT_REGEX = /\.(jpeg|jpg|mp3|mp4|png|webp)$/i;

function parseTarget(target: string): string {
const resolved = path.resolve(target);
Expand Down Expand Up @@ -46,7 +46,7 @@ function readInstrumentFiles(target: string): EditorFile[] {
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`)
chalk.yellow(`⚠ Skipping '${entry.name}': the CLI does not embed binary assets in a share URL\n`)
);
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/playground-url/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ 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.
* string, mirroring the playground editor's own file model: text files as they
* are, and binary assets (images, audio, video) as base64.
*/
export type EditorFile = z.infer<typeof $EditorFile>;
export const $EditorFile = z.object({
Expand Down
30 changes: 30 additions & 0 deletions testing/src/specs/playground.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { execFileSync } from 'node:child_process';
import * as path from 'node:path';

import { generatePlaygroundURL } from '@opendatacapture/playground-url';

import { PlaygroundPage } from '../pages/playground/index.page';
Expand Down Expand Up @@ -247,6 +250,33 @@ test.describe('playground', () => {
await expect(task.getByRole('heading', { name: 'Vite + React' })).toBeVisible({ timeout: PREVIEW_TIMEOUT });
});

// `test:e2e` builds `@opendatacapture/playground-url` first, so its CLI is in `dist`. The React
// example imports `react.svg`; the CLI used to skip it as binary, so the link failed to compile.
test('should render an SVG asset from a link generated by the playground-url CLI', async ({ page }) => {
const repoRoot = path.resolve(import.meta.dirname, '../../..');
const href = execFileSync(
process.execPath,
[
path.join(repoRoot, 'packages/playground-url/dist/cli.js'),
path.join(repoRoot, 'apps/playground/src/instruments/examples/interactive/Interactive-With-React'),
'--base-url',
playgroundURL
],
{ encoding: 'utf-8' }
).trim();
const playground = new PlaygroundPage(page);
await playground.goto(href);

await playground.preview.getByRole('button', { name: 'Begin' }).click({ timeout: PREVIEW_TIMEOUT });

// A visibility check would also pass for an SVG that arrives base64-encoded and renders broken.
const logo = playground.preview
.frameLocator('iframe[name="interactive-instrument"]')
.getByRole('img', { name: 'React logo' });
await expect(logo).toBeVisible({ timeout: PREVIEW_TIMEOUT });
await expect.poll(() => logo.evaluate((img: HTMLImageElement) => img.naturalWidth)).toBeGreaterThan(0);
});

test('should hand a submission from the preview back to the editor', async ({ page }) => {
const playground = new PlaygroundPage(page);
await playground.goto();
Expand Down
Loading