diff --git a/packages/playground/blueprints/src/index.ts b/packages/playground/blueprints/src/index.ts index 170e002a1c..8b6e2c1aa2 100644 --- a/packages/playground/blueprints/src/index.ts +++ b/packages/playground/blueprints/src/index.ts @@ -65,6 +65,14 @@ export { resolveRemoteBlueprint } from './lib/resolve-remote-blueprint'; export { wpContentFilesExcludedFromExport } from './lib/utils/wp-content-files-excluded-from-exports'; export { resolveRuntimeConfiguration } from './lib/resolve-runtime-configuration'; +export { resolveBlueprintFromURL } from './lib/resolve-blueprint-from-url'; +export type { + BlueprintSource, + ResolvedBlueprint, +} from './lib/resolve-blueprint-from-url'; +export { applyQueryOverrides } from './lib/apply-query-overrides'; +export { parseBlueprint } from './lib/utils/parse-blueprint'; + /** * @deprecated This function is a no-op. Playground no longer uses a proxy to download plugins and themes. * To be removed in v0.3.0 diff --git a/packages/playground/website/src/lib/state/url/resolve-blueprint-from-url.ts b/packages/playground/blueprints/src/lib/apply-query-overrides.ts similarity index 57% rename from packages/playground/website/src/lib/state/url/resolve-blueprint-from-url.ts rename to packages/playground/blueprints/src/lib/apply-query-overrides.ts index 71101455cd..0cdc9075aa 100644 --- a/packages/playground/website/src/lib/state/url/resolve-blueprint-from-url.ts +++ b/packages/playground/blueprints/src/lib/apply-query-overrides.ts @@ -1,137 +1,36 @@ -import type { - BlueprintV1Declaration, - BlueprintBundle, - StepDefinition, - BlueprintV1, -} from '@wp-playground/client'; -import { - getBlueprintDeclaration, - isBlueprintBundle, - resolveRemoteBlueprint, -} from '@wp-playground/client'; -import { parseBlueprint } from './router'; -import { OverlayFilesystem, InMemoryFilesystem } from '@wp-playground/storage'; +import type { BlueprintV1Declaration } from './v1/types'; +import type { BlueprintBundle } from './types'; +import { getBlueprintDeclaration, isBlueprintBundle } from './v1/compile'; import { RecommendedPHPVersion } from '@wp-playground/common'; -export type BlueprintSource = - | { - type: 'remote-url'; - url: string; - } - | { - type: 'inline-string'; - } - | { - type: 'none'; - }; - -export type ResolvedBlueprint = { - blueprint: BlueprintV1; - source: BlueprintSource; -}; - -export async function resolveBlueprintFromURL( - url: URL, - defaultBlueprint?: string -): Promise { - const query = url.searchParams; - const fragment = decodeURI(url.hash || '#').substring(1); - - /** - * If the URL has no parameters or fragment, and a default blueprint is provided, - * use the default blueprint. - */ - if ( - window.self === window.top && - !query.size && - !fragment.length && - defaultBlueprint - ) { - return { - blueprint: await resolveRemoteBlueprint(defaultBlueprint), - source: { - type: 'remote-url', - url: defaultBlueprint, - }, - }; - } else if (query.has('blueprint-url')) { - /* - * Support passing blueprints via query parameter, e.g.: - * ?blueprint-url=https://example.com/blueprint.json - */ - return { - blueprint: await resolveRemoteBlueprint( - query.get('blueprint-url')! - ), - source: { - type: 'remote-url', - url: query.get('blueprint-url')!, - }, - }; - } else if (fragment.length) { - /* - * Support passing blueprints in the URI fragment, e.g.: - * /#{"landingPage": "/?p=4"} - */ - return { - blueprint: parseBlueprint(fragment), - source: { - type: 'inline-string', - }, - }; - } else { - const importWxrQueryArg = - query.get('import-wxr') || query.get('import-content'); - - // This Blueprint is intentionally missing most query args (like login). - // They are added below to ensure they're also applied to Blueprints passed - // via the hash fragment (#{...}) or via the `blueprint-url` query param. - return { - blueprint: { - plugins: query.getAll('plugin'), - steps: [ - importWxrQueryArg && - /^(http(s?)):\/\//i.test(importWxrQueryArg) && - ({ - step: 'importWxr', - file: { - resource: 'url', - url: importWxrQueryArg, - }, - } as StepDefinition), - query.get('import-site') && - /^(http(s?)):\/\//i.test(query.get('import-site')!) && - ({ - step: 'importWordPressFiles', - wordPressFilesZip: { - resource: 'url', - url: query.get('import-site')!, - }, - } as StepDefinition), - ...query.getAll('theme').map( - (theme, index, themes) => - ({ - step: 'installTheme', - themeData: { - resource: 'wordpress.org/themes', - slug: theme, - }, - options: { - // Activate only the last theme in the list. - activate: index === themes.length - 1, - }, - progress: { weight: 2 }, - } as StepDefinition) - ), - ].filter(Boolean), - }, - source: { - type: 'none', - }, - }; - } -} - +/** + * Apply query parameter overrides to a blueprint. + * + * This function allows users to override various blueprint settings via URL query parameters: + * - `php`: Override PHP version + * - `wp`: Override WordPress version + * - `networking`: Enable/disable networking + * - `language`: Set site language + * - `multisite`: Enable multisite + * - `login`: Enable/disable auto-login + * - `url`: Set landing page URL + * - `core-pr`: Use a WordPress core PR build + * - `gutenberg-pr`: Install a Gutenberg PR build + * + * @param blueprint - The blueprint or blueprint bundle to apply overrides to + * @param query - URL search parameters containing the overrides + * @returns The blueprint with overrides applied + * + * @example + * ```ts + * const blueprint = { landingPage: '/' }; + * const query = new URLSearchParams('php=8.2&wp=6.4&language=es_ES'); + * const updated = await applyQueryOverrides(blueprint, query); + * // updated.preferredVersions.php === '8.2' + * // updated.preferredVersions.wp === '6.4' + * // updated.steps includes setSiteLanguage step + * ``` + */ export async function applyQueryOverrides( blueprint: BlueprintV1Declaration | BlueprintBundle, query: URLSearchParams @@ -141,6 +40,9 @@ export async function applyQueryOverrides( * via query params. */ if (isBlueprintBundle(blueprint)) { + const { OverlayFilesystem, InMemoryFilesystem } = await import( + '@wp-playground/storage' + ); let blueprintObject = await getBlueprintDeclaration(blueprint); blueprintObject = applyQueryOverridesToDeclaration( blueprintObject, diff --git a/packages/playground/blueprints/src/lib/resolve-blueprint-from-url.ts b/packages/playground/blueprints/src/lib/resolve-blueprint-from-url.ts new file mode 100644 index 0000000000..9056ea2ac8 --- /dev/null +++ b/packages/playground/blueprints/src/lib/resolve-blueprint-from-url.ts @@ -0,0 +1,159 @@ +import type { BlueprintV1 } from './v1/types'; +import type { StepDefinition } from './steps'; +import { resolveRemoteBlueprint } from './resolve-remote-blueprint'; +import { parseBlueprint } from './utils/parse-blueprint'; + +/** + * The source of a resolved blueprint. + */ +export type BlueprintSource = + | { + type: 'remote-url'; + url: string; + } + | { + type: 'inline-string'; + } + | { + type: 'none'; + }; + +/** + * A blueprint resolved from a URL along with metadata about its source. + */ +export type ResolvedBlueprint = { + blueprint: BlueprintV1; + source: BlueprintSource; +}; + +/** + * Resolve a blueprint from a URL. + * + * This function supports multiple ways of passing blueprints: + * 1. Via `blueprint-url` query parameter pointing to a remote blueprint JSON + * 2. Via URL hash fragment containing inline JSON or base64-encoded JSON + * 3. Via legacy query parameters (plugin, theme, import-wxr, import-site) + * 4. Via a default blueprint URL when the URL has no parameters + * + * @param url - The URL to extract blueprint information from + * @param defaultBlueprint - Default blueprint URL to use when the URL has no parameters or fragment + * @returns A promise that resolves to the blueprint and its source metadata + * + * @example + * ```ts + * // From query parameter + * const url = new URL('https://example.com/?blueprint-url=https://example.com/blueprint.json'); + * const { blueprint, source } = await resolveBlueprintFromURL(url); + * + * // From URL fragment + * const url2 = new URL('https://example.com/#{"landingPage": "/?p=4"}'); + * const { blueprint: blueprint2 } = await resolveBlueprintFromURL(url2); + * + * // From query params with default + * const url3 = new URL('https://example.com/'); + * const { blueprint: blueprint3 } = await resolveBlueprintFromURL(url3, 'https://example.com/default.json'); + * ``` + */ +export async function resolveBlueprintFromURL( + url: URL, + defaultBlueprint?: string +): Promise { + const query = url.searchParams; + const fragment = decodeURI(url.hash || '#').substring(1); + + /** + * If the URL has no parameters or fragment, and a default blueprint is provided, + * use the default blueprint. + */ + if ( + typeof window !== 'undefined' && + window.self === window.top && + !query.size && + !fragment.length && + defaultBlueprint + ) { + return { + blueprint: await resolveRemoteBlueprint(defaultBlueprint), + source: { + type: 'remote-url', + url: defaultBlueprint, + }, + }; + } else if (query.has('blueprint-url')) { + /* + * Support passing blueprints via query parameter, e.g.: + * ?blueprint-url=https://example.com/blueprint.json + */ + return { + blueprint: await resolveRemoteBlueprint( + query.get('blueprint-url')! + ), + source: { + type: 'remote-url', + url: query.get('blueprint-url')!, + }, + }; + } else if (fragment.length) { + /* + * Support passing blueprints in the URI fragment, e.g.: + * /#{"landingPage": "/?p=4"} + */ + return { + blueprint: parseBlueprint(fragment), + source: { + type: 'inline-string', + }, + }; + } else { + const importWxrQueryArg = + query.get('import-wxr') || query.get('import-content'); + + // This Blueprint is intentionally missing most query args (like login). + // They are added by applyQueryOverrides() to ensure they're also applied + // to Blueprints passed via the hash fragment (#{...}) or via the + // `blueprint-url` query param. + return { + blueprint: { + plugins: query.getAll('plugin'), + steps: [ + importWxrQueryArg && + /^(http(s?)):\/\//i.test(importWxrQueryArg) && + ({ + step: 'importWxr', + file: { + resource: 'url', + url: importWxrQueryArg, + }, + } as StepDefinition), + query.get('import-site') && + /^(http(s?)):\/\//i.test(query.get('import-site')!) && + ({ + step: 'importWordPressFiles', + wordPressFilesZip: { + resource: 'url', + url: query.get('import-site')!, + }, + } as StepDefinition), + ...query.getAll('theme').map( + (theme, index, themes) => + ({ + step: 'installTheme', + themeData: { + resource: 'wordpress.org/themes', + slug: theme, + }, + options: { + // Activate only the last theme in the list. + activate: index === themes.length - 1, + }, + progress: { weight: 2 }, + } as StepDefinition) + ), + ].filter(Boolean), + }, + source: { + type: 'none', + }, + }; + } +} diff --git a/packages/playground/blueprints/src/lib/utils/parse-blueprint.spec.ts b/packages/playground/blueprints/src/lib/utils/parse-blueprint.spec.ts new file mode 100644 index 0000000000..91b060773e --- /dev/null +++ b/packages/playground/blueprints/src/lib/utils/parse-blueprint.spec.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { parseBlueprint } from './parse-blueprint'; + +describe('parseBlueprint', () => { + it('should parse JSON blueprint string', () => { + const blueprint = { landingPage: '/?p=4' }; + const result = parseBlueprint(JSON.stringify(blueprint)); + expect(result).toEqual(blueprint); + }); + + it('should parse base64-encoded blueprint string', () => { + const blueprint = { landingPage: '/?p=4' }; + const base64 = Buffer.from(JSON.stringify(blueprint)).toString( + 'base64' + ); + const result = parseBlueprint(base64); + expect(result).toEqual(blueprint); + }); + + it('should throw error for invalid blueprint', () => { + expect(() => parseBlueprint('not valid json or base64')).toThrow( + 'Invalid blueprint' + ); + }); +}); diff --git a/packages/playground/blueprints/src/lib/utils/parse-blueprint.ts b/packages/playground/blueprints/src/lib/utils/parse-blueprint.ts new file mode 100644 index 0000000000..390faa258d --- /dev/null +++ b/packages/playground/blueprints/src/lib/utils/parse-blueprint.ts @@ -0,0 +1,34 @@ +import { decodeBase64ToString } from '@wp-playground/common'; + +/** + * Parse a blueprint from either a JSON string or base64-encoded JSON string. + * + * This is useful for parsing blueprints from URL fragments, where they might + * be encoded as either plain JSON or base64-encoded JSON. + * + * @param rawData - The raw blueprint data (JSON or base64-encoded JSON) + * @returns The parsed blueprint object + * @throws Error if the data cannot be parsed as JSON or base64-encoded JSON + * + * @example + * ```ts + * // Parse plain JSON + * parseBlueprint('{"landingPage": "/?p=4"}'); + * + * // Parse base64-encoded JSON + * parseBlueprint('eyJsYW5kaW5nUGFnZSI6ICIvP3A9NCJ9'); + * ``` + */ +export function parseBlueprint(rawData: string): any { + try { + // First try parsing as plain JSON + return JSON.parse(rawData); + } catch { + try { + // If that fails, try decoding as base64 then parsing + return JSON.parse(decodeBase64ToString(rawData)); + } catch { + throw new Error('Invalid blueprint'); + } + } +} diff --git a/packages/playground/blueprints/src/test/apply-query-overrides.spec.ts b/packages/playground/blueprints/src/test/apply-query-overrides.spec.ts new file mode 100644 index 0000000000..b84fe9629b --- /dev/null +++ b/packages/playground/blueprints/src/test/apply-query-overrides.spec.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from 'vitest'; +import { applyQueryOverrides } from '../lib/apply-query-overrides'; +import type { BlueprintV1Declaration } from '../lib/v1/types'; +import { getBlueprintDeclaration } from '../lib/v1/compile'; + +describe('applyQueryOverrides', () => { + it('should override PHP version from query param', async () => { + const blueprint: BlueprintV1Declaration = { + preferredVersions: { php: '7.4', wp: 'latest' }, + }; + const query = new URLSearchParams('php=8.0'); + + const result = (await applyQueryOverrides( + blueprint, + query + )) as BlueprintV1Declaration; + + expect(result.preferredVersions?.php).toBe('8.0'); + }); + + it('should override WordPress version from query param', async () => { + const blueprint: BlueprintV1Declaration = { + preferredVersions: { php: '8.0', wp: '6.3' }, + }; + const query = new URLSearchParams('wp=6.4'); + + const result = (await applyQueryOverrides( + blueprint, + query + )) as BlueprintV1Declaration; + + expect(result.preferredVersions?.wp).toBe('6.4'); + }); + + it('should disable networking when query param is not "yes"', async () => { + const blueprint: BlueprintV1Declaration = {}; + const query = new URLSearchParams('networking=no'); + + const result = (await applyQueryOverrides( + blueprint, + query + )) as BlueprintV1Declaration; + + expect(result.features?.networking).toBe(false); + }); + + it('should add setSiteLanguage step from query param', async () => { + const blueprint: BlueprintV1Declaration = { steps: [] }; + const query = new URLSearchParams('language=es_ES'); + + const result = (await applyQueryOverrides( + blueprint, + query + )) as BlueprintV1Declaration; + + expect(result.steps).toContainEqual({ + step: 'setSiteLanguage', + language: 'es_ES', + }); + }); + + it('should not duplicate setSiteLanguage step if already exists', async () => { + const blueprint: BlueprintV1Declaration = { + steps: [{ step: 'setSiteLanguage', language: 'fr_FR' }], + }; + const query = new URLSearchParams('language=es_ES'); + + const result = (await applyQueryOverrides( + blueprint, + query + )) as BlueprintV1Declaration; + + // Should not add another setSiteLanguage step + const languageSteps = result.steps?.filter( + (step: any) => step && step.step === 'setSiteLanguage' + ); + expect(languageSteps).toHaveLength(1); + expect(languageSteps?.[0]).toMatchObject({ + step: 'setSiteLanguage', + language: 'fr_FR', + }); + }); + + it('should add enableMultisite step when multisite=yes', async () => { + const blueprint: BlueprintV1Declaration = { steps: [] }; + const query = new URLSearchParams('multisite=yes'); + + const result = (await applyQueryOverrides( + blueprint, + query + )) as BlueprintV1Declaration; + + expect(result.steps).toContainEqual({ + step: 'enableMultisite', + }); + }); + + it('should not duplicate enableMultisite step if already exists', async () => { + const blueprint: BlueprintV1Declaration = { + steps: [{ step: 'enableMultisite' }], + }; + const query = new URLSearchParams('multisite=yes'); + + const result = (await applyQueryOverrides( + blueprint, + query + )) as BlueprintV1Declaration; + + // Should not add another enableMultisite step + const multisiteSteps = result.steps?.filter( + (step: any) => step && step.step === 'enableMultisite' + ); + expect(multisiteSteps).toHaveLength(1); + }); + + it('should set login to true unless explicitly set to "no"', async () => { + const blueprint1: BlueprintV1Declaration = {}; + const query1 = new URLSearchParams('login=yes'); + + const result1 = (await applyQueryOverrides( + blueprint1, + query1 + )) as BlueprintV1Declaration; + expect(result1.login).toBe(true); + + const blueprint2: BlueprintV1Declaration = {}; + const query2 = new URLSearchParams('login=no'); + + const result2 = (await applyQueryOverrides( + blueprint2, + query2 + )) as BlueprintV1Declaration; + expect(result2.login).toBeUndefined(); + }); + + it('should override landingPage from url query param', async () => { + const blueprint: BlueprintV1Declaration = { + landingPage: '/', + }; + const query = new URLSearchParams('url=/wp-admin'); + + const result = (await applyQueryOverrides( + blueprint, + query + )) as BlueprintV1Declaration; + + expect(result.landingPage).toBe('/wp-admin'); + }); + + it('should override WordPress version with core-pr query param', async () => { + const blueprint: BlueprintV1Declaration = { + preferredVersions: { php: '8.0', wp: '6.4' }, + }; + const query = new URLSearchParams('core-pr=12345'); + + const result = (await applyQueryOverrides( + blueprint, + query + )) as BlueprintV1Declaration; + + expect(result.preferredVersions?.wp).toContain('12345'); + expect(result.preferredVersions?.wp).toContain('plugin-proxy.php'); + }); + + it('should handle blueprint bundles (filesystems)', async () => { + const { InMemoryFilesystem } = await import('@wp-playground/storage'); + const blueprintBundle = new InMemoryFilesystem({ + 'blueprint.json': JSON.stringify({ landingPage: '/test' }), + }); + + const query = new URLSearchParams('php=8.2'); + const result = await applyQueryOverrides(blueprintBundle, query); + + const blueprint = await getBlueprintDeclaration(result); + expect(blueprint).toEqual({ + landingPage: '/test', + preferredVersions: { php: '8.2', wp: 'latest' }, + features: {}, + login: true, + }); + }); +}); diff --git a/packages/playground/blueprints/src/test/resolve-blueprint-from-url.spec.ts b/packages/playground/blueprints/src/test/resolve-blueprint-from-url.spec.ts new file mode 100644 index 0000000000..8d36273d80 --- /dev/null +++ b/packages/playground/blueprints/src/test/resolve-blueprint-from-url.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { resolveBlueprintFromURL } from '../lib/resolve-blueprint-from-url'; +import { getBlueprintDeclaration } from '../lib/v1/compile'; +import { createServer, type Server } from 'http'; + +describe('resolveBlueprintFromURL', () => { + let server: Server; + let serverUrl: string; + + beforeAll(async () => { + // Start a real HTTP server for testing + await new Promise((resolve) => { + server = createServer((req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Content-Type', 'application/json'); + + if (req.url === '/blueprint.json') { + res.writeHead(200); + res.end(JSON.stringify({ landingPage: '/test' })); + } else if (req.url === '/default.json') { + res.writeHead(200); + res.end(JSON.stringify({ landingPage: '/default' })); + } else { + res.writeHead(404); + res.end('Not found'); + } + }); + + server.listen(0, () => { + const address = server.address(); + if (address && typeof address === 'object') { + serverUrl = `http://localhost:${address.port}`; + resolve(); + } + }); + }); + }); + + afterAll(async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }); + + it('should resolve blueprint from blueprint-url query param', async () => { + const url = new URL( + `https://example.com/?blueprint-url=${serverUrl}/blueprint.json` + ); + const result = await resolveBlueprintFromURL(url); + + const blueprint = await getBlueprintDeclaration(result.blueprint); + expect(blueprint).toEqual({ landingPage: '/test' }); + expect(result.source).toEqual({ + type: 'remote-url', + url: `${serverUrl}/blueprint.json`, + }); + }); + + it('should resolve blueprint from URL hash fragment (JSON)', async () => { + const blueprint = { landingPage: '/?p=4' }; + const url = new URL( + `https://example.com/#${JSON.stringify(blueprint)}` + ); + const result = await resolveBlueprintFromURL(url); + + expect(result.blueprint).toEqual(blueprint); + expect(result.source).toEqual({ type: 'inline-string' }); + }); + + it('should resolve blueprint from URL hash fragment (base64)', async () => { + const blueprint = { landingPage: '/?p=4' }; + const base64 = Buffer.from(JSON.stringify(blueprint)).toString( + 'base64' + ); + const url = new URL(`https://example.com/#${base64}`); + const result = await resolveBlueprintFromURL(url); + + expect(result.blueprint).toEqual(blueprint); + expect(result.source).toEqual({ type: 'inline-string' }); + }); + + it('should create blueprint from query params (plugin)', async () => { + const url = new URL( + 'https://example.com/?plugin=gutenberg&plugin=wp-api' + ); + const result = await resolveBlueprintFromURL(url); + + expect((result.blueprint as any).plugins).toEqual([ + 'gutenberg', + 'wp-api', + ]); + expect(result.source).toEqual({ type: 'none' }); + }); + + it('should create blueprint from query params (theme)', async () => { + const url = new URL('https://example.com/?theme=twentytwentyfour'); + const result = await resolveBlueprintFromURL(url); + + expect((result.blueprint as any).steps).toHaveLength(1); + expect((result.blueprint as any).steps![0]).toMatchObject({ + step: 'installTheme', + themeData: { + resource: 'wordpress.org/themes', + slug: 'twentytwentyfour', + }, + options: { activate: true }, + }); + }); + + it('should create blueprint from query params (import-wxr)', async () => { + const url = new URL( + 'https://example.com/?import-wxr=https://example.com/content.xml' + ); + const result = await resolveBlueprintFromURL(url); + + expect((result.blueprint as any).steps).toContainEqual({ + step: 'importWxr', + file: { + resource: 'url', + url: 'https://example.com/content.xml', + }, + }); + }); +}); diff --git a/packages/playground/common/src/base64.ts b/packages/playground/common/src/base64.ts new file mode 100644 index 0000000000..64824430f4 --- /dev/null +++ b/packages/playground/common/src/base64.ts @@ -0,0 +1,51 @@ +/** + * Decode a base64 string to a UTF-8 string. + * Works in both browser and Node.js environments. + */ +export function decodeBase64ToString(base64: string): string { + return new TextDecoder().decode(decodeBase64ToUint8Array(base64)); +} + +/** + * Decode a base64 string to a Uint8Array. + * Works in both browser and Node.js environments. + */ +export function decodeBase64ToUint8Array(base64: string): Uint8Array { + // Use globalThis to work in both browser and Node.js + const atobFn = + typeof atob !== 'undefined' + ? atob + : (str: string) => Buffer.from(str, 'base64').toString('binary'); + + const binaryString = atobFn(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + +/** + * Encode a UTF-8 string to base64. + * Works in both browser and Node.js environments. + */ +export function encodeStringAsBase64(str: string): string { + return encodeUint8ArrayAsBase64(new TextEncoder().encode(str)); +} + +/** + * Encode a Uint8Array to base64. + * Works in both browser and Node.js environments. + */ +export function encodeUint8ArrayAsBase64(bytes: Uint8Array): string { + const binString = String.fromCodePoint(...bytes); + + // Use globalThis to work in both browser and Node.js + const btoaFn = + typeof btoa !== 'undefined' + ? btoa + : (str: string) => Buffer.from(str, 'binary').toString('base64'); + + return btoaFn(binString); +} diff --git a/packages/playground/common/src/index.ts b/packages/playground/common/src/index.ts index ff18372f97..36a36c9dce 100644 --- a/packages/playground/common/src/index.ts +++ b/packages/playground/common/src/index.ts @@ -13,6 +13,12 @@ import type { UniversalPHP } from '@php-wasm/universal'; import { phpVars } from '@php-wasm/util'; export { createMemoizedFetch } from './create-memoized-fetch'; +export { + decodeBase64ToString, + encodeStringAsBase64, + decodeBase64ToUint8Array, + encodeUint8ArrayAsBase64, +} from './base64'; export const RecommendedPHPVersion = '8.3'; diff --git a/packages/playground/common/src/test/base64.spec.ts b/packages/playground/common/src/test/base64.spec.ts new file mode 100644 index 0000000000..5ac900643d --- /dev/null +++ b/packages/playground/common/src/test/base64.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { decodeBase64ToString, encodeStringAsBase64 } from '../base64'; + +describe('base64 utilities', () => { + it('should decode base64 string to text', () => { + const encoded = 'SGVsbG8gV29ybGQ='; // "Hello World" + const result = decodeBase64ToString(encoded); + expect(result).toBe('Hello World'); + }); + + it('should decode base64 JSON blueprint', () => { + const blueprint = { landingPage: '/?p=4' }; + const encoded = encodeStringAsBase64(JSON.stringify(blueprint)); + const decoded = decodeBase64ToString(encoded); + expect(JSON.parse(decoded)).toEqual(blueprint); + }); + + it('should handle special characters', () => { + const text = 'Special: €ñ中文'; + const encoded = encodeStringAsBase64(text); + const decoded = decodeBase64ToString(encoded); + expect(decoded).toBe(text); + }); +}); diff --git a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx index ac9e1e33c6..d61aa877f6 100644 --- a/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx +++ b/packages/playground/website/src/components/site-manager/site-info-panel/index.tsx @@ -26,7 +26,7 @@ import { setSiteManagerSection, } from '../../../lib/state/redux/slice-ui'; import { selectClientInfoBySiteSlug } from '../../../lib/state/redux/slice-clients'; -import { encodeStringAsBase64 } from '../../../lib/base64'; +import { encodeStringAsBase64 } from '@wp-playground/common'; import { ActiveSiteSettingsForm } from '../site-settings-form/active-site-settings-form'; import { getRelativeDate } from '../../../lib/get-relative-date'; import { setActiveModal } from '../../../lib/state/redux/slice-ui'; diff --git a/packages/playground/website/src/lib/base64.ts b/packages/playground/website/src/lib/base64.ts deleted file mode 100644 index b6e659ab51..0000000000 --- a/packages/playground/website/src/lib/base64.ts +++ /dev/null @@ -1,22 +0,0 @@ -export function decodeBase64ToString(base64: string) { - return new TextDecoder().decode(decodeBase64ToUint8Array(base64)); -} - -export function decodeBase64ToUint8Array(base64: string) { - const binaryString = window.atob(base64); // This will convert base64 to binary string - const len = binaryString.length; - const bytes = new Uint8Array(len); - for (let i = 0; i < len; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; -} - -export function encodeStringAsBase64(str: string) { - return encodeUint8ArrayAsBase64(new TextEncoder().encode(str)); -} - -export function encodeUint8ArrayAsBase64(bytes: Uint8Array) { - const binString = String.fromCodePoint(...bytes); - return btoa(binString); -} diff --git a/packages/playground/website/src/lib/state/redux/slice-sites.ts b/packages/playground/website/src/lib/state/redux/slice-sites.ts index b776f5d137..4aefa02e32 100644 --- a/packages/playground/website/src/lib/state/redux/slice-sites.ts +++ b/packages/playground/website/src/lib/state/redux/slice-sites.ts @@ -12,13 +12,11 @@ import { BlueprintReflection, type RuntimeConfiguration, resolveRuntimeConfiguration, -} from '@wp-playground/blueprints'; -import { type BlueprintSource, resolveBlueprintFromURL, type ResolvedBlueprint, applyQueryOverrides, -} from '../url/resolve-blueprint-from-url'; +} from '@wp-playground/blueprints'; import { logger } from '@php-wasm/logger'; /** diff --git a/packages/playground/website/src/lib/state/url/router.ts b/packages/playground/website/src/lib/state/url/router.ts index efed33e00c..ce7d1b43ed 100644 --- a/packages/playground/website/src/lib/state/url/router.ts +++ b/packages/playground/website/src/lib/state/url/router.ts @@ -1,6 +1,5 @@ import type { SiteInfo } from '../redux/slice-sites'; import { updateUrl } from './router-hooks'; -import { decodeBase64ToString } from '../../base64'; export function redirectTo(url: string) { window.history.pushState({}, '', url); @@ -24,18 +23,6 @@ interface QueryAPIParams { 'blueprint-url'?: string; } -export function parseBlueprint(rawData: string) { - try { - try { - return JSON.parse(rawData); - } catch { - return JSON.parse(decodeBase64ToString(rawData)); - } - } catch { - throw new Error('Invalid blueprint'); - } -} - export class PlaygroundRoute { static site(site: SiteInfo, baseUrl: string = window.location.href) { if (site.metadata.storage === 'none') {