Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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