Skip to content
Draft
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
8 changes: 8 additions & 0 deletions packages/playground/blueprints/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ResolvedBlueprint> {
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
Expand All @@ -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,
Expand Down
159 changes: 159 additions & 0 deletions packages/playground/blueprints/src/lib/resolve-blueprint-from-url.ts
Original file line number Diff line number Diff line change
@@ -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<ResolvedBlueprint> {
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',
},
};
}
}
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
Loading
Loading