diff --git a/docs/plans/2026-03-30-blueprints-v2-ts-runner-design.md b/docs/plans/2026-03-30-blueprints-v2-ts-runner-design.md new file mode 100644 index 00000000000..da85418c006 --- /dev/null +++ b/docs/plans/2026-03-30-blueprints-v2-ts-runner-design.md @@ -0,0 +1,381 @@ +# Blueprints V2 TypeScript Runner — Design Document + +**Date:** 2026-03-30 +**Status:** Design + +## Summary + +Replace the PHP `.phar`-based Blueprints V2 runner in WordPress Playground with a +native TypeScript implementation. The TS runner lives in the existing blueprints +package (`packages/playground/blueprints/src/lib/v2/`) and implements full spec +parity with the PHP runner. The PHP runner in `php-toolkit` continues to exist +independently for non-Playground use. + +A future compliance test suite (shared between the PHP and TS runners) will +verify both implementations against the same blueprint fixtures. + +## Design Decisions + +| Decision | Choice | +| ----------------------- | ------------------------------------------------------- | +| Scope | Full spec parity with PHP runner | +| Location | Same package, `src/lib/v2/` subtree | +| PHP runner relationship | Replace within Playground | +| Step handler reuse | Independent V2 handlers (can share low-level utilities) | + +## Reference Documents + +- [WEP-1: Blueprint V2 Schema](https://github.com/Automattic/WordPress-extension-proposals/tree/trunk/wep-1-blueprint-v2-schema) +- [PHP Blueprints Runner](https://github.com/WordPress/php-toolkit/tree/trunk/components/Blueprints) +- Existing V1 implementation: `packages/playground/blueprints/src/lib/v1/` + +## Architecture + +### Pipeline + +``` +Blueprint JSON (or V1 blueprint) + → Parse & Validate (AJV schema validation, human-friendly errors) + → V1→V2 Transpilation (if no `version` property) + → Resolve Runtime Configuration (PHP/WP version constraints, app options) + → Create Execution Plan (transpile declarative props → ordered steps) + → Resolve Data References (download plugins, themes, etc. — eager, concurrent) + → Execute Steps (sequentially, against UniversalPHP) +``` + +### Module Structure + +``` +packages/playground/blueprints/src/lib/v2/ +├── index.ts # Public exports +├── types.ts # CompiledBlueprintV2, V2StepHandler, etc. +├── run.ts # Top-level compileBlueprintV2() + run orchestration +├── compile/ +│ ├── compile.ts # Main compilation pipeline +│ ├── compile.spec.ts +│ ├── validate.ts # AJV schema validation +│ ├── transpile-declarative.ts # Declarative props → ordered steps +│ ├── transpile-declarative.spec.ts +│ ├── v1-to-v2-transpiler.ts # V1 → V2 blueprint transpilation +│ ├── v1-to-v2-transpiler.spec.ts +│ ├── merge.ts # Blueprint composition/merging +│ └── merge.spec.ts +├── data-references/ +│ ├── resolver.ts # DataReference → ResolvedFile/Dir +│ ├── resolver.spec.ts +│ └── types.ts # ResolvedFile, ResolvedDirectory +├── steps/ +│ ├── index.ts # Step handler registry +│ ├── define-constants.ts +│ ├── set-site-options.ts +│ ├── install-plugin.ts +│ ├── install-plugin.spec.ts +│ ├── activate-plugin.ts +│ ├── install-theme.ts +│ ├── activate-theme.ts +│ ├── import-content.ts +│ ├── import-media.ts +│ ├── run-php.ts +│ ├── run-sql.ts +│ ├── wp-cli.ts +│ ├── write-files.ts +│ ├── filesystem.ts # cp, mv, mkdir, rm, rmdir +│ ├── unzip.ts +│ ├── set-site-language.ts +│ └── import-theme-starter-content.ts +└── blueprint-v2-declaration.ts # (existing, updated) +``` + +### Public API + +Two main functions exported from the package: + +```typescript +/** + * Compiles a Blueprint V2 (or V1) declaration into an executable form. + * Handles V1 detection/transpilation, schema validation, and + * transpilation of declarative properties into ordered steps. + */ +function compileBlueprintV2(blueprint: BlueprintV2Declaration | BlueprintV1Declaration, options?: CompileBlueprintV2Options): Promise; + +interface CompileBlueprintV2Options { + progress?: ProgressTracker; + semaphore?: Semaphore; // Concurrency control (default: 3) + corsProxy?: string; + executionContext?: ReadableFilesystemBackend; // For bundle paths + onStepCompleted?: (step: string, index: number) => void; +} +``` + +```typescript +interface CompiledBlueprintV2 { + runtimeConfig: { + phpVersion?: VersionConstraint; + wordpressVersion?: VersionConstraint; + applicationOptions?: { + 'wordpress-playground'?: { + landingPage?: string; + login?: boolean | { username: string; password: string }; + networkAccess?: boolean; + }; + }; + }; + steps: CompiledV2Step[]; + run: (playground: UniversalPHP) => Promise; +} +``` + +## Compilation & Validation + +### Validation + +JSON schema validation using AJV, matching the approach in V1. The schema is +generated from the TypeScript types in `wep-1-blueprint-v2-schema/`. Validation +produces human-friendly error messages per the spec — suggesting typo +corrections for step names, pointing to specific JSON paths, etc. + +### Transpilation: Declarative → Steps + +Declarative properties are converted into an ordered step list following the +spec-defined order: + +1. `constants` → `defineConstants` +2. `siteOptions` → `setSiteOptions` +3. `muPlugins` → mu-plugin installation +4. `themes` → `installTheme` (each, not activated) +5. `activeTheme` → `installTheme` + `activateTheme` +6. `plugins` → `installPlugin` (each, `active: true` by default) +7. `fonts` → font installation +8. `media` → `importMedia` +9. `siteLanguage` → `setSiteLanguage` +10. `roles` → role creation via `runPHP` +11. `users` → user creation via `runPHP` +12. `postTypes` → post type registration via `runPHP` +13. `content` → `importContent` +14. `additionalStepsAfterExecution` → appended as-is + +Each compiled step holds its raw args plus unresolved data references. +Resolution happens lazily during execution — downloads start eagerly but steps +await their specific references before running. + +## Data References + +### V2 Reference Types + +```typescript +type DataReference = + | URLReference // "https://..." + | ExecutionContextPath // "./" or "/" prefixed paths + | InlineFile // { filename, content } + | InlineDirectory // { directoryName, files } + | GitPath; // { gitRepository, ref?, pathInRepository? } +``` + +Plus contextual references for specific schema locations: + +- `PluginDirectoryReference` — `"jetpack"` or `"jetpack@6.4.3"` +- `ThemeDirectoryReference` — same pattern for themes + +### Resolution + +The resolver converts a `DataReference` into concrete content: + +1. **Classify** the reference by inspecting its shape +2. **Fetch** the content (HTTP download, WordPress.org API, execution context + filesystem read, or inline content unwrap) +3. **Return** a `ResolvedFile` (bytes + filename) or `ResolvedDirectory` (filesystem tree) + +Configuration: + +- `Semaphore` for concurrency limiting (default 3 concurrent downloads) +- Optional CORS proxy URL +- Execution context filesystem (for `./` and `/` path resolution) +- Optional git auth headers callback + +Downloads are queued eagerly at the start of execution in the order steps will +need them. Each step awaits its references before running. + +## Step Handlers + +Each step has an independent handler with this signature: + +```typescript +type V2StepHandler = (playground: UniversalPHP, args: TArgs, context: StepExecutionContext) => Promise; + +interface StepExecutionContext { + progress: ProgressTracker; + resolver: DataReferenceResolver; + executionContext?: ReadableFilesystemBackend; +} +``` + +### Handler Inventory + +| Handler | Behavior | +| ---------------------------------- | -------------------------------------------------------------------------------------------------- | +| `defineConstants` | Writes `define()` calls into `wp-config.php` | +| `setSiteOptions` | Runs PHP to call `update_option()` per key-value pair | +| `setSiteLanguage` | Sets WPLANG, downloads translations via WP API | +| `installPlugin` | Resolves source (slug, URL, path, inline), extracts to `wp-content/plugins/`, optionally activates | +| `activatePlugin` | Runs PHP to call `activate_plugin()` | +| `installTheme` | Resolves source, extracts to `wp-content/themes/` | +| `activateTheme` | Runs PHP to call `switch_theme()` | +| `importThemeStarterContent` | Runs PHP to trigger theme starter content import | +| `importContent` | Handles `mysql-dump`, `posts`, and `wxr` content types via PHP | +| `importMedia` | Uploads files to WP Media Library via PHP | +| `runPHP` | Resolves inline/file PHP code, executes via `playground.run()` | +| `runSQL` | Resolves SQL source, executes statements against the database | +| `wp-cli` | Runs WP-CLI commands via the PHP runtime | +| `writeFiles` | Resolves each file reference, writes to target paths | +| `cp`, `mv`, `mkdir`, `rm`, `rmdir` | Filesystem operations on the Playground VFS | +| `unzip` | Resolves zip source, extracts to target path | + +Most handlers resolve data references then execute PHP. The complex ones are +`installPlugin`/`installTheme` (detecting zip vs directory vs single-file +format) and `importContent` (delegating to Data Liberation importers in PHP). + +## V1→V2 Transpilation + +Any blueprint without a `version` property is treated as V1. The transpiler +(`compile/v1-to-v2-transpiler.ts`) follows the spec's mapping tables: + +### Top-level Property Mapping + +| V1 property | V2 destination | +| -------------------------- | ---------------------------------------------------------- | +| `preferredVersions.php/wp` | `phpVersion`/`wordpressVersion` | +| `landingPage` | `applicationOptions['wordpress-playground'].landingPage` | +| `login` | `applicationOptions['wordpress-playground'].login` | +| `features.networking` | `applicationOptions['wordpress-playground'].networkAccess` | +| `meta.title` | `blueprintMeta.name` | +| `meta.description` | `blueprintMeta.description` | +| `meta.author` | `blueprintMeta.authors` (wrapped in array) | +| `meta.categories` | `blueprintMeta.tags` | +| `plugins` (shorthand) | `additionalStepsAfterExecution[].installPlugin` | +| `steps` | `additionalStepsAfterExecution` (with per-step rewrites) | +| `constants` | `additionalStepsAfterExecution[].defineConstants` | +| `siteOptions` | `additionalStepsAfterExecution[].setSiteOptions` | + +### Per-Step Rewrites + +Each V1 step maps to a V2 equivalent with renamed fields per the spec tables +(e.g., `pluginData` → `source`, `themeFolderName` → `themeDirectoryName`, +`defineWpConfigConsts` → `defineConstants`). + +### Resource→DataReference Conversion + +V1 resource objects are rewritten to V2 data references: + +- `{ resource: "url", url: "..." }` → the URL string +- `{ resource: "literal", name, contents }` → `{ filename, content }` +- `{ resource: "wordpress.org/plugins", slug }` → the slug string +- `{ resource: "vfs", path }` → `"site:"` +- `{ resource: "bundled", path }` → `"./"` +- `{ resource: "git:directory", ... }` → `{ gitRepository, ref, pathInRepository }` + +### Path Translation + +VFS paths starting with `/wordpress/` or `wordpress/` are rewritten to +document-root-relative paths. PHP code in `runPHP` steps gets `/wordpress/` +literals replaced with `getenv('DOCROOT') . '/'`. + +## Blueprint Composition + +The merge algorithm (`compile/merge.ts`) implements the spec's composition rules: + +1. **Initialize** an empty merge target +2. **Validate** each input blueprint +3. **Merge loop** — for each input blueprint: + - `version`: assert same + - `blueprintMeta`, `$schema`: ignore + - `siteLanguage`, `activeTheme`: use if only one defines it, conflict if both differ + - `constants`, `siteOptions`, `postTypes`, `fonts`: append key-value pairs, fail on conflicts + - `phpVersion`, `wordpressVersion`: intersect version ranges, fail if empty intersection + - `plugins`, `themes`, `muPlugins`: merge by slug, assert identical definitions + - `additionalStepsAfterExecution`, `content`, `media`: append + - `users`: merge by username/email, fail on role conflicts + - `roles`: merge by name, fail on capability conflicts +4. **File merge** for bundles: copy files, fail on path collisions + +## Integration Points + +### CLI (`packages/playground/cli/`) + +In `blueprints-v2/worker-thread-v2.ts`, replace: + +```typescript +const streamed = await runBlueprintV2({ php, blueprint, ... }); +``` + +With: + +```typescript +const compiled = await compileBlueprintV2(blueprint, { progress, ... }); +await compiled.run(php); +``` + +### Website Remote Worker (`packages/playground/remote/`) + +In `playground-worker-endpoint-blueprints-v2.ts`, same replacement pattern. + +### Client (`packages/playground/client/`) + +No changes — it already passes the blueprint to the remote worker and listens +for events. + +### What Gets Deleted + +- `get-v2-runner.ts` (loads the `.phar` binary) +- The old `run-blueprint-v2.ts` (PHP execution wrapper) +- The `.phar` binary from the build pipeline +- The `run-blueprints.php` helper script + +### What Stays + +- `blueprint-v2-declaration.ts` (types and parsing, updated) +- `wep-1-blueprint-v2-schema/` (source of truth for V2 types) +- The `--experimental-blueprints-v2-runner` flag + +## Testing + +### Unit Tests + +Co-located `.spec.ts` files using the same pattern as V1 — create a +`UniversalPHP` instance, run compiled blueprints, assert state. Key areas: + +- **Transpilation order** — declarative properties produce steps in spec-defined order +- **V1→V2 transpilation** — every mapping from the spec's tables +- **Data reference resolution** — each reference type, error cases, concurrency +- **Step handlers** — each handler with various input shapes +- **Merge algorithm** — conflict detection, version intersection, file collisions +- **Validation** — schema conformance, human-friendly error messages + +### Future: Compliance Test Suite + +A shared test suite that runs identical blueprint fixtures against both the PHP +and TS runners. This will be developed separately and can validate spec +conformance across implementations. The per-handler tests here are designed to +be extractable into that shared suite. + +## Error Handling + +Per the spec: + +- Validation failures stop execution with human-friendly messages +- Step failures stop execution and report which step failed +- Failed blueprints do NOT clean up — the site remains for debugging +- Optional steps (via `onError: 'skip-plugin'`) log warnings but continue + +Error types: + +- `InvalidBlueprintError` — schema validation failure +- `BlueprintStepExecutionError` — step runtime failure (includes step index and name) +- `DataReferenceResolutionError` — failed download, missing file, etc. +- `BlueprintMergeConflictError` — composition conflict + +## Progress Tracking + +Uses `ProgressTracker` from `@php-wasm/progress` (same as V1). Each step gets +a weight based on expected cost (plugin installs weighted higher than +`defineConstants`). Captions update as steps execute. The existing integration +points already expect progress events. diff --git a/docs/plans/2026-03-30-blueprints-v2-ts-runner-plan.md b/docs/plans/2026-03-30-blueprints-v2-ts-runner-plan.md new file mode 100644 index 00000000000..399999a75b7 --- /dev/null +++ b/docs/plans/2026-03-30-blueprints-v2-ts-runner-plan.md @@ -0,0 +1,1663 @@ +# Blueprints V2 TypeScript Runner Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the PHP `.phar`-based Blueprints V2 runner in WordPress Playground with a native TypeScript implementation that has full spec parity. + +**Architecture:** The V2 TS runner follows the spec's pipeline: parse → validate → transpile declarative props to steps → resolve data references → execute steps sequentially against UniversalPHP. It lives in `packages/playground/blueprints/src/lib/v2/` alongside the existing schema types, replacing the PHP wrapper files. + +**Tech Stack:** TypeScript, Vitest, AJV (JSON schema validation), `@php-wasm/universal` (UniversalPHP interface), `@php-wasm/progress` (ProgressTracker), `@php-wasm/util` (path utilities, Semaphore) + +**Worktree:** `.worktrees/blueprints-v2-ts-runner` (branch: `feature/blueprints-v2-ts-runner`) + +**Reference docs:** + +- Design: `docs/plans/2026-03-30-blueprints-v2-ts-runner-design.md` +- V2 Spec: https://github.com/Automattic/WordPress-extension-proposals/tree/trunk/wep-1-blueprint-v2-schema +- PHP Runner: https://github.com/WordPress/php-toolkit/tree/trunk/components/Blueprints +- V1 Implementation (pattern reference): `packages/playground/blueprints/src/lib/v1/` + +--- + +## Phase 1: Foundation — Types, Module Structure, Exports + +### Task 1: Create V2 type definitions + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/types.ts` + +**Context:** This file defines the core interfaces for the compiled blueprint, step handlers, and execution context. Follow the pattern from V1's `compile.ts` lines 46-143 but adapted for V2's schema. + +**Step 1: Create the types file** + +```typescript +import type { UniversalPHP } from '@php-wasm/universal'; +import type { ProgressTracker } from '@php-wasm/progress'; +import type { Semaphore } from '@php-wasm/util'; + +// Re-export the V2 schema types as the declaration type +import type { Blueprint as BlueprintV2Schema } from './wep-1-blueprint-v2-schema/appendix-A-blueprint-v2-schema'; + +export type BlueprintV2Declaration = BlueprintV2Schema; + +/** + * Result of compiling a V2 blueprint. Contains resolved + * runtime configuration and an executable run() function. + */ +export interface CompiledBlueprintV2 { + runtimeConfig: V2RuntimeConfig; + steps: CompiledV2Step[]; + run: (playground: UniversalPHP) => Promise; +} + +export interface V2RuntimeConfig { + phpVersion?: string | V2VersionConstraint; + wordpressVersion?: string | V2VersionConstraint; + applicationOptions?: { + 'wordpress-playground'?: { + landingPage?: string; + login?: boolean | { username: string; password: string }; + networkAccess?: boolean; + }; + }; +} + +export interface V2VersionConstraint { + min?: string; + max?: string; + preferred?: string; +} + +export interface CompiledV2Step { + step: string; + args: Record; + progress?: { weight?: number; caption?: string }; +} + +/** + * Context passed to every step handler during execution. + */ +export interface StepExecutionContext { + progress: ProgressTracker; + resolver: DataReferenceResolver; +} + +/** + * Interface for the data reference resolver. Steps use this + * to resolve DataReferences into concrete file/directory content. + */ +export interface DataReferenceResolver { + resolveFile(ref: unknown): Promise; + resolveDirectory(ref: unknown): Promise; +} + +export interface ResolvedFile { + name: string; + contents: Uint8Array; +} + +export interface ResolvedDirectory { + name: string; + files: Record; +} + +/** + * Signature for all V2 step handlers. + */ +export type V2StepHandler> = (playground: UniversalPHP, args: TArgs, context: StepExecutionContext) => Promise; + +/** + * Options for compileBlueprintV2(). + */ +export interface CompileBlueprintV2Options { + progress?: ProgressTracker; + semaphore?: Semaphore; + corsProxy?: string; + executionContext?: ReadableFilesystemBackend; + onStepCompleted?: (step: string, index: number) => void; +} + +/** + * Error thrown when a blueprint fails schema validation. + */ +export class InvalidBlueprintV2Error extends Error { + constructor( + message: string, + public validationErrors?: unknown[] + ) { + super(message); + this.name = 'InvalidBlueprintV2Error'; + } +} + +/** + * Error thrown when a step fails during execution. + */ +export class BlueprintV2StepExecutionError extends Error { + constructor( + message: string, + public stepIndex: number, + public stepName: string, + public cause?: Error + ) { + super(message); + this.name = 'BlueprintV2StepExecutionError'; + } +} + +/** + * Error thrown when a data reference cannot be resolved. + */ +export class DataReferenceResolutionError extends Error { + constructor( + message: string, + public reference: unknown + ) { + super(message); + this.name = 'DataReferenceResolutionError'; + } +} + +/** + * Error thrown when blueprint merging finds a conflict. + */ +export class BlueprintMergeConflictError extends Error { + constructor( + message: string, + public conflicts: string[] + ) { + super(message); + this.name = 'BlueprintMergeConflictError'; + } +} +``` + +Note: The `ReadableFilesystemBackend` type is imported from `@php-wasm/universal`. The V2 schema types from `wep-1-blueprint-v2-schema/` are TypeScript-only type declarations (not runtime values), so the import may need to be adjusted based on how those files are currently set up. Check whether those appendix files are importable as modules or just reference documentation. If they aren't importable, define the necessary V2 schema types inline in this file. + +**Step 2: Commit** + +```bash +git add packages/playground/blueprints/src/lib/v2/types.ts +git commit -m "feat(blueprints): add V2 TypeScript runner type definitions" +``` + +--- + +### Task 2: Create the step handler registry + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/index.ts` + +**Context:** This is a registry mapping step names to their handler functions. Follow the pattern from V1's `steps/handlers.ts` but simpler — just a plain object mapping. + +**Step 1: Create the registry skeleton** + +```typescript +import type { V2StepHandler } from '../types'; + +/** + * Registry of all V2 step handlers, keyed by step name. + * Handlers are added as they are implemented. + */ +export const v2StepHandlers: Record = {}; + +/** + * Register a step handler. Called by each step module. + */ +export function registerV2StepHandler(stepName: string, handler: V2StepHandler): void { + v2StepHandlers[stepName] = handler; +} +``` + +**Step 2: Commit** + +```bash +git add packages/playground/blueprints/src/lib/v2/steps/index.ts +git commit -m "feat(blueprints): add V2 step handler registry" +``` + +--- + +### Task 3: Create the compilation pipeline skeleton + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/compile/compile.ts` + +**Context:** This is the main entry point for V2 blueprint compilation. It orchestrates validation, declarative-to-step transpilation, and returns a `CompiledBlueprintV2`. The `run()` function on the returned object handles data reference resolution and step execution. + +**Step 1: Create the compile skeleton** + +```typescript +import type { UniversalPHP } from '@php-wasm/universal'; +import type { ProgressTracker } from '@php-wasm/progress'; +import type { BlueprintV2Declaration, CompiledBlueprintV2, CompiledV2Step, CompileBlueprintV2Options, V2RuntimeConfig, InvalidBlueprintV2Error, BlueprintV2StepExecutionError, StepExecutionContext } from '../types'; +import { v2StepHandlers } from '../steps/index'; + +/** + * Compiles a V2 blueprint declaration into an executable form. + * + * This is the main entry point for V2 blueprint processing. + * It validates the blueprint, extracts runtime configuration, + * transpiles declarative properties into ordered steps, and + * returns an object whose run() method executes the blueprint. + */ +export async function compileBlueprintV2(blueprint: BlueprintV2Declaration, options: CompileBlueprintV2Options = {}): Promise { + // TODO: Task 6 — validate against JSON schema + // TODO: Task 7 — detect V1 and transpile to V2 + + const runtimeConfig = extractRuntimeConfig(blueprint); + const steps = transpileDeclarativeToSteps(blueprint); + + return { + runtimeConfig, + steps, + run: async (playground: UniversalPHP) => { + await executeSteps(playground, steps, options); + }, + }; +} + +function extractRuntimeConfig(blueprint: BlueprintV2Declaration): V2RuntimeConfig { + // TODO: Task 5 — implement runtime config extraction + return {}; +} + +function transpileDeclarativeToSteps(blueprint: BlueprintV2Declaration): CompiledV2Step[] { + // TODO: Task 8 — implement declarative-to-step transpilation + return []; +} + +async function executeSteps(playground: UniversalPHP, steps: CompiledV2Step[], options: CompileBlueprintV2Options): Promise { + // TODO: Task 9 — implement step execution loop +} +``` + +**Step 2: Commit** + +```bash +git add packages/playground/blueprints/src/lib/v2/compile/compile.ts +git commit -m "feat(blueprints): add V2 compilation pipeline skeleton" +``` + +--- + +### Task 4: Create the V2 module index and update package exports + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/index.ts` +- Modify: `packages/playground/blueprints/src/index.ts` + +**Context:** The V2 module needs its own barrel export, and the package's main index.ts needs updated exports that expose the new compile/run API alongside the existing V2 type exports. + +**Step 1: Create the V2 barrel export** + +```typescript +// V2 types +export type { BlueprintV2Declaration, CompiledBlueprintV2, CompiledV2Step, CompileBlueprintV2Options, V2RuntimeConfig, V2VersionConstraint, V2StepHandler, StepExecutionContext, DataReferenceResolver, ResolvedFile, ResolvedDirectory } from './types'; + +export { InvalidBlueprintV2Error, BlueprintV2StepExecutionError, DataReferenceResolutionError, BlueprintMergeConflictError } from './types'; + +// V2 compilation +export { compileBlueprintV2 } from './compile/compile'; + +// V2 step handlers (for extensibility) +export { v2StepHandlers, registerV2StepHandler } from './steps/index'; +``` + +**Step 2: Update the main package exports** + +In `packages/playground/blueprints/src/index.ts`, find the existing V2 exports section (around lines 63-71) and replace it. Keep the existing type exports that consumers may depend on, but add the new compile/run API. + +Look for this block: + +```typescript +export type { BlueprintV2, BlueprintV2Declaration, RawBlueprintV2Data, ParsedBlueprintV1orV2String as ParsedBlueprintV2String } from './lib/v2/blueprint-v2-declaration'; +export { getV2Runner } from './lib/v2/get-v2-runner'; +export { runBlueprintV2 } from './lib/v2/run-blueprint-v2'; +export type { BlueprintMessage } from './lib/v2/run-blueprint-v2'; +``` + +Replace with: + +```typescript +// V2 Blueprint types (keep existing type exports for now) +export type { BlueprintV2, BlueprintV2Declaration as BlueprintV2DeclarationLegacy, RawBlueprintV2Data, ParsedBlueprintV1orV2String as ParsedBlueprintV2String } from './lib/v2/blueprint-v2-declaration'; + +// V2 TypeScript runner (new) +export { compileBlueprintV2, InvalidBlueprintV2Error, BlueprintV2StepExecutionError, DataReferenceResolutionError, BlueprintMergeConflictError } from './lib/v2/index'; + +export type { CompiledBlueprintV2, CompileBlueprintV2Options, V2RuntimeConfig } from './lib/v2/index'; + +// Legacy V2 PHP runner exports (to be removed in cleanup phase) +export { getV2Runner } from './lib/v2/get-v2-runner'; +export { runBlueprintV2 } from './lib/v2/run-blueprint-v2'; +export type { BlueprintMessage } from './lib/v2/run-blueprint-v2'; +``` + +**Step 3: Verify the package builds** + +```bash +cd .worktrees/blueprints-v2-ts-runner +npx nx build playground-blueprints +``` + +Expected: Build succeeds (the skeleton code has no runtime dependencies yet that would break). + +If the build fails due to the V2 schema types not being importable, adjust `types.ts` to define the schema types inline instead of importing from the appendix files. + +**Step 4: Commit** + +```bash +git add packages/playground/blueprints/src/lib/v2/index.ts packages/playground/blueprints/src/index.ts +git commit -m "feat(blueprints): add V2 module barrel export and update package exports" +``` + +--- + +## Phase 2: Data Reference Resolver + +### Task 5: Implement the data reference resolver + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/data-references/types.ts` +- Create: `packages/playground/blueprints/src/lib/v2/data-references/resolver.ts` +- Create: `packages/playground/blueprints/src/lib/v2/data-references/resolver.spec.ts` + +**Context:** V2 data references are simpler than V1 resources. A reference is either a URL string, an execution context path, an inline file/directory object, a git path object, or a contextual slug. The resolver converts these into concrete bytes. + +Refer to the V2 spec's Appendix B (`wep-1-blueprint-v2-schema/appendix-B-data-sources.ts`) for the exact type definitions. + +**Step 1: Create data reference types** + +```typescript +/** + * V2 Data Reference types — mirrors Appendix B of the V2 spec. + */ + +export type URLReference = `http://${string}` | `https://${string}`; +export type ExecutionContextPath = `/${string}` | `./${string}`; + +export interface InlineFile { + filename: string; + content: string; +} + +export interface InlineDirectory { + directoryName: string; + files: Record; +} + +export interface GitPath { + gitRepository: URLReference; + ref?: string; + pathInRepository?: string; +} + +/** + * Union of all general data reference types. + */ +export type DataReference = URLReference | ExecutionContextPath | InlineFile | InlineDirectory | GitPath; + +/** + * Slug-based references for plugins and themes. + * E.g., "jetpack" or "jetpack@6.4.3" + */ +export type PluginDirectoryReference = string; +export type ThemeDirectoryReference = string; + +/** + * Resolved file — the output of resolving a DataReference. + */ +export interface ResolvedFile { + name: string; + contents: Uint8Array; +} + +/** + * Resolved directory — tree of resolved files. + */ +export interface ResolvedDirectory { + name: string; + files: Record; +} + +/** + * Configuration for the resolver. + */ +export interface DataReferenceResolverConfig { + semaphore?: import('@php-wasm/util').Semaphore; + corsProxy?: string; + executionContext?: import('@php-wasm/universal').ReadableFilesystemBackend; + gitAdditionalHeadersCallback?: (url: string) => Record; +} +``` + +**Step 2: Write failing tests for the resolver** + +Create `resolver.spec.ts` with tests for each reference type: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DataReferenceResolverImpl } from './resolver'; +import type { ResolvedFile, InlineFile, InlineDirectory } from './types'; + +describe('DataReferenceResolverImpl', () => { + let resolver: DataReferenceResolverImpl; + + beforeEach(() => { + resolver = new DataReferenceResolverImpl({}); + }); + + describe('resolveFile', () => { + it('should resolve an inline file', async () => { + const ref: InlineFile = { + filename: 'hello.php', + content: ' { + // Mock fetch for URL resolution + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new TextEncoder().encode('file contents').buffer), + headers: new Headers(), + }); + + const resolved = await resolver.resolveFile('https://example.com/plugin.zip'); + expect(resolved.name).toBe('plugin.zip'); + expect(resolved.contents).toBeInstanceOf(Uint8Array); + }); + + it('should resolve a WordPress.org plugin slug', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new TextEncoder().encode('zip data').buffer), + headers: new Headers(), + }); + + const resolved = await resolver.resolvePluginReference('jetpack'); + expect(resolved.name).toBe('jetpack.zip'); + expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('downloads.wordpress.org/plugin/jetpack'), expect.anything()); + }); + + it('should resolve a versioned plugin slug', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new TextEncoder().encode('zip data').buffer), + headers: new Headers(), + }); + + const resolved = await resolver.resolvePluginReference('akismet@6.4.3'); + expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('downloads.wordpress.org/plugin/akismet.6.4.3.zip'), expect.anything()); + }); + + it('should throw on failed URL fetch', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + await expect(resolver.resolveFile('https://example.com/missing.zip')).rejects.toThrow('DataReferenceResolutionError'); + }); + }); + + describe('resolveDirectory', () => { + it('should resolve an inline directory', async () => { + const ref: InlineDirectory = { + directoryName: 'my-plugin', + files: { + 'index.php': ' { + if (isInlineFile(ref)) { + return { + name: ref.filename, + contents: textEncoder.encode(ref.content), + }; + } + + if (typeof ref === 'string') { + if (isUrlReference(ref)) { + return this.fetchUrl(ref); + } + + if (isExecutionContextPath(ref)) { + return this.resolveExecutionContextPath(ref); + } + } + + if (isGitPath(ref)) { + return this.resolveGitPath(ref); + } + + throw new DataReferenceResolutionError(`Cannot resolve data reference: ${JSON.stringify(ref)}`, ref); + } + + async resolveDirectory(ref: unknown): Promise { + if (isInlineDirectory(ref)) { + return this.resolveInlineDirectory(ref); + } + + if (typeof ref === 'string' && isExecutionContextPath(ref)) { + return this.resolveExecutionContextDirectoryPath(ref); + } + + if (isGitPath(ref)) { + return this.resolveGitDirectoryPath(ref); + } + + throw new DataReferenceResolutionError(`Cannot resolve directory reference: ${JSON.stringify(ref)}`, ref); + } + + async resolvePluginReference(slug: string): Promise { + const { name, version } = parseSlugWithVersion(slug); + const versionSuffix = version ? `.${version}` : ''; + const url = `https://downloads.wordpress.org/plugin/` + `${name}${versionSuffix}.zip`; + return this.fetchUrl(url, `${name}.zip`); + } + + async resolveThemeReference(slug: string): Promise { + const { name, version } = parseSlugWithVersion(slug); + const versionSuffix = version ? `.${version}` : ''; + const url = `https://downloads.wordpress.org/theme/` + `${name}${versionSuffix}.zip`; + return this.fetchUrl(url, `${name}.zip`); + } + + private async fetchUrl(url: string, filename?: string): Promise { + const effectiveUrl = this.corsProxy ? `${this.corsProxy}${url}` : url; + + return this.semaphore.run(async () => { + const response = await fetch(effectiveUrl, { + redirect: 'follow', + }); + + if (!response.ok) { + throw new DataReferenceResolutionError(`Failed to fetch ${url}: ` + `${response.status} ${response.statusText}`, url); + } + + const buffer = await response.arrayBuffer(); + const name = filename ?? url.split('/').pop() ?? 'downloaded-file'; + + return { + name, + contents: new Uint8Array(buffer), + }; + }); + } + + private async resolveExecutionContextPath(path: string): Promise { + if (!this.executionContext) { + throw new DataReferenceResolutionError(`Cannot resolve execution context path "${path}": ` + `no execution context provided`, path); + } + + const normalizedPath = normalizePath(path); + const contents = await this.executionContext.readFileAsBuffer(normalizedPath); + const name = normalizedPath.split('/').pop() ?? 'file'; + + return { name, contents: new Uint8Array(contents) }; + } + + private async resolveExecutionContextDirectoryPath(path: string): Promise { + if (!this.executionContext) { + throw new DataReferenceResolutionError(`Cannot resolve execution context path "${path}": ` + `no execution context provided`, path); + } + + // Read directory listing and build tree + const normalizedPath = normalizePath(path); + const name = normalizedPath.split('/').pop() ?? 'directory'; + return this.readDirectoryFromContext(normalizedPath, name); + } + + private async readDirectoryFromContext(path: string, name: string): Promise { + const listing = await this.executionContext.listFiles(path); + const files: Record = {}; + + for (const entry of listing) { + const entryPath = `${path}/${entry}`; + try { + const contents = await this.executionContext.readFileAsBuffer(entryPath); + files[entry] = new Uint8Array(contents); + } catch { + // If reading as file fails, try as directory + files[entry] = await this.readDirectoryFromContext(entryPath, entry); + } + } + + return { name, files }; + } + + private resolveInlineDirectory(ref: InlineDirectory): ResolvedDirectory { + const files: Record = {}; + + for (const [key, value] of Object.entries(ref.files)) { + if (typeof value === 'string') { + files[key] = textEncoder.encode(value); + } else { + files[key] = this.resolveInlineDirectory(value); + } + } + + return { name: ref.directoryName, files }; + } + + private async resolveGitPath(ref: GitPath): Promise { + // Git paths are resolved by fetching the repository archive + // This is a simplified implementation — full git clone + // support can be added later via @wp-playground/storage + const archiveUrl = buildGitArchiveUrl(ref.gitRepository, ref.ref); + return this.fetchUrl(archiveUrl); + } + + private async resolveGitDirectoryPath(ref: GitPath): Promise { + // For git directories, we fetch as an archive and extract + // the specified path. Full implementation deferred. + throw new DataReferenceResolutionError('Git directory references are not yet implemented', ref); + } +} + +// --- Helper functions --- + +function isInlineFile(ref: unknown): ref is InlineFile { + return typeof ref === 'object' && ref !== null && 'filename' in ref && 'content' in ref; +} + +function isInlineDirectory(ref: unknown): ref is InlineDirectory { + return typeof ref === 'object' && ref !== null && 'directoryName' in ref && 'files' in ref; +} + +function isUrlReference(ref: string): ref is URLReference { + return ref.startsWith('http://') || ref.startsWith('https://'); +} + +function isExecutionContextPath(ref: string): boolean { + return ref.startsWith('./') || ref.startsWith('/'); +} + +function isGitPath(ref: unknown): ref is GitPath { + return typeof ref === 'object' && ref !== null && 'gitRepository' in ref; +} + +/** + * Parse a slug that may include a version suffix. + * E.g., "jetpack@6.4.3" → { name: "jetpack", version: "6.4.3" } + */ +function parseSlugWithVersion(slug: string): { + name: string; + version?: string; +} { + const atIndex = slug.indexOf('@'); + if (atIndex === -1) { + return { name: slug }; + } + return { + name: slug.substring(0, atIndex), + version: slug.substring(atIndex + 1), + }; +} + +/** + * Normalize an execution context path by resolving "./" + * and preventing "../" escapes. + */ +function normalizePath(path: string): string { + // Strip leading ./ — both ./ and / are relative to context root + let normalized = path.replace(/^\.\//, '/'); + if (!normalized.startsWith('/')) { + normalized = '/' + normalized; + } + + // Prevent path traversal + const segments = normalized.split('/').filter(Boolean); + const resolved: string[] = []; + for (const segment of segments) { + if (segment === '..') { + // Per spec: cannot escape execution context + continue; + } + if (segment !== '.') { + resolved.push(segment); + } + } + + return '/' + resolved.join('/'); +} + +function buildGitArchiveUrl(repoUrl: string, ref?: string): string { + // Convert GitHub repo URLs to archive download URLs + const githubMatch = repoUrl.match(/github\.com\/([^/]+)\/([^/]+?)(\.git)?$/); + if (githubMatch) { + const [, owner, repo] = githubMatch; + const branch = ref ?? 'HEAD'; + return `https://github.com/${owner}/${repo}/archive/${branch}.zip`; + } + + // For non-GitHub repos, attempt a generic archive URL + const branch = ref ?? 'HEAD'; + return `${repoUrl}/archive/${branch}.zip`; +} + +export { isInlineFile, isInlineDirectory, isUrlReference, isExecutionContextPath, isGitPath, parseSlugWithVersion, normalizePath }; +``` + +**Step 5: Run tests to verify they pass** + +```bash +npx nx test playground-blueprints --testFile=resolver.spec.ts +``` + +Expected: All tests pass. + +**Step 6: Commit** + +```bash +git add packages/playground/blueprints/src/lib/v2/data-references/ +git commit -m "feat(blueprints): implement V2 data reference resolver" +``` + +--- + +## Phase 3: Compilation Pipeline + +### Task 6: Implement schema validation + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/compile/validate.ts` +- Create: `packages/playground/blueprints/src/lib/v2/compile/validate.spec.ts` + +**Context:** Validate a blueprint object against the V2 JSON schema using AJV. The package already depends on AJV for V1 validation — check `package.json` for the exact version. Follow the same validation approach as V1 (see `packages/playground/blueprints/src/lib/v1/compile.ts` around line 200 where `validateBlueprint()` is called). + +For the initial implementation, write a structural validator that checks the key properties and types without a full JSON schema. A full AJV-based JSON schema can be generated from the TypeScript types later (same as V1 does with its build step `build:blueprint-schema`). + +**Step 1: Write failing tests** + +```typescript +import { describe, it, expect } from 'vitest'; +import { validateBlueprintV2 } from './validate'; + +describe('validateBlueprintV2', () => { + it('should accept a valid minimal V2 blueprint', () => { + const result = validateBlueprintV2({ version: 2 }); + expect(result.valid).toBe(true); + }); + + it('should reject a blueprint without version: 2', () => { + const result = validateBlueprintV2({} as any); + expect(result.valid).toBe(false); + }); + + it('should reject a blueprint with wrong version', () => { + const result = validateBlueprintV2({ version: 1 } as any); + expect(result.valid).toBe(false); + }); + + it('should accept a blueprint with plugins', () => { + const result = validateBlueprintV2({ + version: 2, + plugins: ['jetpack', 'akismet'], + }); + expect(result.valid).toBe(true); + }); + + it('should accept a blueprint with all declarative properties', () => { + const result = validateBlueprintV2({ + version: 2, + wordpressVersion: '6.6', + phpVersion: '8.1', + plugins: ['jetpack'], + themes: ['twentytwentyfour'], + activeTheme: 'twentytwentyfour', + constants: { WP_DEBUG: true }, + siteOptions: { blogname: 'Test Site' }, + siteLanguage: 'en_US', + }); + expect(result.valid).toBe(true); + }); + + it('should provide human-friendly error messages', () => { + const result = validateBlueprintV2({ + version: 2, + additionalStepsAfterExecution: [{ step: 'intallPlugi' }], + } as any); + // Should suggest correct step name + if (!result.valid) { + expect(result.errors[0]).toContain('installPlugin'); + } + }); +}); +``` + +**Step 2: Run tests, verify failure** + +```bash +npx nx test playground-blueprints --testFile=validate.spec.ts +``` + +**Step 3: Implement the validator** + +Write `validate.ts` with structural validation. Check `version: 2` is present, validate known property types, and for `additionalStepsAfterExecution`, validate step names against the known set with fuzzy matching for error messages. + +**Step 4: Run tests, verify pass** + +**Step 5: Commit** + +```bash +git add packages/playground/blueprints/src/lib/v2/compile/validate.ts packages/playground/blueprints/src/lib/v2/compile/validate.spec.ts +git commit -m "feat(blueprints): implement V2 blueprint validation" +``` + +--- + +### Task 7: Implement runtime configuration extraction + +**Files:** + +- Modify: `packages/playground/blueprints/src/lib/v2/compile/compile.ts` +- Create: `packages/playground/blueprints/src/lib/v2/compile/compile.spec.ts` + +**Context:** Extract `phpVersion`, `wordpressVersion`, and `applicationOptions` from the blueprint into a normalized `V2RuntimeConfig`. Version constraints can be a string (`"8.1"`, `"latest"`) or an object (`{ min: "8.0", max: "8.2" }`). + +**Step 1: Write failing tests** + +```typescript +import { describe, it, expect } from 'vitest'; +import { compileBlueprintV2 } from './compile'; + +describe('compileBlueprintV2 — runtime config', () => { + it('should extract simple PHP version', async () => { + const compiled = await compileBlueprintV2({ + version: 2, + phpVersion: '8.1', + } as any); + expect(compiled.runtimeConfig.phpVersion).toBe('8.1'); + }); + + it('should extract PHP version constraint', async () => { + const compiled = await compileBlueprintV2({ + version: 2, + phpVersion: { min: '8.0', max: '8.2' }, + } as any); + expect(compiled.runtimeConfig.phpVersion).toEqual({ + min: '8.0', + max: '8.2', + }); + }); + + it('should extract WordPress version', async () => { + const compiled = await compileBlueprintV2({ + version: 2, + wordpressVersion: '6.6', + } as any); + expect(compiled.runtimeConfig.wordpressVersion).toBe('6.6'); + }); + + it('should extract application options', async () => { + const compiled = await compileBlueprintV2({ + version: 2, + applicationOptions: { + 'wordpress-playground': { + landingPage: '/wp-admin/plugins.php', + login: true, + networkAccess: true, + }, + }, + } as any); + const opts = compiled.runtimeConfig.applicationOptions?.['wordpress-playground']; + expect(opts?.landingPage).toBe('/wp-admin/plugins.php'); + expect(opts?.login).toBe(true); + expect(opts?.networkAccess).toBe(true); + }); + + it('should return empty config for minimal blueprint', async () => { + const compiled = await compileBlueprintV2({ + version: 2, + } as any); + expect(compiled.runtimeConfig.phpVersion).toBeUndefined(); + expect(compiled.runtimeConfig.wordpressVersion).toBeUndefined(); + }); +}); +``` + +**Step 2: Run tests, verify failure** + +**Step 3: Implement `extractRuntimeConfig()` in compile.ts** + +Fill in the `extractRuntimeConfig` function to read `phpVersion`, `wordpressVersion`, and `applicationOptions` from the blueprint and return them in the `V2RuntimeConfig` shape. + +**Step 4: Run tests, verify pass** + +**Step 5: Commit** + +```bash +git add packages/playground/blueprints/src/lib/v2/compile/ +git commit -m "feat(blueprints): implement V2 runtime configuration extraction" +``` + +--- + +### Task 8: Implement declarative-to-step transpilation + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/compile/transpile-declarative.ts` +- Create: `packages/playground/blueprints/src/lib/v2/compile/transpile-declarative.spec.ts` + +**Context:** This is the core of the V2 compilation. Declarative properties are transpiled into ordered steps following the spec-defined order (constants → siteOptions → muPlugins → themes → activeTheme → plugins → fonts → media → siteLanguage → roles → users → postTypes → content → additionalStepsAfterExecution). + +**Step 1: Write failing tests** + +Test that each declarative property produces the correct step type in the correct position. Test ordering especially — this is critical for spec compliance. + +```typescript +import { describe, it, expect } from 'vitest'; +import { transpileDeclarativeToSteps } from './transpile-declarative'; +import type { CompiledV2Step } from '../types'; + +describe('transpileDeclarativeToSteps', () => { + it('should return empty array for minimal blueprint', () => { + const steps = transpileDeclarativeToSteps({ version: 2 } as any); + expect(steps).toEqual([]); + }); + + it('should transpile constants to defineConstants step', () => { + const steps = transpileDeclarativeToSteps({ + version: 2, + constants: { WP_DEBUG: true, SCRIPT_DEBUG: true }, + } as any); + expect(steps).toHaveLength(1); + expect(steps[0].step).toBe('defineConstants'); + expect(steps[0].args).toEqual({ + constants: { WP_DEBUG: true, SCRIPT_DEBUG: true }, + }); + }); + + it('should transpile plugins to installPlugin steps', () => { + const steps = transpileDeclarativeToSteps({ + version: 2, + plugins: ['jetpack', 'akismet'], + } as any); + expect(steps).toHaveLength(2); + expect(steps[0].step).toBe('installPlugin'); + expect(steps[0].args).toEqual({ + source: 'jetpack', + active: true, + }); + expect(steps[1].step).toBe('installPlugin'); + expect(steps[1].args).toEqual({ + source: 'akismet', + active: true, + }); + }); + + it('should transpile plugin objects with active: false', () => { + const steps = transpileDeclarativeToSteps({ + version: 2, + plugins: [{ source: 'jetpack', active: false }], + } as any); + expect(steps[0].args).toMatchObject({ active: false }); + }); + + it('should transpile activeTheme to installTheme + activateTheme', () => { + const steps = transpileDeclarativeToSteps({ + version: 2, + activeTheme: 'twentytwentyfour', + } as any); + expect(steps).toHaveLength(2); + expect(steps[0].step).toBe('installTheme'); + expect(steps[1].step).toBe('activateTheme'); + }); + + it('should maintain the spec-defined step order', () => { + const steps = transpileDeclarativeToSteps({ + version: 2, + plugins: ['jetpack'], + constants: { WP_DEBUG: true }, + siteOptions: { blogname: 'Test' }, + siteLanguage: 'de_DE', + activeTheme: 'twentytwentyfour', + additionalStepsAfterExecution: [{ step: 'runPHP', code: { filename: 's.php', content: ' s.step); + const constantsIdx = stepOrder.indexOf('defineConstants'); + const siteOptionsIdx = stepOrder.indexOf('setSiteOptions'); + const installThemeIdx = stepOrder.indexOf('installTheme'); + const activateThemeIdx = stepOrder.indexOf('activateTheme'); + const installPluginIdx = stepOrder.indexOf('installPlugin'); + const siteLanguageIdx = stepOrder.indexOf('setSiteLanguage'); + const runPhpIdx = stepOrder.indexOf('runPHP'); + + // Spec order: constants < siteOptions < themes < plugins + // < siteLanguage < additionalSteps + expect(constantsIdx).toBeLessThan(siteOptionsIdx); + expect(siteOptionsIdx).toBeLessThan(installThemeIdx); + expect(installThemeIdx).toBeLessThan(activateThemeIdx); + expect(activateThemeIdx).toBeLessThan(installPluginIdx); + expect(installPluginIdx).toBeLessThan(siteLanguageIdx); + expect(siteLanguageIdx).toBeLessThan(runPhpIdx); + }); + + it('should append additionalStepsAfterExecution at the end', () => { + const steps = transpileDeclarativeToSteps({ + version: 2, + plugins: ['jetpack'], + additionalStepsAfterExecution: [ + { step: 'runPHP', code: { filename: 's.php', content: ' }`. + +**Commit message:** `feat(blueprints): implement V2 defineConstants step handler` + +--- + +### Task 12: Implement setSiteOptions step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/set-site-options.ts` +- Create: `packages/playground/blueprints/src/lib/v2/steps/set-site-options.spec.ts` + +**Context:** Calls `update_option()` for each key-value pair via PHP. Special handling for `permalink_structure` (must flush rewrite rules). V2 supports JSON-serializable values (strings, numbers, booleans, arrays, objects). + +Reference V1: `packages/playground/blueprints/src/lib/steps/set-site-options.ts` + +**Commit message:** `feat(blueprints): implement V2 setSiteOptions step handler` + +--- + +### Task 13: Implement installPlugin step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/install-plugin.ts` +- Create: `packages/playground/blueprints/src/lib/v2/steps/install-plugin.spec.ts` + +**Context:** This is the most complex step. The `source` can be: + +- A WordPress.org slug (`"jetpack"`, `"jetpack@6.4.3"`) +- A URL (`"https://example.com/plugin.zip"`) +- An execution context path (`"./wp-content/plugins/my-plugin/"`) +- An inline file or directory + +Steps: + +1. Resolve the source via the data reference resolver +2. Detect format (ZIP, directory, single .php file) +3. Extract/copy to `wp-content/plugins/` +4. Optionally activate via `activate_plugin()` +5. Handle `activationOptions`, `onError`, `targetDirectoryName` + +Reference V1: `packages/playground/blueprints/src/lib/steps/install-plugin.ts` and `install-asset.ts` + +**Commit message:** `feat(blueprints): implement V2 installPlugin step handler` + +--- + +### Task 14: Implement activatePlugin step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/activate-plugin.ts` + +**Context:** Calls `activate_plugin()` in PHP. Receives `pluginPath` (path to plugin entry file relative to plugins directory). + +**Commit message:** `feat(blueprints): implement V2 activatePlugin step handler` + +--- + +### Task 15: Implement installTheme and activateTheme step handlers + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/install-theme.ts` +- Create: `packages/playground/blueprints/src/lib/v2/steps/activate-theme.ts` + +**Context:** Similar to installPlugin but targets `wp-content/themes/`. `activateTheme` calls `switch_theme()`. Theme sources follow the same resolution logic as plugins. + +**Commit message:** `feat(blueprints): implement V2 theme step handlers` + +--- + +### Task 16: Implement writeFiles step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/write-files.ts` + +**Context:** Receives `{ files: Record }`. For each entry, resolves the data reference and writes the result to the specified path on the VFS. + +**Commit message:** `feat(blueprints): implement V2 writeFiles step handler` + +--- + +### Task 17: Implement runPHP step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/run-php.ts` + +**Context:** Receives `{ code: DataReference, env?: Record }`. Resolves the code data reference to PHP source, writes it to a temp file, and executes via `playground.run()`. Set environment variables before execution if provided. + +**Commit message:** `feat(blueprints): implement V2 runPHP step handler` + +--- + +### Task 18: Implement runSQL step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/run-sql.ts` + +**Context:** Resolves the SQL source data reference, then executes the SQL statements. Can be done via a PHP script that reads and executes the SQL file using `$wpdb`. + +**Commit message:** `feat(blueprints): implement V2 runSQL step handler` + +--- + +### Task 19: Implement wp-cli step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/wp-cli.ts` + +**Context:** Runs a WP-CLI command string. Reference V1: `packages/playground/blueprints/src/lib/steps/wp-cli.ts`. Uses `playground.cli()` or runs PHP with WP-CLI included. + +**Commit message:** `feat(blueprints): implement V2 wp-cli step handler` + +--- + +### Task 20: Implement setSiteLanguage step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/set-site-language.ts` + +**Context:** Sets WPLANG constant and downloads translations. Reference V1: `packages/playground/blueprints/src/lib/steps/set-site-language.ts`. + +**Commit message:** `feat(blueprints): implement V2 setSiteLanguage step handler` + +--- + +### Task 21: Implement unzip step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/unzip.ts` + +**Context:** Resolves the zip file data reference, extracts to the specified path. Use the zip utilities already available in the codebase (check `@wp-playground/storage` or the V1 unzip step). + +**Commit message:** `feat(blueprints): implement V2 unzip step handler` + +--- + +### Task 22: Implement importContent step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/import-content.ts` + +**Context:** Handles three content types: `mysql-dump` (execute SQL), `posts` (insert via PHP), and `wxr` (WordPress XML import). For WXR, delegate to the WordPress importer plugin via PHP. For `posts`, generate PHP code that calls `wp_insert_post()` for each post object. + +This is a complex handler. For inline post objects (the `WordPressPost` type from the schema), generate PHP that serializes the post data and inserts it. For file-based sources, resolve the data reference and use the appropriate importer. + +**Commit message:** `feat(blueprints): implement V2 importContent step handler` + +--- + +### Task 23: Implement importMedia step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/import-media.ts` + +**Context:** Uploads media files to the WordPress Media Library. Each `MediaDefinition` can be a simple data reference or an object with `source`, `title`, `description`, `alt`, `caption`. Resolve the file, write it to a temp location, then use `wp_insert_attachment()` / `wp_handle_sideload()` via PHP. + +**Commit message:** `feat(blueprints): implement V2 importMedia step handler` + +--- + +### Task 24: Implement importThemeStarterContent step handler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/steps/import-theme-starter-content.ts` + +**Context:** Triggers theme starter content import via PHP. Reference V1: `packages/playground/blueprints/src/lib/steps/import-theme-starter-content.ts`. + +**Commit message:** `feat(blueprints): implement V2 importThemeStarterContent step handler` + +--- + +## Phase 5: V1→V2 Transpilation + +### Task 25: Implement V1 to V2 transpiler + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/compile/v1-to-v2-transpiler.ts` +- Create: `packages/playground/blueprints/src/lib/v2/compile/v1-to-v2-transpiler.spec.ts` + +**Context:** The spec requires V2 runners to accept V1 blueprints (any blueprint without a `version` property). The transpiler converts V1 schema to V2 schema following the mapping tables in the spec's "Backwards compatibility with Blueprints v1" section. + +Key mappings: + +- `preferredVersions.php/wp` → `phpVersion`/`wordpressVersion` +- `landingPage`, `login`, `features.networking` → `applicationOptions['wordpress-playground']` +- `meta.*` → `blueprintMeta.*` +- `steps` → `additionalStepsAfterExecution` (with per-step rewrites) +- `plugins` (shorthand) → `additionalStepsAfterExecution[].installPlugin` +- Resource objects → V2 data references + +**Step 1: Write comprehensive tests** + +Test every row in the spec's mapping tables: + +- Top-level property mapping (each property individually) +- Step mapping (each V1 step type → V2 equivalent) +- Resource → DataReference conversion (each resource type) +- Path translation (`/wordpress/` → docroot-relative) +- Edge cases: empty blueprint, blueprint with only steps, deprecated steps + +**Step 2: Implement the transpiler** + +A function `transpileV1toV2(v1: object): BlueprintV2Declaration` that: + +1. Sets `version: 2` +2. Maps top-level properties +3. Rewrites each step in `steps[]` +4. Converts resource objects to data references +5. Translates `/wordpress/` paths + +**Step 3: Wire into compile.ts** + +In `compileBlueprintV2()`, before validation, check if the blueprint lacks a `version` property. If so, validate it as V1 (using the existing V1 schema validator), then transpile to V2. + +**Commit message:** `feat(blueprints): implement V1 to V2 blueprint transpiler` + +--- + +## Phase 6: Blueprint Composition + +### Task 26: Implement blueprint merge algorithm + +**Files:** + +- Create: `packages/playground/blueprints/src/lib/v2/compile/merge.ts` +- Create: `packages/playground/blueprints/src/lib/v2/compile/merge.spec.ts` + +**Context:** The spec's composition section defines a property-by-property merge algorithm. Implement `mergeBlueprintsV2(blueprints: BlueprintV2Declaration[]): BlueprintV2Declaration` following the spec rules: + +- `version`: assert same +- `blueprintMeta`, `$schema`: ignore +- `siteLanguage`, `activeTheme`: conflict if both differ +- `constants`, `siteOptions`, `postTypes`, `fonts`: append, fail on key conflicts +- `phpVersion`, `wordpressVersion`: intersect version ranges +- `plugins`, `themes`, `muPlugins`: merge by slug +- `additionalStepsAfterExecution`, `content`, `media`: append +- `users`, `roles`: merge with conflict detection + +**Step 1: Write tests for each merge rule** + +**Step 2: Implement the merge function** + +**Step 3: Export from the V2 module** + +**Commit message:** `feat(blueprints): implement V2 blueprint merge algorithm` + +--- + +## Phase 7: Integration + +### Task 27: Update CLI integration + +**Files:** + +- Modify: `packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts` + +**Context:** Replace the call to the old `runBlueprintV2()` (which uses the PHP .phar) with the new `compileBlueprintV2()` + `compiled.run()`. The key call site is around line 346 in the `runBlueprintV2()` method of the worker thread class. + +The new flow: + +1. Parse the blueprint declaration (keep existing parsing logic) +2. Call `compileBlueprintV2(blueprint, { progress, ... })` +3. Extract runtime config for PHP/WP version selection +4. Call `compiled.run(php)` +5. Dispatch progress events from the progress tracker (replace the PHP message-based progress with direct TS progress tracking) + +Note: The `onMessage` callback pattern for `blueprint.target_resolved`, `blueprint.progress`, `blueprint.error` should be replaced with direct progress tracker callbacks and try/catch error handling. + +Also check `blueprints-v2-handler.ts` in the CLI to see if any changes are needed there. + +**Step 1: Update imports** + +**Step 2: Replace the runBlueprintV2 call with compile + run** + +**Step 3: Test manually with the CLI** + +```bash +cd .worktrees/blueprints-v2-ts-runner +npx nx dev playground-cli server --experimental-blueprints-v2-runner --blueprint='{"version":2,"plugins":["hello-dolly"]}' +``` + +**Commit message:** `feat(blueprints): integrate V2 TS runner into CLI` + +--- + +### Task 28: Update website remote worker integration + +**Files:** + +- Modify: `packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v2.ts` + +**Context:** Same pattern as CLI — replace the `runBlueprintV2()` call with `compileBlueprintV2()` + `compiled.run()`. The remote worker dispatches `blueprint.message` events for the client to consume. + +Update the `boot()` method to: + +1. Compile the blueprint +2. Set up progress tracking that dispatches `blueprint.message` events +3. Run the compiled blueprint +4. Dispatch completion event + +**Step 1: Update imports and replace the execution call** + +**Step 2: Wire progress tracking to event dispatch** + +**Step 3: Test via `npm run dev` and loading a V2 blueprint in the browser** + +```bash +cd .worktrees/blueprints-v2-ts-runner +npm run dev +# Then open: http://localhost:5400/?experimental-blueprints-v2-runner&blueprint={"version":2,"plugins":["hello-dolly"]} +``` + +**Commit message:** `feat(blueprints): integrate V2 TS runner into website remote worker` + +--- + +### Task 29: Verify client integration (no changes expected) + +**Files:** + +- Read: `packages/playground/client/src/blueprints-v2-handler.ts` + +**Context:** The client handler listens for `blueprint.message` events from the remote worker. Since we're keeping the same event format in Task 28, the client should work without changes. Verify this by reading the code and testing. + +**Step 1: Read the client handler and verify no changes needed** + +**Step 2: End-to-end test via the dev server** + +**Commit message:** (no commit if no changes needed) + +--- + +## Phase 8: Cleanup + +### Task 30: Remove PHP runner files + +**Files:** + +- Delete: `packages/playground/blueprints/src/lib/v2/get-v2-runner.ts` +- Delete: `packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts` +- Modify: `packages/playground/blueprints/src/index.ts` (remove legacy exports) + +**Context:** Now that the TS runner is integrated, remove the PHP .phar wrapper files and their exports. Also remove any `.phar` binary imports from the build pipeline. + +**Step 1: Delete the PHP wrapper files** + +**Step 2: Remove legacy exports from index.ts** + +Remove these lines: + +```typescript +export { getV2Runner } from './lib/v2/get-v2-runner'; +export { runBlueprintV2 } from './lib/v2/run-blueprint-v2'; +export type { BlueprintMessage } from './lib/v2/run-blueprint-v2'; +``` + +**Step 3: Search for any other references to the deleted files** + +```bash +grep -r "get-v2-runner\|run-blueprint-v2\|getV2Runner\|blueprints\.phar" packages/ --include="*.ts" --include="*.tsx" -l +``` + +Update or remove any remaining references. + +**Step 4: Verify the package builds** + +```bash +npx nx build playground-blueprints +``` + +**Step 5: Run all tests** + +```bash +npx nx test playground-blueprints +``` + +**Step 6: Commit** + +```bash +git add -A +git commit -m "chore(blueprints): remove PHP .phar runner files" +``` + +--- + +### Task 31: Update blueprint-v2-declaration.ts + +**Files:** + +- Modify: `packages/playground/blueprints/src/lib/v2/blueprint-v2-declaration.ts` + +**Context:** This file has parsing utilities and type exports. Update it to use the new V2 types from `types.ts` and ensure the `parseBlueprintDeclaration()` function works with the new compilation pipeline. + +**Commit message:** `refactor(blueprints): update V2 declaration parsing for TS runner` + +--- + +### Task 32: Run full test suite and fix issues + +**Files:** Various + +**Context:** Run the complete test suite for the blueprints package and any dependent packages. Fix any failures. + +```bash +npx nx test playground-blueprints +npx nx test playground-cli +npx nx e2e playground-website # if feasible +``` + +**Commit message:** `fix(blueprints): resolve test failures from V2 TS runner integration` + +--- + +## Task Dependency Graph + +``` +Phase 1 (Foundation): + Task 1 (types) + Task 2 (step registry) ← depends on Task 1 + Task 3 (compile skeleton) ← depends on Task 1, 2 + Task 4 (exports) ← depends on Task 1, 2, 3 + +Phase 2 (Data References): + Task 5 (resolver) ← depends on Task 1 + +Phase 3 (Compilation): + Task 6 (validation) ← depends on Task 3 + Task 7 (runtime config) ← depends on Task 3 + Task 8 (transpilation) ← depends on Task 3 + Task 9 (execution loop) ← depends on Task 2, 3, 5 + +Phase 4 (Step Handlers) — all depend on Task 2, 5: + Tasks 10-24 can be worked in parallel + +Phase 5 (V1→V2 Transpiler): + Task 25 ← depends on Task 3, 8 + +Phase 6 (Composition): + Task 26 ← depends on Task 6 + +Phase 7 (Integration): + Tasks 27-29 ← depend on Phase 4 completion + +Phase 8 (Cleanup): + Tasks 30-32 ← depend on Phase 7 completion +``` + +**Parallelizable groups:** + +- Tasks 10-24 (step handlers) — all independent +- Tasks 6, 7, 8 (compilation sub-tasks) — independent of each other +- Tasks 27, 28 (CLI + website integration) — independent of each other diff --git a/packages/playground/blueprints/src/index.ts b/packages/playground/blueprints/src/index.ts index d55a6955f46..91d6a647948 100644 --- a/packages/playground/blueprints/src/index.ts +++ b/packages/playground/blueprints/src/index.ts @@ -60,15 +60,28 @@ export { } from './lib/v1/resources'; export * from './lib/steps'; export * from './lib/steps/handlers'; +// V2 Blueprint types (keep existing type exports for now) export type { BlueprintV2, BlueprintV2Declaration, RawBlueprintV2Data, ParsedBlueprintV1orV2String as ParsedBlueprintV2String, } from './lib/v2/blueprint-v2-declaration'; -export { getV2Runner } from './lib/v2/get-v2-runner'; -export { runBlueprintV2 } from './lib/v2/run-blueprint-v2'; -export type { BlueprintMessage } from './lib/v2/run-blueprint-v2'; + +// V2 TypeScript runner (new) +export { + compileBlueprintV2, + InvalidBlueprintV2Error, + BlueprintV2StepExecutionError, + DataReferenceResolutionError, + BlueprintMergeConflictError, +} from './lib/v2/index'; + +export type { + CompiledBlueprintV2, + CompileBlueprintV2Options, + V2RuntimeConfig, +} from './lib/v2/index'; export { resolveRemoteBlueprint, diff --git a/packages/playground/blueprints/src/lib/v2/compile/compile.spec.ts b/packages/playground/blueprints/src/lib/v2/compile/compile.spec.ts new file mode 100644 index 00000000000..fa63c5adcaa --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/compile/compile.spec.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { extractRuntimeConfig } from './compile'; +import type { BlueprintV2Declaration } from '../types'; + +describe('extractRuntimeConfig', () => { + it('should extract a simple PHP version string', () => { + const blueprint: BlueprintV2Declaration = { + version: 2, + phpVersion: '8.1', + }; + const config = extractRuntimeConfig(blueprint); + expect(config.phpVersion).toEqual({ + preferred: '8.1', + }); + }); + + it('should extract a PHP version constraint object', () => { + const blueprint = { + version: 2, + phpVersion: { + min: '8.0', + recommended: '8.2', + max: '8.4', + }, + } as BlueprintV2Declaration; + const config = extractRuntimeConfig(blueprint); + expect(config.phpVersion).toEqual({ + min: '8.0', + max: '8.4', + preferred: '8.2', + }); + }); + + it('should extract a WordPress version string', () => { + const blueprint: BlueprintV2Declaration = { + version: 2, + wordpressVersion: '6.4', + }; + const config = extractRuntimeConfig(blueprint); + expect(config.wordpressVersion).toEqual({ + preferred: '6.4', + }); + }); + + it('should extract application options', () => { + const blueprint: BlueprintV2Declaration = { + version: 2, + applicationOptions: { + 'wordpress-playground': { + landingPage: '/wp-admin/plugins.php', + login: true, + networkAccess: false, + }, + }, + }; + const config = extractRuntimeConfig(blueprint); + expect(config.applicationOptions).toEqual({ + 'wordpress-playground': { + landingPage: '/wp-admin/plugins.php', + login: true, + networkAccess: false, + }, + }); + }); + + it('should return an empty config for a minimal blueprint', () => { + const blueprint: BlueprintV2Declaration = { + version: 2, + }; + const config = extractRuntimeConfig(blueprint); + expect(config).toEqual({}); + }); + + it('should handle "latest" as a version string', () => { + const blueprint: BlueprintV2Declaration = { + version: 2, + phpVersion: 'latest', + wordpressVersion: 'latest', + }; + const config = extractRuntimeConfig(blueprint); + expect(config.phpVersion).toEqual({ + preferred: 'latest', + }); + expect(config.wordpressVersion).toEqual({ + preferred: 'latest', + }); + }); + + it('should extract a WordPress version constraint object with preferred', () => { + const blueprint = { + version: 2, + wordpressVersion: { + min: '6.2', + preferred: '6.4', + }, + } as BlueprintV2Declaration; + const config = extractRuntimeConfig(blueprint); + expect(config.wordpressVersion).toEqual({ + min: '6.2', + preferred: '6.4', + }); + }); +}); diff --git a/packages/playground/blueprints/src/lib/v2/compile/compile.ts b/packages/playground/blueprints/src/lib/v2/compile/compile.ts new file mode 100644 index 00000000000..147e77a1f8b --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/compile/compile.ts @@ -0,0 +1,201 @@ +import { ProgressTracker } from '@php-wasm/progress'; +import type { UniversalPHP } from '@php-wasm/universal'; +import type { + BlueprintV2Declaration, + CompiledBlueprintV2, + CompiledV2Step, + CompileBlueprintV2Options, + V2RuntimeConfig, + V2VersionConstraint, + StepExecutionContext, +} from '../types'; +import { + BlueprintV2StepExecutionError, + InvalidBlueprintV2Error, +} from '../types'; +import type { DataReferenceResolverConfig } from '../data-references/types'; +import { DataReferenceResolverImpl } from '../data-references/resolver'; +import { v2StepHandlers } from '../steps/index'; +import { validateBlueprintV2 } from './validate'; +import { transpileDeclarativeToSteps } from './transpile-declarative'; +import { transpileV1toV2 } from './v1-to-v2-transpiler'; + +/** + * Compiles a V2 blueprint declaration into an executable form. + * + * This is the main entry point for V2 blueprint processing. + * It validates the blueprint, extracts runtime configuration, + * transpiles declarative properties into ordered steps, and + * returns an object whose run() method executes the blueprint. + */ +export async function compileBlueprintV2( + blueprint: BlueprintV2Declaration, + options: CompileBlueprintV2Options = {} +): Promise { + // Detect V1 blueprints (no `version` property) and + // transpile to V2 before proceeding. + let effectiveBlueprint = blueprint; + if (!hasVersionProperty(blueprint)) { + effectiveBlueprint = transpileV1toV2( + blueprint as unknown as Record + ); + } + + const validation = validateBlueprintV2(effectiveBlueprint); + if (!validation.valid) { + throw new InvalidBlueprintV2Error( + 'Blueprint validation failed: ' + validation.errors.join('; '), + validation.errors + ); + } + + const runtimeConfig = extractRuntimeConfig(effectiveBlueprint); + const steps = transpileDeclarativeToSteps(effectiveBlueprint); + + return { + runtimeConfig, + steps, + run: async (playground: UniversalPHP) => { + await executeSteps(playground, steps, options); + }, + }; +} + +/** + * Extracts runtime configuration from a V2 blueprint declaration. + * + * Converts phpVersion / wordpressVersion strings into + * `V2VersionConstraint` objects and passes applicationOptions + * through as-is. + */ +export function extractRuntimeConfig( + blueprint: BlueprintV2Declaration +): V2RuntimeConfig { + const config: V2RuntimeConfig = {}; + + if (blueprint.phpVersion !== undefined) { + config.phpVersion = toVersionConstraint(blueprint.phpVersion); + } + + if (blueprint.wordpressVersion !== undefined) { + config.wordpressVersion = toVersionConstraint( + blueprint.wordpressVersion + ); + } + + if (blueprint.applicationOptions !== undefined) { + config.applicationOptions = blueprint.applicationOptions; + } + + return config; +} + +/** + * Normalizes a version field into a `V2VersionConstraint`. + * + * - Plain strings (e.g. `"8.1"`, `"latest"`) become + * `{ preferred: "" }`. + * - Objects that look like version constraints are mapped + * field-by-field, using `preferred` or `recommended` as + * the preferred key. + * - Other values (e.g. DataReferences like URLs) are not + * representable as a version constraint and are ignored. + */ +function toVersionConstraint(value: unknown): V2VersionConstraint | undefined { + if (typeof value === 'string') { + return { preferred: value }; + } + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + const obj = value as Record; + const constraint: V2VersionConstraint = {}; + if (typeof obj.min === 'string') { + constraint.min = obj.min; + } + if (typeof obj.max === 'string') { + constraint.max = obj.max; + } + // The PHP schema uses "recommended"; WordPress uses + // "preferred". Accept both. + if (typeof obj.preferred === 'string') { + constraint.preferred = obj.preferred; + } else if (typeof obj.recommended === 'string') { + constraint.preferred = obj.recommended; + } + return constraint; + } + + // DataReference values (URLs, paths, etc.) cannot be + // represented as a simple version constraint — return + // undefined so the caller knows no constraint was set. + return undefined; +} + +/** + * Returns true if the blueprint has a `version` property, + * indicating it's a V2 blueprint (or at least declares a + * version). Blueprints without this property are V1. + */ +function hasVersionProperty(blueprint: unknown): boolean { + return ( + typeof blueprint === 'object' && + blueprint !== null && + 'version' in blueprint + ); +} + +/** + * Executes an ordered list of compiled steps against a PHP + * runtime. Each step is dispatched to its registered handler. + * Progress reporting, data reference resolution, and error + * wrapping are handled automatically. + */ +async function executeSteps( + playground: UniversalPHP, + steps: CompiledV2Step[], + options: CompileBlueprintV2Options +): Promise { + const resolverConfig: DataReferenceResolverConfig = { + semaphore: options.semaphore, + corsProxy: options.corsProxy, + executionContext: options.executionContext, + }; + const resolver = new DataReferenceResolverImpl(resolverConfig); + + const context: StepExecutionContext = { + php: playground, + progress: options.progress ?? new ProgressTracker(), + dataReferenceResolver: resolver, + }; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const handler = v2StepHandlers[step.step]; + + if (!handler) { + throw new BlueprintV2StepExecutionError( + step.step, + `Unknown step handler: "${step.step}"` + ); + } + + try { + if (step.progressHints?.caption) { + context.progress.setCaption(step.progressHints.caption); + } + await handler(step.args, context); + options.onStepCompleted?.(step.step, i); + } catch (error) { + if (error instanceof BlueprintV2StepExecutionError) { + throw error; + } + throw new BlueprintV2StepExecutionError( + step.step, + `Step "${step.step}" (index ${i}) failed: ${ + error instanceof Error ? error.message : String(error) + }`, + error + ); + } + } +} diff --git a/packages/playground/blueprints/src/lib/v2/compile/merge.spec.ts b/packages/playground/blueprints/src/lib/v2/compile/merge.spec.ts new file mode 100644 index 00000000000..bd15489b5ba --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/compile/merge.spec.ts @@ -0,0 +1,373 @@ +import { describe, it, expect } from 'vitest'; +import { mergeBlueprintsV2 } from './merge'; +import type { BlueprintV2Declaration } from '../types'; +import { BlueprintMergeConflictError } from '../types'; + +function bp(overrides: Record = {}): BlueprintV2Declaration { + return { version: 2, ...overrides } as BlueprintV2Declaration; +} + +describe('mergeBlueprintsV2', () => { + // ============================================================ + // Edge cases + // ============================================================ + + it('returns empty V2 blueprint for empty input', () => { + const result = mergeBlueprintsV2([]); + expect(result.version).toBe(2); + }); + + it('returns a copy of single blueprint', () => { + const single = bp({ siteLanguage: 'en_US' }); + const result = mergeBlueprintsV2([single]); + expect(result).toEqual(single); + expect(result).not.toBe(single); + }); + + // ============================================================ + // version + // ============================================================ + + it('rejects blueprints with different versions', () => { + expect(() => + mergeBlueprintsV2([ + bp(), + { version: 3 } as unknown as BlueprintV2Declaration, + ]) + ).toThrow(BlueprintMergeConflictError); + }); + + // ============================================================ + // siteLanguage / activeTheme (scalar exclusive) + // ============================================================ + + it('merges siteLanguage when only one defines it', () => { + const result = mergeBlueprintsV2([bp(), bp({ siteLanguage: 'fr_FR' })]); + expect((result as any).siteLanguage).toBe('fr_FR'); + }); + + it('allows same siteLanguage from both', () => { + const result = mergeBlueprintsV2([ + bp({ siteLanguage: 'de_DE' }), + bp({ siteLanguage: 'de_DE' }), + ]); + expect((result as any).siteLanguage).toBe('de_DE'); + }); + + it('rejects conflicting siteLanguage', () => { + expect(() => + mergeBlueprintsV2([ + bp({ siteLanguage: 'en_US' }), + bp({ siteLanguage: 'fr_FR' }), + ]) + ).toThrow(BlueprintMergeConflictError); + }); + + it('rejects conflicting activeTheme', () => { + expect(() => + mergeBlueprintsV2([ + bp({ activeTheme: 'astra' }), + bp({ activeTheme: 'storefront' }), + ]) + ).toThrow(BlueprintMergeConflictError); + }); + + // ============================================================ + // constants / siteOptions / postTypes / fonts (key-value maps) + // ============================================================ + + it('merges non-overlapping constants', () => { + const result = mergeBlueprintsV2([ + bp({ constants: { WP_DEBUG: true } }), + bp({ constants: { DISALLOW_FILE_EDIT: true } }), + ]); + expect((result as any).constants).toEqual({ + WP_DEBUG: true, + DISALLOW_FILE_EDIT: true, + }); + }); + + it('allows identical constant values', () => { + const result = mergeBlueprintsV2([ + bp({ constants: { WP_DEBUG: true } }), + bp({ constants: { WP_DEBUG: true } }), + ]); + expect((result as any).constants).toEqual({ WP_DEBUG: true }); + }); + + it('rejects conflicting constant values', () => { + expect(() => + mergeBlueprintsV2([ + bp({ constants: { WP_DEBUG: true } }), + bp({ constants: { WP_DEBUG: false } }), + ]) + ).toThrow(BlueprintMergeConflictError); + }); + + it('merges non-overlapping siteOptions', () => { + const result = mergeBlueprintsV2([ + bp({ siteOptions: { blogname: 'A' } }), + bp({ siteOptions: { blogdescription: 'B' } }), + ]); + expect((result as any).siteOptions).toEqual({ + blogname: 'A', + blogdescription: 'B', + }); + }); + + it('rejects conflicting siteOptions', () => { + expect(() => + mergeBlueprintsV2([ + bp({ siteOptions: { blogname: 'A' } }), + bp({ siteOptions: { blogname: 'B' } }), + ]) + ).toThrow(BlueprintMergeConflictError); + }); + + // ============================================================ + // phpVersion / wordpressVersion (version constraint intersection) + // ============================================================ + + it('merges string versions (last preferred wins)', () => { + const result = mergeBlueprintsV2([ + bp({ phpVersion: '8.1' }), + bp({ phpVersion: '8.2' }), + ]); + expect((result as any).phpVersion).toEqual({ + preferred: '8.2', + }); + }); + + it('intersects version ranges', () => { + const result = mergeBlueprintsV2([ + bp({ phpVersion: { min: '8.0', max: '8.4' } }), + bp({ phpVersion: { min: '8.1', max: '8.3' } }), + ]); + expect((result as any).phpVersion).toEqual({ + min: '8.1', + max: '8.3', + }); + }); + + it('rejects empty intersection', () => { + expect(() => + mergeBlueprintsV2([ + bp({ phpVersion: { min: '8.0', max: '8.1' } }), + bp({ phpVersion: { min: '8.3', max: '8.4' } }), + ]) + ).toThrow(BlueprintMergeConflictError); + }); + + it('merges version with only one defining it', () => { + const result = mergeBlueprintsV2([ + bp(), + bp({ wordpressVersion: '6.4' }), + ]); + expect((result as any).wordpressVersion).toBe('6.4'); + }); + + // ============================================================ + // plugins / themes / muPlugins (merge by slug) + // ============================================================ + + it('merges non-overlapping plugin lists', () => { + const result = mergeBlueprintsV2([ + bp({ plugins: ['jetpack'] }), + bp({ plugins: ['woocommerce'] }), + ]); + expect((result as any).plugins).toEqual(['jetpack', 'woocommerce']); + }); + + it('deduplicates identical plugin entries', () => { + const result = mergeBlueprintsV2([ + bp({ plugins: ['jetpack'] }), + bp({ plugins: ['jetpack'] }), + ]); + expect((result as any).plugins).toEqual(['jetpack']); + }); + + it('rejects conflicting plugin definitions', () => { + expect(() => + mergeBlueprintsV2([ + bp({ + plugins: [{ source: 'jetpack', active: true }], + }), + bp({ + plugins: [{ source: 'jetpack', active: false }], + }), + ]) + ).toThrow(BlueprintMergeConflictError); + }); + + it('merges theme lists', () => { + const result = mergeBlueprintsV2([ + bp({ themes: ['astra'] }), + bp({ themes: ['storefront'] }), + ]); + expect((result as any).themes).toEqual(['astra', 'storefront']); + }); + + // ============================================================ + // additionalStepsAfterExecution / content / media (append) + // ============================================================ + + it('appends additionalStepsAfterExecution', () => { + const result = mergeBlueprintsV2([ + bp({ + additionalStepsAfterExecution: [{ step: 'login' }], + }), + bp({ + additionalStepsAfterExecution: [ + { step: 'runPHP', code: '' }, + ], + }), + ]); + expect((result as any).additionalStepsAfterExecution).toHaveLength(2); + }); + + it('appends content entries', () => { + const result = mergeBlueprintsV2([ + bp({ content: [{ type: 'wxr', source: 'a.xml' }] }), + bp({ content: [{ type: 'wxr', source: 'b.xml' }] }), + ]); + expect((result as any).content).toHaveLength(2); + }); + + it('appends media entries', () => { + const result = mergeBlueprintsV2([ + bp({ media: [{ source: 'a.jpg' }] }), + bp({ media: [{ source: 'b.jpg' }] }), + ]); + expect((result as any).media).toHaveLength(2); + }); + + // ============================================================ + // users (merge by username) + // ============================================================ + + it('merges non-overlapping users', () => { + const result = mergeBlueprintsV2([ + bp({ users: [{ username: 'alice', role: 'editor' }] }), + bp({ users: [{ username: 'bob', role: 'author' }] }), + ]); + expect((result as any).users).toHaveLength(2); + }); + + it('deduplicates identical users', () => { + const result = mergeBlueprintsV2([ + bp({ users: [{ username: 'alice', role: 'editor' }] }), + bp({ users: [{ username: 'alice', role: 'editor' }] }), + ]); + expect((result as any).users).toHaveLength(1); + }); + + it('rejects users with conflicting roles', () => { + expect(() => + mergeBlueprintsV2([ + bp({ users: [{ username: 'alice', role: 'editor' }] }), + bp({ + users: [{ username: 'alice', role: 'administrator' }], + }), + ]) + ).toThrow(BlueprintMergeConflictError); + }); + + // ============================================================ + // roles (merge by name) + // ============================================================ + + it('merges non-overlapping roles', () => { + const result = mergeBlueprintsV2([ + bp({ + roles: [ + { + name: 'shop_manager', + capabilities: { manage_shop: true }, + }, + ], + }), + bp({ + roles: [ + { name: 'teacher', capabilities: { manage_courses: true } }, + ], + }), + ]); + expect((result as any).roles).toHaveLength(2); + }); + + it('rejects roles with conflicting capabilities', () => { + expect(() => + mergeBlueprintsV2([ + bp({ + roles: [ + { + name: 'shop_manager', + capabilities: { manage_shop: true }, + }, + ], + }), + bp({ + roles: [ + { + name: 'shop_manager', + capabilities: { manage_shop: false }, + }, + ], + }), + ]) + ).toThrow(BlueprintMergeConflictError); + }); + + // ============================================================ + // applicationOptions (shallow merge) + // ============================================================ + + it('merges applicationOptions from multiple blueprints', () => { + const result = mergeBlueprintsV2([ + bp({ + applicationOptions: { + 'wordpress-playground': { landingPage: '/wp-admin/' }, + }, + }), + bp({ + applicationOptions: { + 'wordpress-playground': { login: true }, + }, + }), + ]); + expect( + (result as any).applicationOptions['wordpress-playground'] + ).toEqual({ + landingPage: '/wp-admin/', + login: true, + }); + }); + + // ============================================================ + // Integration + // ============================================================ + + it('merges three blueprints', () => { + const result = mergeBlueprintsV2([ + bp({ + plugins: ['jetpack'], + constants: { WP_DEBUG: true }, + }), + bp({ + plugins: ['woocommerce'], + siteLanguage: 'en_US', + }), + bp({ + themes: ['storefront'], + siteOptions: { blogname: 'Store' }, + }), + ]); + expect((result as any).plugins).toEqual(['jetpack', 'woocommerce']); + expect((result as any).themes).toEqual(['storefront']); + expect((result as any).constants).toEqual({ WP_DEBUG: true }); + expect((result as any).siteOptions).toEqual({ + blogname: 'Store', + }); + expect((result as any).siteLanguage).toBe('en_US'); + }); +}); diff --git a/packages/playground/blueprints/src/lib/v2/compile/merge.ts b/packages/playground/blueprints/src/lib/v2/compile/merge.ts new file mode 100644 index 00000000000..3702f93ae12 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/compile/merge.ts @@ -0,0 +1,460 @@ +import type { BlueprintV2Declaration } from '../types'; +import { BlueprintMergeConflictError } from '../types'; + +/** + * Merges multiple V2 blueprint declarations into a single + * declaration following the spec's composition rules. + * + * Merge strategy by property: + * - `version`: assert all same + * - `blueprintMeta`, `$schema`: ignored (dropped) + * - `siteLanguage`, `activeTheme`: conflict if both define + * different values + * - `constants`, `siteOptions`, `postTypes`, `fonts`: append + * key-value pairs, fail on key conflicts + * - `phpVersion`, `wordpressVersion`: intersect ranges + * - `plugins`, `themes`, `muPlugins`: merge by slug + * - `additionalStepsAfterExecution`, `content`, `media`: + * append arrays + * - `users`: merge by username, fail on role conflicts + * - `roles`: merge by name, fail on capability conflicts + */ +export function mergeBlueprintsV2( + blueprints: BlueprintV2Declaration[] +): BlueprintV2Declaration { + if (blueprints.length === 0) { + return { version: 2 } as BlueprintV2Declaration; + } + if (blueprints.length === 1) { + return { ...blueprints[0] }; + } + + const result: Record = { version: 2 }; + + for (const bp of blueprints) { + assertVersion(bp); + mergeScalarExclusive(result, bp, 'siteLanguage'); + mergeScalarExclusive(result, bp, 'activeTheme'); + mergeKeyValueMap(result, bp, 'constants'); + mergeKeyValueMap(result, bp, 'siteOptions'); + mergeKeyValueMap(result, bp, 'postTypes'); + mergeKeyValueMap(result, bp, 'fonts'); + mergeVersionConstraint(result, bp, 'phpVersion'); + mergeVersionConstraint(result, bp, 'wordpressVersion'); + mergeBySlug(result, bp, 'plugins'); + mergeBySlug(result, bp, 'themes'); + mergeBySlug(result, bp, 'muPlugins'); + mergeAppendArray(result, bp, 'additionalStepsAfterExecution'); + mergeAppendArray(result, bp, 'content'); + mergeAppendArray(result, bp, 'media'); + mergeUsers(result, bp); + mergeRoles(result, bp); + mergeApplicationOptions(result, bp); + } + + return result as BlueprintV2Declaration; +} + +// ------------------------------------------------------------------ +// Merge helpers +// ------------------------------------------------------------------ + +/** + * Asserts all blueprints have version 2. + */ +function assertVersion(bp: BlueprintV2Declaration): void { + const bpObj = bp as Record; + if (bpObj.version !== undefined && bpObj.version !== 2) { + throw new BlueprintMergeConflictError( + 'version', + `Cannot merge blueprints with different versions: expected 2, got ${bpObj.version}` + ); + } +} + +/** + * Merges a scalar property that must be exclusive — if both + * blueprints define it with different values, it's a conflict. + */ +function mergeScalarExclusive( + result: Record, + bp: BlueprintV2Declaration, + key: string +): void { + const bpObj = bp as Record; + const value = bpObj[key]; + if (value === undefined) { + return; + } + if (result[key] !== undefined && !deepEqual(result[key], value)) { + throw new BlueprintMergeConflictError( + key, + `Conflicting values for "${key}": ` + + `${JSON.stringify(result[key])} vs ${JSON.stringify(value)}` + ); + } + result[key] = value; +} + +/** + * Merges key-value map properties (Record). + * Appends new keys, fails on key conflicts with different + * values. + */ +function mergeKeyValueMap( + result: Record, + bp: BlueprintV2Declaration, + key: string +): void { + const bpObj = bp as Record; + const incoming = bpObj[key] as Record | undefined; + if (!incoming) { + return; + } + + const existing = (result[key] ?? {}) as Record; + + for (const [k, v] of Object.entries(incoming)) { + if (k in existing && !deepEqual(existing[k], v)) { + throw new BlueprintMergeConflictError( + `${key}.${k}`, + `Conflicting values for "${key}.${k}": ` + + `${JSON.stringify(existing[k])} vs ${JSON.stringify(v)}` + ); + } + existing[k] = v; + } + + result[key] = existing; +} + +/** + * Merges version constraints by intersecting ranges. + * If both specify `preferred`, the second wins unless they + * conflict with the resulting range. + */ +function mergeVersionConstraint( + result: Record, + bp: BlueprintV2Declaration, + key: string +): void { + const bpObj = bp as Record; + const incoming = bpObj[key]; + if (incoming === undefined) { + return; + } + + if (result[key] === undefined) { + result[key] = incoming; + return; + } + + // Normalize to constraint objects + const a = normalizeVersionConstraint(result[key]); + const b = normalizeVersionConstraint(incoming); + + const merged: Record = {}; + + // Take the higher min + if (a.min && b.min) { + merged.min = a.min > b.min ? a.min : b.min; + } else if (a.min || b.min) { + merged.min = (a.min ?? b.min)!; + } + + // Take the lower max + if (a.max && b.max) { + merged.max = a.max < b.max ? a.max : b.max; + } else if (a.max || b.max) { + merged.max = (a.max ?? b.max)!; + } + + // Check for empty intersection + if (merged.min && merged.max && merged.min > merged.max) { + throw new BlueprintMergeConflictError( + key, + `Incompatible version constraints for "${key}": ` + + `min ${merged.min} > max ${merged.max}` + ); + } + + // Preferred: last one wins + if (b.preferred) { + merged.preferred = b.preferred; + } else if (a.preferred) { + merged.preferred = a.preferred; + } + + result[key] = merged; +} + +/** + * Normalizes a version constraint — strings become + * `{ preferred: value }`, objects are used as-is. + */ +function normalizeVersionConstraint(value: unknown): Record { + if (typeof value === 'string') { + return { preferred: value }; + } + if (typeof value === 'object' && value !== null) { + return { ...(value as Record) }; + } + return {}; +} + +/** + * Merges array properties by slug. For plugins/themes, each + * entry is either a string (slug) or an object with a source. + * Entries with the same slug must be identical. + */ +function mergeBySlug( + result: Record, + bp: BlueprintV2Declaration, + key: string +): void { + const bpObj = bp as Record; + const incoming = bpObj[key] as unknown[] | undefined; + if (!incoming || incoming.length === 0) { + return; + } + + const existing = ((result[key] ?? []) as unknown[]).slice(); + const slugIndex = buildSlugIndex(existing); + + for (const entry of incoming) { + const slug = extractSlug(entry); + if (slug && slug in slugIndex) { + // Duplicate slug — verify they're identical + if (!deepEqual(existing[slugIndex[slug]], entry)) { + throw new BlueprintMergeConflictError( + `${key}[${slug}]`, + `Conflicting definitions for "${key}" entry "${slug}"` + ); + } + // Already present, skip + } else { + if (slug) { + slugIndex[slug] = existing.length; + } + existing.push(entry); + } + } + + result[key] = existing; +} + +/** + * Builds a slug → index mapping for an array of entries. + */ +function buildSlugIndex(arr: unknown[]): Record { + const index: Record = {}; + for (let i = 0; i < arr.length; i++) { + const slug = extractSlug(arr[i]); + if (slug) { + index[slug] = i; + } + } + return index; +} + +/** + * Extracts a slug from a plugin/theme/mu-plugin entry. + * Strings are slugs directly; objects may have a `source` + * or `slug` field. + */ +function extractSlug(entry: unknown): string | null { + if (typeof entry === 'string') { + return entry; + } + if (typeof entry === 'object' && entry !== null) { + const obj = entry as Record; + if (typeof obj.slug === 'string') { + return obj.slug; + } + if (typeof obj.source === 'string') { + return obj.source; + } + } + return null; +} + +/** + * Appends arrays from incoming blueprint to result. + */ +function mergeAppendArray( + result: Record, + bp: BlueprintV2Declaration, + key: string +): void { + const bpObj = bp as Record; + const incoming = bpObj[key] as unknown[] | undefined; + if (!incoming || incoming.length === 0) { + return; + } + + const existing = ((result[key] ?? []) as unknown[]).slice(); + existing.push(...incoming); + result[key] = existing; +} + +/** + * Merges users by username. Fails on role conflicts for + * the same user. + */ +function mergeUsers( + result: Record, + bp: BlueprintV2Declaration +): void { + const bpObj = bp as Record; + const incoming = bpObj.users as Record[] | undefined; + if (!incoming || incoming.length === 0) { + return; + } + + const existing = ( + (result.users ?? []) as Record[] + ).slice(); + const usernameIndex: Record = {}; + for (let i = 0; i < existing.length; i++) { + const u = String(existing[i].username ?? ''); + if (u) { + usernameIndex[u] = i; + } + } + + for (const user of incoming) { + const username = String(user.username ?? ''); + if (username in usernameIndex) { + const existingUser = existing[usernameIndex[username]]; + if ( + existingUser.role !== undefined && + user.role !== undefined && + existingUser.role !== user.role + ) { + throw new BlueprintMergeConflictError( + `users[${username}].role`, + `Conflicting roles for user "${username}": ` + + `"${existingUser.role}" vs "${user.role}"` + ); + } + } else { + usernameIndex[username] = existing.length; + existing.push(user); + } + } + + result.users = existing; +} + +/** + * Merges roles by name. Fails on capability conflicts. + */ +function mergeRoles( + result: Record, + bp: BlueprintV2Declaration +): void { + const bpObj = bp as Record; + const incoming = bpObj.roles as Record[] | undefined; + if (!incoming || incoming.length === 0) { + return; + } + + const existing = ( + (result.roles ?? []) as Record[] + ).slice(); + const nameIndex: Record = {}; + for (let i = 0; i < existing.length; i++) { + const n = String(existing[i].name ?? ''); + if (n) { + nameIndex[n] = i; + } + } + + for (const role of incoming) { + const name = String(role.name ?? ''); + if (name in nameIndex) { + const existingRole = existing[nameIndex[name]]; + if ( + existingRole.capabilities !== undefined && + role.capabilities !== undefined && + !deepEqual(existingRole.capabilities, role.capabilities) + ) { + throw new BlueprintMergeConflictError( + `roles[${name}].capabilities`, + `Conflicting capabilities for role "${name}"` + ); + } + } else { + nameIndex[name] = existing.length; + existing.push(role); + } + } + + result.roles = existing; +} + +/** + * Merges applicationOptions by deep-merging the + * 'wordpress-playground' key. + */ +function mergeApplicationOptions( + result: Record, + bp: BlueprintV2Declaration +): void { + const bpObj = bp as Record; + const incoming = bpObj.applicationOptions as + | Record + | undefined; + if (!incoming) { + return; + } + + const existing = (result.applicationOptions ?? {}) as Record< + string, + unknown + >; + + for (const [appKey, appOpts] of Object.entries(incoming)) { + if (!(appKey in existing)) { + existing[appKey] = appOpts; + } else { + // Shallow merge per-app options + const merged = { + ...(existing[appKey] as Record), + ...(appOpts as Record), + }; + existing[appKey] = merged; + } + } + + result.applicationOptions = existing; +} + +// ------------------------------------------------------------------ +// Utilities +// ------------------------------------------------------------------ + +/** + * Simple deep equality check for JSON-serializable values. + */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true; + } + if ( + typeof a !== 'object' || + typeof b !== 'object' || + a === null || + b === null + ) { + return false; + } + const keysA = Object.keys(a as Record); + const keysB = Object.keys(b as Record); + if (keysA.length !== keysB.length) { + return false; + } + const objA = a as Record; + const objB = b as Record; + return keysA.every((key) => deepEqual(objA[key], objB[key])); +} diff --git a/packages/playground/blueprints/src/lib/v2/compile/transpile-declarative.spec.ts b/packages/playground/blueprints/src/lib/v2/compile/transpile-declarative.spec.ts new file mode 100644 index 00000000000..d084e004079 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/compile/transpile-declarative.spec.ts @@ -0,0 +1,298 @@ +import { describe, it, expect } from 'vitest'; +import { transpileDeclarativeToSteps } from './transpile-declarative'; +import type { BlueprintV2Declaration } from '../types'; + +describe('transpileDeclarativeToSteps', () => { + it('should return an empty array for a minimal blueprint', () => { + const blueprint: BlueprintV2Declaration = { version: 2 }; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toEqual([]); + }); + + it('should transpile constants to a defineConstants step', () => { + const blueprint = { + version: 2, + constants: { WP_DEBUG: true, WP_DEBUG_LOG: true }, + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toHaveLength(1); + expect(steps[0].step).toBe('defineConstants'); + expect(steps[0].args).toEqual({ + constants: { WP_DEBUG: true, WP_DEBUG_LOG: true }, + }); + }); + + it('should transpile plugins array to installPlugin steps with active: true default', () => { + const blueprint = { + version: 2, + plugins: ['jetpack', 'akismet'], + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toHaveLength(2); + expect(steps[0].step).toBe('installPlugin'); + expect(steps[0].args).toEqual({ + source: 'jetpack', + active: true, + }); + expect(steps[1].step).toBe('installPlugin'); + expect(steps[1].args).toEqual({ + source: 'akismet', + active: true, + }); + }); + + it('should preserve active: false from plugin objects', () => { + const blueprint = { + version: 2, + plugins: [ + { + source: 'woocommerce', + active: false, + }, + ], + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toHaveLength(1); + expect(steps[0].step).toBe('installPlugin'); + expect(steps[0].args.active).toBe(false); + expect(steps[0].args.source).toBe('woocommerce'); + }); + + it('should transpile activeTheme to installTheme + activateTheme', () => { + const blueprint = { + version: 2, + activeTheme: 'twentytwentyfour', + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toHaveLength(2); + expect(steps[0].step).toBe('installTheme'); + expect(steps[0].args).toEqual({ + source: 'twentytwentyfour', + }); + expect(steps[1].step).toBe('activateTheme'); + expect(steps[1].args).toEqual({ + themeDirectoryName: 'twentytwentyfour', + }); + }); + + it('should maintain spec-defined step order', () => { + const blueprint = { + version: 2, + // Deliberately list properties out of spec order + // to verify the transpiler reorders them correctly. + plugins: ['jetpack'], + siteLanguage: 'fr_FR', + constants: { WP_DEBUG: true }, + themes: ['astra'], + siteOptions: { blogname: 'Test' }, + activeTheme: 'twentytwentyfour', + content: [ + { type: 'wxr', source: 'https://example.com/content.wxr' }, + ], + media: ['https://example.com/image.jpg'], + additionalStepsAfterExecution: [ + { step: 'runPHP', code: ' s.step); + + // Verify spec-defined ordering: + // 1. defineConstants + // 2. setSiteOptions + // 3. (muPlugins — none here) + // 4. installTheme (from themes) + // 5. installTheme + activateTheme (from activeTheme) + // 6. installPlugin (from plugins) + // 7. (fonts — none here) + // 8. importMedia + // 9. setSiteLanguage + // 10-12. (roles, users, postTypes — none here) + // 13. importContent + // 14. additionalStepsAfterExecution + const expectedOrder = [ + 'defineConstants', + 'setSiteOptions', + 'installTheme', // from themes + 'installTheme', // from activeTheme + 'activateTheme', // from activeTheme + 'installPlugin', // from plugins + 'importMedia', + 'setSiteLanguage', + 'importContent', + 'runPHP', // from additionalStepsAfterExecution + ]; + expect(stepNames).toEqual(expectedOrder); + }); + + it('should append additionalStepsAfterExecution at the end', () => { + const blueprint = { + version: 2, + constants: { WP_DEBUG: true }, + additionalStepsAfterExecution: [ + { step: 'runPHP', code: ' { + const blueprint = { + version: 2, + siteOptions: { + blogname: 'My Blog', + timezone_string: 'Europe/London', + }, + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toHaveLength(1); + expect(steps[0].step).toBe('setSiteOptions'); + expect(steps[0].args).toEqual({ + options: { + blogname: 'My Blog', + timezone_string: 'Europe/London', + }, + }); + }); + + it('should transpile siteLanguage to a setSiteLanguage step', () => { + const blueprint = { + version: 2, + siteLanguage: 'de_DE', + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toHaveLength(1); + expect(steps[0].step).toBe('setSiteLanguage'); + expect(steps[0].args).toEqual({ language: 'de_DE' }); + }); + + it('should transpile themes to installTheme steps without activation', () => { + const blueprint = { + version: 2, + themes: ['astra', 'flavflavor'], + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toHaveLength(2); + expect(steps[0].step).toBe('installTheme'); + expect(steps[0].args).toEqual({ source: 'astra' }); + expect(steps[1].step).toBe('installTheme'); + expect(steps[1].args).toEqual({ source: 'flavflavor' }); + // No activateTheme step should be present + expect(steps.every((s) => s.step !== 'activateTheme')).toBe(true); + }); + + it('should add progressHints with appropriate weights', () => { + const blueprint = { + version: 2, + plugins: ['jetpack'], + themes: ['astra'], + constants: { WP_DEBUG: true }, + siteLanguage: 'en_US', + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + + const constantsStep = steps.find((s) => s.step === 'defineConstants'); + expect(constantsStep?.progressHints?.weight).toBe(1); + + const pluginStep = steps.find((s) => s.step === 'installPlugin'); + expect(pluginStep?.progressHints?.weight).toBe(5); + expect(pluginStep?.progressHints?.caption).toContain('jetpack'); + + const themeStep = steps.find((s) => s.step === 'installTheme'); + expect(themeStep?.progressHints?.weight).toBe(5); + + const langStep = steps.find((s) => s.step === 'setSiteLanguage'); + expect(langStep?.progressHints?.weight).toBe(1); + }); + + it('should transpile media entries to importMedia steps', () => { + const blueprint = { + version: 2, + media: [ + 'https://example.com/image.jpg', + { + source: 'https://example.com/video.mp4', + title: 'My Video', + }, + ], + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toHaveLength(2); + expect(steps[0].step).toBe('importMedia'); + expect(steps[0].args).toEqual({ + source: 'https://example.com/image.jpg', + }); + expect(steps[1].step).toBe('importMedia'); + expect(steps[1].args).toEqual({ + source: 'https://example.com/video.mp4', + title: 'My Video', + }); + }); + + it('should transpile content entries to importContent steps', () => { + const blueprint = { + version: 2, + content: [ + { + type: 'wxr', + source: 'https://example.com/content.wxr', + }, + ], + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toHaveLength(1); + expect(steps[0].step).toBe('importContent'); + expect(steps[0].args).toEqual({ + type: 'wxr', + source: 'https://example.com/content.wxr', + }); + }); + + it('should transpile plugin objects with all properties', () => { + const blueprint = { + version: 2, + plugins: [ + { + source: 'woocommerce', + active: true, + activationOptions: { storeCity: 'London' }, + humanReadableName: 'WooCommerce', + }, + ], + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toHaveLength(1); + expect(steps[0].args.source).toBe('woocommerce'); + expect(steps[0].args.active).toBe(true); + expect(steps[0].args.activationOptions).toEqual({ + storeCity: 'London', + }); + expect(steps[0].progressHints?.caption).toContain('WooCommerce'); + }); + + it('should transpile activeTheme objects', () => { + const blueprint = { + version: 2, + activeTheme: { + source: 'https://example.com/theme.zip', + humanReadableName: 'My Theme', + }, + } as BlueprintV2Declaration; + const steps = transpileDeclarativeToSteps(blueprint); + expect(steps).toHaveLength(2); + expect(steps[0].step).toBe('installTheme'); + expect(steps[0].args.source).toBe('https://example.com/theme.zip'); + expect(steps[0].progressHints?.caption).toContain('My Theme'); + expect(steps[1].step).toBe('activateTheme'); + }); +}); diff --git a/packages/playground/blueprints/src/lib/v2/compile/transpile-declarative.ts b/packages/playground/blueprints/src/lib/v2/compile/transpile-declarative.ts new file mode 100644 index 00000000000..6a59d034269 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/compile/transpile-declarative.ts @@ -0,0 +1,444 @@ +import type { BlueprintV2Declaration, CompiledV2Step } from '../types'; + +/** + * Transpiles declarative blueprint properties into an ordered + * sequence of compiled steps, following the spec-defined order. + * + * Each declarative property (e.g. `plugins`, `themes`, `constants`) + * maps to one or more step objects that the execution loop can + * dispatch to step handlers. + */ +export function transpileDeclarativeToSteps( + blueprint: BlueprintV2Declaration +): CompiledV2Step[] { + const steps: CompiledV2Step[] = []; + + transpileConstants(blueprint, steps); + transpileSiteOptions(blueprint, steps); + transpileMuPlugins(blueprint, steps); + transpileThemes(blueprint, steps); + transpileActiveTheme(blueprint, steps); + transpilePlugins(blueprint, steps); + transpileFonts(blueprint, steps); + transpileMedia(blueprint, steps); + transpileSiteLanguage(blueprint, steps); + transpileRoles(blueprint, steps); + transpileUsers(blueprint, steps); + transpilePostTypes(blueprint, steps); + transpileContent(blueprint, steps); + transpileAdditionalSteps(blueprint, steps); + + return steps; +} + +// ------------------------------------------------------------------ +// Per-property transpilation helpers (spec-defined order) +// ------------------------------------------------------------------ + +/** + * 1. constants -> defineConstants step + */ +function transpileConstants( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.constants) { + return; + } + steps.push({ + step: 'defineConstants', + args: { constants: blueprint.constants }, + progressHints: { + caption: 'Defining constants', + weight: 1, + }, + }); +} + +/** + * 2. siteOptions -> setSiteOptions step + */ +function transpileSiteOptions( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.siteOptions) { + return; + } + steps.push({ + step: 'setSiteOptions', + args: { options: blueprint.siteOptions }, + progressHints: { + caption: 'Setting site options', + weight: 1, + }, + }); +} + +/** + * 3. muPlugins -> installMuPlugin steps + */ +function transpileMuPlugins( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.muPlugins) { + return; + } + for (const entry of blueprint.muPlugins) { + steps.push({ + step: 'installMuPlugin', + args: { source: entry }, + progressHints: { + caption: 'Installing mu-plugin', + weight: 2, + }, + }); + } +} + +/** + * 4. themes -> installTheme steps (without activation) + */ +function transpileThemes( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.themes) { + return; + } + for (const entry of blueprint.themes) { + const args = normalizeThemeEntry(entry); + const name = describeThemeSource(args); + steps.push({ + step: 'installTheme', + args, + progressHints: { + caption: `Installing ${name} theme`, + weight: 5, + }, + }); + } +} + +/** + * 5. activeTheme -> installTheme + activateTheme steps + */ +function transpileActiveTheme( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.activeTheme) { + return; + } + const args = normalizeThemeEntry(blueprint.activeTheme); + const name = describeThemeSource(args); + steps.push({ + step: 'installTheme', + args, + progressHints: { + caption: `Installing ${name} theme`, + weight: 5, + }, + }); + steps.push({ + step: 'activateTheme', + args: { themeDirectoryName: args.source as string }, + progressHints: { + caption: `Activating ${name} theme`, + weight: 1, + }, + }); +} + +/** + * 6. plugins -> installPlugin steps (active: true by default) + */ +function transpilePlugins( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.plugins) { + return; + } + for (const entry of blueprint.plugins) { + const args = normalizePluginEntry(entry); + const name = describePluginSource(args); + steps.push({ + step: 'installPlugin', + args, + progressHints: { + caption: `Installing ${name} plugin`, + weight: 5, + }, + }); + } +} + +/** + * 7. fonts -> installFont steps + */ +function transpileFonts( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.fonts) { + return; + } + for (const [name, definition] of Object.entries(blueprint.fonts)) { + steps.push({ + step: 'installFont', + args: { name, definition }, + progressHints: { + caption: `Installing ${name} font`, + weight: 2, + }, + }); + } +} + +/** + * 8. media -> importMedia steps + */ +function transpileMedia( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.media) { + return; + } + for (const entry of blueprint.media) { + const args = + typeof entry === 'string' ? { source: entry } : { ...entry }; + steps.push({ + step: 'importMedia', + args, + progressHints: { + caption: 'Importing media', + weight: 2, + }, + }); + } +} + +/** + * 9. siteLanguage -> setSiteLanguage step + */ +function transpileSiteLanguage( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.siteLanguage) { + return; + } + steps.push({ + step: 'setSiteLanguage', + args: { language: blueprint.siteLanguage }, + progressHints: { + caption: 'Setting site language', + weight: 1, + }, + }); +} + +/** + * 10. roles -> runPHP steps (stub) + */ +function transpileRoles( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.roles) { + return; + } + for (const role of blueprint.roles) { + steps.push({ + step: 'runPHP', + args: { + code: generateCreateRolePHP(role), + }, + progressHints: { + caption: `Creating ${role.name} role`, + weight: 1, + }, + }); + } +} + +/** + * 11. users -> runPHP steps (stub) + */ +function transpileUsers( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.users) { + return; + } + for (const user of blueprint.users) { + steps.push({ + step: 'runPHP', + args: { + code: generateCreateUserPHP(user), + }, + progressHints: { + caption: `Creating user ${user.username}`, + weight: 1, + }, + }); + } +} + +/** + * 12. postTypes -> runPHP steps (stub) + */ +function transpilePostTypes( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.postTypes) { + return; + } + for (const [key, definition] of Object.entries(blueprint.postTypes)) { + steps.push({ + step: 'runPHP', + args: { + code: generateRegisterPostTypePHP(key, definition), + }, + progressHints: { + caption: `Registering ${key} post type`, + weight: 1, + }, + }); + } +} + +/** + * 13. content -> importContent steps + */ +function transpileContent( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.content) { + return; + } + for (const entry of blueprint.content) { + steps.push({ + step: 'importContent', + args: { ...entry }, + progressHints: { + caption: 'Importing content', + weight: 3, + }, + }); + } +} + +/** + * 14. additionalStepsAfterExecution -> appended as-is + */ +function transpileAdditionalSteps( + blueprint: BlueprintV2Declaration, + steps: CompiledV2Step[] +): void { + if (!blueprint.additionalStepsAfterExecution) { + return; + } + for (const entry of blueprint.additionalStepsAfterExecution) { + const { step: stepName, ...rest } = entry as Record; + steps.push({ + step: stepName as string, + args: rest, + }); + } +} + +// ------------------------------------------------------------------ +// Normalization helpers +// ------------------------------------------------------------------ + +function normalizePluginEntry(entry: unknown): Record { + if (typeof entry === 'string') { + return { source: entry, active: true }; + } + if (typeof entry === 'object' && entry !== null) { + const obj = entry as Record; + return { + ...obj, + active: obj.active !== undefined ? obj.active : true, + }; + } + return { source: entry, active: true }; +} + +function normalizeThemeEntry(entry: unknown): Record { + if (typeof entry === 'string') { + return { source: entry }; + } + if (typeof entry === 'object' && entry !== null) { + return { ...(entry as Record) }; + } + return { source: entry }; +} + +function describePluginSource(args: Record): string { + if (typeof args.humanReadableName === 'string') { + return args.humanReadableName; + } + if (typeof args.source === 'string') { + return args.source; + } + return 'unknown'; +} + +function describeThemeSource(args: Record): string { + if (typeof args.humanReadableName === 'string') { + return args.humanReadableName; + } + if (typeof args.source === 'string') { + return args.source; + } + return 'unknown'; +} + +// ------------------------------------------------------------------ +// PHP code generation stubs +// ------------------------------------------------------------------ + +function generateCreateRolePHP(role: Record): string { + const name = String(role.name ?? ''); + const caps = role.capabilities ?? {}; + const capsJson = JSON.stringify(caps); + return [ + '): string { + const username = String(user.username ?? ''); + const email = String(user.email ?? ''); + const role = String(user.role ?? 'subscriber'); + return [ + 'set_role('${role}'); }`, + ].join('\n'); +} + +function generateRegisterPostTypePHP(key: string, definition: unknown): string { + if (typeof definition === 'string') { + // Execution-context path reference — handled by + // a future data-reference resolution step. + return ` { + // ============================================================ + // Basic structure + // ============================================================ + + it('sets version to 2', () => { + const result = transpileV1toV2({}); + expect(result.version).toBe(2); + }); + + it('handles empty V1 blueprint', () => { + const result = transpileV1toV2({}); + expect(result).toEqual({ version: 2 }); + }); + + // ============================================================ + // Top-level property mapping: version constraints + // ============================================================ + + describe('version constraints', () => { + it('maps preferredVersions.php to phpVersion', () => { + const result = transpileV1toV2({ + preferredVersions: { php: '8.2', wp: 'latest' }, + }); + expect(result.phpVersion).toBe('8.2'); + }); + + it('maps preferredVersions.wp to wordpressVersion', () => { + const result = transpileV1toV2({ + preferredVersions: { php: '8.2', wp: '6.4' }, + }); + expect(result.wordpressVersion).toBe('6.4'); + }); + + it('maps "latest" string values', () => { + const result = transpileV1toV2({ + preferredVersions: { php: 'latest', wp: 'latest' }, + }); + expect(result.phpVersion).toBe('latest'); + expect(result.wordpressVersion).toBe('latest'); + }); + + it('omits version fields when preferredVersions is absent', () => { + const result = transpileV1toV2({}); + expect(result).not.toHaveProperty('phpVersion'); + expect(result).not.toHaveProperty('wordpressVersion'); + }); + }); + + // ============================================================ + // Top-level property mapping: applicationOptions + // ============================================================ + + describe('applicationOptions', () => { + it('maps landingPage', () => { + const result = transpileV1toV2({ landingPage: '/wp-admin/' }); + expect( + (result as any).applicationOptions['wordpress-playground'] + .landingPage + ).toBe('/wp-admin/'); + }); + + it('maps login: true', () => { + const result = transpileV1toV2({ login: true }); + expect( + (result as any).applicationOptions['wordpress-playground'].login + ).toBe(true); + }); + + it('maps login object', () => { + const result = transpileV1toV2({ + login: { username: 'admin', password: 'pass' }, + }); + expect( + (result as any).applicationOptions['wordpress-playground'].login + ).toEqual({ username: 'admin', password: 'pass' }); + }); + + it('maps features.networking to networkAccess', () => { + const result = transpileV1toV2({ + features: { networking: true }, + }); + expect( + (result as any).applicationOptions['wordpress-playground'] + .networkAccess + ).toBe(true); + }); + + it('combines all applicationOptions fields', () => { + const result = transpileV1toV2({ + landingPage: '/shop/', + login: true, + features: { networking: true }, + }); + const opts = (result as any).applicationOptions[ + 'wordpress-playground' + ]; + expect(opts.landingPage).toBe('/shop/'); + expect(opts.login).toBe(true); + expect(opts.networkAccess).toBe(true); + }); + + it('omits applicationOptions when no relevant V1 fields', () => { + const result = transpileV1toV2({}); + expect(result).not.toHaveProperty('applicationOptions'); + }); + }); + + // ============================================================ + // Top-level property mapping: blueprintMeta + // ============================================================ + + describe('blueprintMeta', () => { + it('maps meta.title to name', () => { + const result = transpileV1toV2({ + meta: { title: 'My Blueprint', author: 'jdoe' }, + }); + expect((result as any).blueprintMeta.name).toBe('My Blueprint'); + }); + + it('maps meta.description', () => { + const result = transpileV1toV2({ + meta: { + title: 'Test', + description: 'A test', + author: 'jdoe', + }, + }); + expect((result as any).blueprintMeta.description).toBe('A test'); + }); + + it('wraps meta.author in array', () => { + const result = transpileV1toV2({ + meta: { title: 'Test', author: 'jdoe' }, + }); + expect((result as any).blueprintMeta.authors).toEqual(['jdoe']); + }); + + it('maps meta.categories to tags', () => { + const result = transpileV1toV2({ + meta: { + title: 'Test', + author: 'jdoe', + categories: ['ecommerce', 'starter'], + }, + }); + expect((result as any).blueprintMeta.tags).toEqual([ + 'ecommerce', + 'starter', + ]); + }); + + it('omits blueprintMeta when no meta', () => { + const result = transpileV1toV2({}); + expect(result).not.toHaveProperty('blueprintMeta'); + }); + }); + + // ============================================================ + // Declarative shorthand → steps + // ============================================================ + + describe('V1 constants', () => { + it('transpiles constants to defineConstants step', () => { + const result = transpileV1toV2({ + constants: { WP_DEBUG: true, DISALLOW_FILE_EDIT: true }, + }); + expect( + (result as any).additionalStepsAfterExecution + ).toContainEqual({ + step: 'defineConstants', + constants: { WP_DEBUG: true, DISALLOW_FILE_EDIT: true }, + }); + }); + }); + + describe('V1 siteOptions', () => { + it('transpiles siteOptions to setSiteOptions step', () => { + const result = transpileV1toV2({ + siteOptions: { blogname: 'My Site' }, + }); + expect( + (result as any).additionalStepsAfterExecution + ).toContainEqual({ + step: 'setSiteOptions', + options: { blogname: 'My Site' }, + }); + }); + }); + + describe('V1 plugins shorthand', () => { + it('transpiles string plugin slugs', () => { + const result = transpileV1toV2({ + plugins: ['hello-dolly', 'akismet'], + }); + const steps = (result as any).additionalStepsAfterExecution; + expect(steps).toContainEqual({ + step: 'installPlugin', + source: 'hello-dolly', + active: true, + }); + expect(steps).toContainEqual({ + step: 'installPlugin', + source: 'akismet', + active: true, + }); + }); + + it('transpiles resource plugin entries', () => { + const result = transpileV1toV2({ + plugins: [ + { + resource: 'url', + url: 'https://example.com/plugin.zip', + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution + ).toContainEqual({ + step: 'installPlugin', + source: 'https://example.com/plugin.zip', + active: true, + }); + }); + + it('transpiles wordpress.org plugin slugs', () => { + const result = transpileV1toV2({ + plugins: [ + { resource: 'wordpress.org/plugins', slug: 'jetpack' }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution + ).toContainEqual({ + step: 'installPlugin', + source: 'jetpack', + active: true, + }); + }); + }); + + // ============================================================ + // Step ordering + // ============================================================ + + it('orders declarative steps before explicit steps', () => { + const result = transpileV1toV2({ + constants: { WP_DEBUG: true }, + siteOptions: { blogname: 'Test' }, + plugins: ['hello-dolly'], + steps: [{ step: 'login' }], + }); + const steps = (result as any).additionalStepsAfterExecution; + const stepNames = steps.map((s: Record) => s.step); + expect(stepNames).toEqual([ + 'defineConstants', + 'setSiteOptions', + 'installPlugin', + 'login', + ]); + }); + + // ============================================================ + // Per-step rewrites + // ============================================================ + + describe('step rewrites', () => { + it('rewrites installPlugin: pluginData → source', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'installPlugin', + pluginData: { + resource: 'url', + url: 'https://example.com/plugin.zip', + }, + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0] + ).toMatchObject({ + step: 'installPlugin', + source: 'https://example.com/plugin.zip', + }); + }); + + it('rewrites installPlugin: pluginZipFile → source', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'installPlugin', + pluginZipFile: { + resource: 'url', + url: 'https://example.com/p.zip', + }, + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0] + ).toMatchObject({ + step: 'installPlugin', + source: 'https://example.com/p.zip', + }); + }); + + it('rewrites installPlugin: preserves options', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'installPlugin', + pluginData: { + resource: 'wordpress.org/plugins', + slug: 'woo', + }, + options: { + activate: false, + targetFolderName: 'woo-custom', + }, + ifAlreadyInstalled: 'skip', + }, + ], + }); + const step = (result as any).additionalStepsAfterExecution[0]; + expect(step.source).toBe('woo'); + expect(step.active).toBe(false); + expect(step.targetFolderName).toBe('woo-custom'); + expect(step.ifAlreadyInstalled).toBe('skip'); + }); + + it('rewrites installTheme: themeData → source', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'installTheme', + themeData: { + resource: 'wordpress.org/themes', + slug: 'astra', + }, + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0] + ).toMatchObject({ + step: 'installTheme', + source: 'astra', + }); + }); + + it('rewrites activateTheme: themeFolderName → themeDirectoryName', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'activateTheme', + themeFolderName: 'twentytwentyfour', + }, + ], + }); + expect((result as any).additionalStepsAfterExecution[0]).toEqual({ + step: 'activateTheme', + themeDirectoryName: 'twentytwentyfour', + }); + }); + + it('rewrites defineWpConfigConsts → defineConstants', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'defineWpConfigConsts', + consts: { WP_DEBUG: true }, + }, + ], + }); + expect((result as any).additionalStepsAfterExecution[0]).toEqual({ + step: 'defineConstants', + constants: { WP_DEBUG: true }, + }); + }); + + it('rewrites wpCLI step name', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'wp-cli', + command: 'wp plugin list', + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0] + ).toMatchObject({ + step: 'wpCLI', + command: 'wp plugin list', + }); + }); + + it('rewrites runPHPWithOptions → runPHP', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'runPHPWithOptions', + options: { + code: '', + env: { FOO: 'bar' }, + }, + }, + ], + }); + const step = (result as any).additionalStepsAfterExecution[0]; + expect(step.step).toBe('runPHP'); + expect(step.env).toEqual({ FOO: 'bar' }); + }); + + it('rewrites importWxr → importContent', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'importWxr', + file: { + resource: 'url', + url: 'https://example.com/content.xml', + }, + }, + ], + }); + expect((result as any).additionalStepsAfterExecution[0]).toEqual({ + step: 'importContent', + source: 'https://example.com/content.xml', + type: 'wxr', + }); + }); + + it('rewrites writeFile → writeFiles', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'writeFile', + path: '/wordpress/wp-content/test.txt', + data: 'hello world', + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0] + ).toMatchObject({ + step: 'writeFiles', + writeToPath: '/wp-content/test.txt', + data: 'hello world', + }); + }); + + it('rewrites runSql → runSQL', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'runSql', + sql: { + resource: 'url', + url: 'https://example.com/data.sql', + }, + }, + ], + }); + expect((result as any).additionalStepsAfterExecution[0]).toEqual({ + step: 'runSQL', + source: 'https://example.com/data.sql', + }); + }); + + it('rewrites setSiteOptions step', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'setSiteOptions', + options: { blogname: 'Test' }, + }, + ], + }); + expect((result as any).additionalStepsAfterExecution[0]).toEqual({ + step: 'setSiteOptions', + options: { blogname: 'Test' }, + }); + }); + + it('preserves login step fields', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'login', + username: 'editor', + }, + ], + }); + expect((result as any).additionalStepsAfterExecution[0]).toEqual({ + step: 'login', + username: 'editor', + }); + }); + + it('preserves filesystem steps with path translation', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'mkdir', + path: '/wordpress/wp-content/uploads/test', + }, + ], + }); + expect((result as any).additionalStepsAfterExecution[0]).toEqual({ + step: 'mkdir', + path: '/wp-content/uploads/test', + }); + }); + + it('preserves cp step with path translation', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'cp', + fromPath: '/wordpress/wp-content/a.txt', + toPath: '/wordpress/wp-content/b.txt', + }, + ], + }); + expect((result as any).additionalStepsAfterExecution[0]).toEqual({ + step: 'cp', + fromPath: '/wp-content/a.txt', + toPath: '/wp-content/b.txt', + }); + }); + + it('passes through unknown steps', () => { + const result = transpileV1toV2({ + steps: [{ step: 'someCustomStep', foo: 'bar' }], + }); + expect((result as any).additionalStepsAfterExecution[0]).toEqual({ + step: 'someCustomStep', + foo: 'bar', + }); + }); + + it('filters out falsy step entries', () => { + const result = transpileV1toV2({ + steps: [null, undefined, false, { step: 'login' }] as any, + }); + expect((result as any).additionalStepsAfterExecution).toHaveLength( + 1 + ); + expect((result as any).additionalStepsAfterExecution[0].step).toBe( + 'login' + ); + }); + }); + + // ============================================================ + // Resource → DataReference conversion + // ============================================================ + + describe('resource conversion', () => { + it('converts url resource to URL string', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'installPlugin', + pluginData: { + resource: 'url', + url: 'https://example.com/p.zip', + }, + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].source + ).toBe('https://example.com/p.zip'); + }); + + it('converts literal resource to inline file', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'writeFile', + path: '/test.txt', + data: { + resource: 'literal', + name: 'test.txt', + contents: 'hello', + }, + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].data + ).toEqual({ + filename: 'test.txt', + content: 'hello', + }); + }); + + it('converts wordpress.org/plugins to slug string', () => { + const result = transpileV1toV2({ + plugins: [ + { + resource: 'wordpress.org/plugins', + slug: 'jetpack', + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].source + ).toBe('jetpack'); + }); + + it('converts wordpress.org/themes to slug string', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'installTheme', + themeData: { + resource: 'wordpress.org/themes', + slug: 'astra', + }, + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].source + ).toBe('astra'); + }); + + it('converts vfs resource to site: path', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'installPlugin', + pluginData: { + resource: 'vfs', + path: '/wordpress/wp-content/plugins/foo', + }, + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].source + ).toBe('site:/wp-content/plugins/foo'); + }); + + it('converts bundled resource to ./ path', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'installPlugin', + pluginData: { + resource: 'bundled', + path: 'plugins/my-plugin.zip', + }, + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].source + ).toBe('./plugins/my-plugin.zip'); + }); + + it('converts git:directory to GitPath', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'installPlugin', + pluginData: { + resource: 'git:directory', + url: 'https://github.com/org/repo', + ref: 'main', + path: 'plugins/my-plugin', + }, + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].source + ).toEqual({ + gitRepository: 'https://github.com/org/repo', + ref: 'main', + pathInRepository: 'plugins/my-plugin', + }); + }); + + it('converts literal:directory to InlineDirectory', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'writeFiles', + writeToPath: '/test', + filesTree: { + resource: 'literal:directory', + name: 'test-dir', + files: { 'a.txt': 'hello' }, + }, + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].filesTree + ).toEqual({ + directoryName: 'test-dir', + files: { 'a.txt': 'hello' }, + }); + }); + + it('unwraps zip resource wrapper', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'installPlugin', + pluginData: { + resource: 'zip', + inner: { + resource: 'url', + url: 'https://example.com/files', + }, + }, + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].source + ).toBe('https://example.com/files'); + }); + }); + + // ============================================================ + // Path translation + // ============================================================ + + describe('path translation', () => { + it('strips /wordpress/ prefix from paths', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'rm', + path: '/wordpress/wp-content/test.txt', + }, + ], + }); + expect((result as any).additionalStepsAfterExecution[0].path).toBe( + '/wp-content/test.txt' + ); + }); + + it('strips wordpress/ prefix (no leading slash)', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'rm', + path: 'wordpress/wp-content/test.txt', + }, + ], + }); + expect((result as any).additionalStepsAfterExecution[0].path).toBe( + '/wp-content/test.txt' + ); + }); + + it('leaves non-wordpress paths unchanged', () => { + const result = transpileV1toV2({ + steps: [{ step: 'rm', path: '/tmp/test.txt' }], + }); + expect((result as any).additionalStepsAfterExecution[0].path).toBe( + '/tmp/test.txt' + ); + }); + + it('translates /wordpress/ in PHP code', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'runPHP', + code: "", + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].code + ).toContain("getenv('DOCROOT')"); + expect( + (result as any).additionalStepsAfterExecution[0].code + ).not.toContain('/wordpress/'); + }); + + it('translates activatePlugin pluginPath', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'activatePlugin', + pluginPath: '/wordpress/wp-content/plugins/foo/foo.php', + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].pluginPath + ).toBe('/wp-content/plugins/foo/foo.php'); + }); + + it('translates unzip extractToPath', () => { + const result = transpileV1toV2({ + steps: [ + { + step: 'unzip', + zipFile: { + resource: 'url', + url: 'https://example.com/files.zip', + }, + extractToPath: '/wordpress/wp-content/plugins', + }, + ], + }); + expect( + (result as any).additionalStepsAfterExecution[0].extractToPath + ).toBe('/wp-content/plugins'); + }); + }); + + // ============================================================ + // Complex / integration scenarios + // ============================================================ + + describe('complex blueprints', () => { + it('transpiles a full V1 blueprint', () => { + const v1 = { + landingPage: '/wp-admin/', + preferredVersions: { php: '8.2', wp: '6.4' }, + login: true, + features: { networking: true }, + meta: { + title: 'Test Store', + description: 'A test store', + author: 'testuser', + categories: ['ecommerce'], + }, + constants: { WP_DEBUG: true }, + siteOptions: { blogname: 'My Store' }, + plugins: ['woocommerce'], + steps: [ + { + step: 'installTheme', + themeData: { + resource: 'wordpress.org/themes', + slug: 'storefront', + }, + options: { activate: true }, + }, + { + step: 'runPHP', + code: "", + }, + ], + }; + + const result = transpileV1toV2(v1); + + expect(result.version).toBe(2); + expect(result.phpVersion).toBe('8.2'); + expect(result.wordpressVersion).toBe('6.4'); + expect( + (result as any).applicationOptions['wordpress-playground'] + ).toEqual({ + landingPage: '/wp-admin/', + login: true, + networkAccess: true, + }); + expect((result as any).blueprintMeta).toEqual({ + name: 'Test Store', + description: 'A test store', + authors: ['testuser'], + tags: ['ecommerce'], + }); + + const steps = (result as any).additionalStepsAfterExecution; + expect(steps).toHaveLength(5); + expect(steps[0].step).toBe('defineConstants'); + expect(steps[1].step).toBe('setSiteOptions'); + expect(steps[2]).toMatchObject({ + step: 'installPlugin', + source: 'woocommerce', + active: true, + }); + expect(steps[3]).toMatchObject({ + step: 'installTheme', + source: 'storefront', + active: true, + }); + expect(steps[4].step).toBe('runPHP'); + expect(steps[4].code).toContain("getenv('DOCROOT')"); + }); + + it('handles blueprint with only steps', () => { + const result = transpileV1toV2({ + steps: [{ step: 'login' }], + }); + expect(result.version).toBe(2); + expect((result as any).additionalStepsAfterExecution).toEqual([ + { step: 'login' }, + ]); + }); + }); +}); diff --git a/packages/playground/blueprints/src/lib/v2/compile/v1-to-v2-transpiler.ts b/packages/playground/blueprints/src/lib/v2/compile/v1-to-v2-transpiler.ts new file mode 100644 index 00000000000..73b3cbd686f --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/compile/v1-to-v2-transpiler.ts @@ -0,0 +1,695 @@ +import type { BlueprintV2Declaration } from '../types'; + +/** + * Transpiles a V1 blueprint (any blueprint without a `version` + * property) into V2 format following the spec's mapping tables. + * + * The mapping covers: + * - Top-level properties (versions, applicationOptions, meta) + * - Declarative shorthand (constants, siteOptions, plugins) + * - V1 steps → V2 steps with per-step field rewrites + * - Resource objects → V2 data references + * - `/wordpress/` path translation + */ +export function transpileV1toV2( + v1: Record +): BlueprintV2Declaration { + const v2: Record = { version: 2 }; + + mapVersionConstraints(v1, v2); + mapApplicationOptions(v1, v2); + mapBlueprintMeta(v1, v2); + + // Build additionalStepsAfterExecution from V1 declarative + // properties and explicit steps. + const steps: Record[] = []; + transpileV1Constants(v1, steps); + transpileV1SiteOptions(v1, steps); + transpileV1Plugins(v1, steps); + transpileV1Steps(v1, steps); + + if (steps.length > 0) { + v2.additionalStepsAfterExecution = steps; + } + + return v2 as BlueprintV2Declaration; +} + +// ------------------------------------------------------------------ +// Top-level property mapping +// ------------------------------------------------------------------ + +/** + * Maps `preferredVersions.php/wp` → `phpVersion`/`wordpressVersion`. + */ +function mapVersionConstraints( + v1: Record, + v2: Record +): void { + const prefs = v1.preferredVersions as Record | undefined; + if (!prefs) { + return; + } + if (prefs.php !== undefined) { + v2.phpVersion = String(prefs.php); + } + if (prefs.wp !== undefined) { + v2.wordpressVersion = String(prefs.wp); + } +} + +/** + * Maps `landingPage`, `login`, and `features.networking` into + * `applicationOptions['wordpress-playground']`. + */ +function mapApplicationOptions( + v1: Record, + v2: Record +): void { + const playgroundOpts: Record = {}; + + if (v1.landingPage !== undefined) { + playgroundOpts.landingPage = v1.landingPage; + } + + if (v1.login !== undefined) { + playgroundOpts.login = v1.login; + } + + const features = v1.features as Record | undefined; + if (features?.networking !== undefined) { + playgroundOpts.networkAccess = features.networking; + } + + if (Object.keys(playgroundOpts).length > 0) { + v2.applicationOptions = { + 'wordpress-playground': playgroundOpts, + }; + } +} + +/** + * Maps `meta.*` → `blueprintMeta.*`. + */ +function mapBlueprintMeta( + v1: Record, + v2: Record +): void { + const meta = v1.meta as Record | undefined; + if (!meta) { + return; + } + + const bpMeta: Record = {}; + + if (meta.title !== undefined) { + bpMeta.name = meta.title; + } + if (meta.description !== undefined) { + bpMeta.description = meta.description; + } + if (meta.author !== undefined) { + bpMeta.authors = [meta.author]; + } + if (meta.categories !== undefined) { + bpMeta.tags = meta.categories; + } + + if (Object.keys(bpMeta).length > 0) { + v2.blueprintMeta = bpMeta; + } +} + +// ------------------------------------------------------------------ +// Declarative property → step transpilation +// ------------------------------------------------------------------ + +/** + * V1 `constants` → `defineConstants` step. + */ +function transpileV1Constants( + v1: Record, + steps: Record[] +): void { + if (!v1.constants) { + return; + } + steps.push({ + step: 'defineConstants', + constants: v1.constants, + }); +} + +/** + * V1 `siteOptions` → `setSiteOptions` step. + */ +function transpileV1SiteOptions( + v1: Record, + steps: Record[] +): void { + if (!v1.siteOptions) { + return; + } + steps.push({ + step: 'setSiteOptions', + options: v1.siteOptions, + }); +} + +/** + * V1 `plugins` shorthand → `installPlugin` steps. + * Each entry is either a slug string or a V1 resource. + */ +function transpileV1Plugins( + v1: Record, + steps: Record[] +): void { + const plugins = v1.plugins as unknown[] | undefined; + if (!plugins) { + return; + } + for (const entry of plugins) { + if (typeof entry === 'string') { + steps.push({ + step: 'installPlugin', + source: entry, + active: true, + }); + } else if (isResourceObject(entry)) { + steps.push({ + step: 'installPlugin', + source: convertResourceToDataReference(entry), + active: true, + }); + } + } +} + +// ------------------------------------------------------------------ +// V1 step → V2 step transpilation +// ------------------------------------------------------------------ + +/** + * Rewrites V1 `steps` into V2 `additionalStepsAfterExecution` + * entries with per-step field renaming. + */ +function transpileV1Steps( + v1: Record, + steps: Record[] +): void { + const v1Steps = v1.steps as unknown[] | undefined; + if (!v1Steps) { + return; + } + + for (const raw of v1Steps) { + // Filter out falsy entries (V1 allows undefined/false/null) + if (!raw || typeof raw !== 'object') { + continue; + } + const step = raw as Record; + const rewritten = rewriteStep(step); + if (rewritten) { + steps.push(rewritten); + } + } +} + +/** + * Step name mapping: V1 name → V2 name. + */ +const STEP_NAME_MAP: Record = { + defineWpConfigConsts: 'defineConstants', + 'wp-cli': 'wpCLI', + wpCLI: 'wpCLI', + runPHPWithOptions: 'runPHP', + importWxr: 'importContent', + writeFile: 'writeFiles', + runSql: 'runSQL', +}; + +/** + * Rewrites a single V1 step to its V2 equivalent. + */ +function rewriteStep( + step: Record +): Record | null { + const v1Name = step.step as string; + if (!v1Name) { + return null; + } + + const v2Name = STEP_NAME_MAP[v1Name] ?? v1Name; + + switch (v1Name) { + case 'installPlugin': + return rewriteInstallPlugin(step); + case 'installTheme': + return rewriteInstallTheme(step); + case 'activatePlugin': + return rewriteActivatePlugin(step); + case 'activateTheme': + return rewriteActivateTheme(step); + case 'defineWpConfigConsts': + return rewriteDefineWpConfigConsts(step); + case 'runPHP': + return rewriteRunPHP(step); + case 'runPHPWithOptions': + return rewriteRunPHPWithOptions(step); + case 'setSiteOptions': + return rewriteSetSiteOptions(step); + case 'wp-cli': + case 'wpCLI': + return rewriteWpCLI(step, v2Name); + case 'writeFile': + return rewriteWriteFile(step); + case 'writeFiles': + return rewriteWriteFiles(step); + case 'importWxr': + return rewriteImportWxr(step); + case 'importWordPressFiles': + return rewriteImportWordPressFiles(step); + case 'unzip': + return rewriteUnzip(step); + case 'runSql': + return rewriteRunSql(step); + case 'login': + return rewriteLogin(step); + case 'cp': + case 'mv': + return rewriteCpMv(step); + case 'rm': + case 'mkdir': + case 'rmdir': + return rewritePathStep(step); + case 'setSiteLanguage': + return { step: v2Name, language: step.language }; + case 'enableMultisite': + return { step: 'enableMultisite' }; + case 'importThemeStarterContent': + return rewriteImportThemeStarterContent(step); + case 'updateUserMeta': + return rewriteUpdateUserMeta(step); + case 'defineSiteUrl': + return { + step: 'defineSiteUrl', + siteUrl: step.siteUrl, + }; + case 'resetData': + return { step: 'resetData' }; + case 'request': + // Deprecated — pass through for best-effort + return { step: 'request', ...omit(step, 'step') }; + default: + // Unknown step — pass through as-is + return { step: v2Name, ...omit(step, 'step') }; + } +} + +// ------------------------------------------------------------------ +// Per-step rewrite functions +// ------------------------------------------------------------------ + +function rewriteInstallPlugin( + step: Record +): Record { + // V1: pluginData or pluginZipFile → V2: source + const source = step.pluginData ?? step.pluginZipFile ?? step.source; + const result: Record = { + step: 'installPlugin', + source: convertFieldToDataReference(source), + }; + const options = step.options as Record | undefined; + if (options?.activate !== undefined) { + result.active = options.activate; + } + if (step.ifAlreadyInstalled !== undefined) { + result.ifAlreadyInstalled = step.ifAlreadyInstalled; + } + if (options?.targetFolderName !== undefined) { + result.targetFolderName = options.targetFolderName; + } + return result; +} + +function rewriteInstallTheme( + step: Record +): Record { + // V1: themeData or themeZipFile → V2: source + const source = step.themeData ?? step.themeZipFile ?? step.source; + const result: Record = { + step: 'installTheme', + source: convertFieldToDataReference(source), + }; + const options = step.options as Record | undefined; + if (options?.activate !== undefined) { + result.active = options.activate; + } + if (step.ifAlreadyInstalled !== undefined) { + result.ifAlreadyInstalled = step.ifAlreadyInstalled; + } + if (options?.importStarterContent !== undefined) { + result.importStarterContent = options.importStarterContent; + } + if (options?.targetFolderName !== undefined) { + result.targetFolderName = options.targetFolderName; + } + return result; +} + +function rewriteActivatePlugin( + step: Record +): Record { + return { + step: 'activatePlugin', + pluginPath: translateWordPressPath(String(step.pluginPath ?? '')), + ...(step.pluginName !== undefined + ? { pluginName: step.pluginName } + : {}), + }; +} + +function rewriteActivateTheme( + step: Record +): Record { + return { + step: 'activateTheme', + themeDirectoryName: step.themeFolderName, + }; +} + +function rewriteDefineWpConfigConsts( + step: Record +): Record { + return { + step: 'defineConstants', + constants: step.consts, + }; +} + +function rewriteRunPHP(step: Record): Record { + const code = step.code; + if (typeof code === 'string') { + return { + step: 'runPHP', + code: translateWordPressPathsInPHP(code), + }; + } + return { step: 'runPHP', code }; +} + +function rewriteRunPHPWithOptions( + step: Record +): Record { + // V1 runPHPWithOptions wraps options in an `options` field + const options = step.options as Record | undefined; + if (!options) { + return { step: 'runPHP' }; + } + const result: Record = { + step: 'runPHP', + }; + if (typeof options.code === 'string') { + result.code = translateWordPressPathsInPHP(options.code); + } else if (options.code !== undefined) { + result.code = options.code; + } + if (options.env !== undefined) { + result.env = options.env; + } + return result; +} + +function rewriteSetSiteOptions( + step: Record +): Record { + return { + step: 'setSiteOptions', + options: step.options, + }; +} + +function rewriteWpCLI( + step: Record, + v2Name: string +): Record { + return { + step: v2Name, + command: step.command, + ...(step.wpCliPath !== undefined ? { wpCliPath: step.wpCliPath } : {}), + }; +} + +function rewriteWriteFile( + step: Record +): Record { + const path = translateWordPressPath(String(step.path ?? '')); + const data = convertFieldToDataReference(step.data); + return { + step: 'writeFiles', + writeToPath: path, + data, + }; +} + +function rewriteWriteFiles( + step: Record +): Record { + const writeToPath = translateWordPressPath(String(step.writeToPath ?? '')); + const filesTree = convertFieldToDataReference(step.filesTree); + return { + step: 'writeFiles', + writeToPath, + filesTree, + }; +} + +function rewriteImportWxr( + step: Record +): Record { + return { + step: 'importContent', + source: convertFieldToDataReference(step.file), + type: 'wxr', + }; +} + +function rewriteImportWordPressFiles( + step: Record +): Record { + return { + step: 'writeFiles', + writeToPath: '/', + data: convertFieldToDataReference(step.wordPressFilesZip), + ...(step.pathInZip !== undefined ? { pathInZip: step.pathInZip } : {}), + }; +} + +function rewriteUnzip(step: Record): Record { + const zipFile = step.zipFile ?? step.zipPath ?? step.source; + return { + step: 'unzip', + source: convertFieldToDataReference(zipFile), + extractToPath: translateWordPressPath(String(step.extractToPath ?? '')), + }; +} + +function rewriteRunSql(step: Record): Record { + return { + step: 'runSQL', + source: convertFieldToDataReference(step.sql), + }; +} + +function rewriteLogin(step: Record): Record { + const result: Record = { + step: 'login', + }; + if (step.username !== undefined) { + result.username = step.username; + } + if (step.password !== undefined) { + result.password = step.password; + } + return result; +} + +function rewriteCpMv(step: Record): Record { + return { + step: step.step as string, + fromPath: translateWordPressPath(String(step.fromPath ?? '')), + toPath: translateWordPressPath(String(step.toPath ?? '')), + }; +} + +function rewritePathStep( + step: Record +): Record { + return { + step: step.step as string, + path: translateWordPressPath(String(step.path ?? '')), + }; +} + +function rewriteImportThemeStarterContent( + step: Record +): Record { + const result: Record = { + step: 'importThemeStarterContent', + }; + if (step.themeSlug !== undefined) { + result.themeSlug = step.themeSlug; + } + return result; +} + +function rewriteUpdateUserMeta( + step: Record +): Record { + return { + step: 'updateUserMeta', + userId: step.userId, + meta: step.meta, + }; +} + +// ------------------------------------------------------------------ +// Resource → DataReference conversion +// ------------------------------------------------------------------ + +/** + * Checks whether a value is a V1 resource object + * (has a `resource` property). + */ +function isResourceObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && 'resource' in value; +} + +/** + * Converts a step field value to a V2 data reference. + * If it's a V1 resource object, converts it; otherwise + * returns as-is. + */ +function convertFieldToDataReference(value: unknown): unknown { + if (typeof value === 'string') { + return value; + } + if (isResourceObject(value)) { + return convertResourceToDataReference(value); + } + return value; +} + +/** + * Converts a V1 resource object to a V2 data reference. + */ +function convertResourceToDataReference( + resource: Record +): unknown { + switch (resource.resource) { + case 'url': + return resource.url as string; + + case 'literal': + return { + filename: resource.name, + content: resource.contents, + }; + + case 'wordpress.org/plugins': + return resource.slug as string; + + case 'wordpress.org/themes': + return resource.slug as string; + + case 'vfs': + return `site:${translateWordPressPath( + String(resource.path ?? '') + )}`; + + case 'bundled': + return `./${resource.path}`; + + case 'git:directory': + return convertGitDirectoryReference(resource); + + case 'literal:directory': + return { + directoryName: resource.name, + files: resource.files, + }; + + case 'zip': { + // Unwrap zip wrapper — convert inner reference + const inner = resource.inner; + return convertFieldToDataReference(inner); + } + + default: + // Unknown resource — pass through + return resource; + } +} + +/** + * Converts a V1 `git:directory` resource to a V2 GitPath. + */ +function convertGitDirectoryReference( + resource: Record +): Record { + const result: Record = { + gitRepository: resource.url, + }; + if (resource.ref !== undefined) { + result.ref = resource.ref; + } + if (resource.path !== undefined) { + result.pathInRepository = resource.path; + } + return result; +} + +// ------------------------------------------------------------------ +// Path translation +// ------------------------------------------------------------------ + +/** + * Translates `/wordpress/` VFS paths to document-root-relative + * paths. In V2, paths no longer have the `/wordpress/` prefix. + */ +function translateWordPressPath(path: string): string { + if (path.startsWith('/wordpress/')) { + return path.slice('/wordpress'.length); + } + if (path.startsWith('wordpress/')) { + return '/' + path.slice('wordpress/'.length); + } + return path; +} + +/** + * Translates `/wordpress/` path literals in PHP code to use + * `getenv('DOCROOT')`. + */ +function translateWordPressPathsInPHP(code: string): string { + // Replace '/wordpress/' literal paths in PHP with + // getenv('DOCROOT') concatenation. + return code.replace(/\/wordpress\//g, "' . getenv('DOCROOT') . '/"); +} + +// ------------------------------------------------------------------ +// Utilities +// ------------------------------------------------------------------ + +function omit( + obj: Record, + ...keys: string[] +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (!keys.includes(key)) { + result[key] = value; + } + } + return result; +} diff --git a/packages/playground/blueprints/src/lib/v2/compile/validate.spec.ts b/packages/playground/blueprints/src/lib/v2/compile/validate.spec.ts new file mode 100644 index 00000000000..63ba2af024e --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/compile/validate.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { validateBlueprintV2 } from './validate'; + +describe('validateBlueprintV2', () => { + it('should accept a valid minimal V2 blueprint', () => { + const result = validateBlueprintV2({ version: 2 }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should reject a blueprint without version', () => { + const result = validateBlueprintV2({}); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([ + expect.stringContaining('Missing required property "version"'), + ]); + }); + + it('should reject a blueprint with wrong version (version: 1)', () => { + const result = validateBlueprintV2({ version: 1 }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([ + expect.stringContaining('Invalid version'), + ]); + }); + + it('should accept a blueprint with a plugins array', () => { + const result = validateBlueprintV2({ + version: 2, + plugins: ['jetpack', 'akismet'], + }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should accept a blueprint with all declarative properties', () => { + const result = validateBlueprintV2({ + version: 2, + plugins: ['jetpack'], + themes: ['twentytwentyfour'], + muPlugins: [], + media: [], + content: [], + users: [], + roles: [], + siteOptions: { blogname: 'Test' }, + constants: { WP_DEBUG: true }, + siteLanguage: 'en_US', + phpVersion: '8.1', + wordpressVersion: '6.4', + applicationOptions: { + 'wordpress-playground': { + landingPage: '/wp-admin', + login: true, + networkAccess: false, + }, + }, + blueprintMeta: { + name: 'Test Blueprint', + }, + additionalStepsAfterExecution: [ + { step: 'runPHP', code: ' { + const result = validateBlueprintV2({ + version: 2, + additionalStepsAfterExecution: [{ step: 'intallPlugi' }], + }); + expect(result.valid).toBe(false); + expect(result.errors.length).toBe(1); + expect(result.errors[0]).toContain('Unknown step name "intallPlugi"'); + expect(result.errors[0]).toContain('Did you mean "installPlugin"?'); + }); + + it('should reject non-object values', () => { + expect(validateBlueprintV2(null).valid).toBe(false); + expect(validateBlueprintV2(42).valid).toBe(false); + expect(validateBlueprintV2('hello').valid).toBe(false); + }); + + it('should report type errors for incorrectly typed properties', () => { + const result = validateBlueprintV2({ + version: 2, + plugins: 'not-an-array', + siteOptions: [], + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining('"plugins" must be an array'), + expect.stringContaining('"siteOptions" must be an object'), + ]) + ); + }); + + it('should accept valid step names without errors', () => { + const result = validateBlueprintV2({ + version: 2, + additionalStepsAfterExecution: [ + { step: 'installPlugin' }, + { step: 'activatePlugin' }, + { step: 'runPHP' }, + { step: 'wp-cli' }, + ], + }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should accept phpVersion as an object', () => { + const result = validateBlueprintV2({ + version: 2, + phpVersion: { min: '8.0', recommended: '8.2' }, + }); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should reject phpVersion as a number', () => { + const result = validateBlueprintV2({ + version: 2, + phpVersion: 8.1, + }); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([ + expect.stringContaining( + '"phpVersion" must be a string or an object' + ), + ]); + }); +}); diff --git a/packages/playground/blueprints/src/lib/v2/compile/validate.ts b/packages/playground/blueprints/src/lib/v2/compile/validate.ts new file mode 100644 index 00000000000..5af351ca5f4 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/compile/validate.ts @@ -0,0 +1,251 @@ +/** + * Known V2 step names — the set of step discriminators defined in + * the Blueprint V2 schema's `Step` union type. + */ +export const KNOWN_V2_STEP_NAMES = new Set([ + 'defineConstants', + 'setSiteOptions', + 'installPlugin', + 'activatePlugin', + 'installTheme', + 'activateTheme', + 'importThemeStarterContent', + 'importContent', + 'importMedia', + 'runPHP', + 'runSQL', + 'wp-cli', + 'writeFiles', + 'cp', + 'mv', + 'mkdir', + 'rm', + 'rmdir', + 'unzip', + 'setSiteLanguage', +]); + +export interface BlueprintValidationV2Result { + valid: boolean; + errors: string[]; +} + +/** + * Validates an unknown value as a V2 Blueprint declaration. + * + * Performs structural checks — not full JSON Schema validation — + * to catch common authoring mistakes with actionable messages. + */ +export function validateBlueprintV2( + blueprint: unknown +): BlueprintValidationV2Result { + const errors: string[] = []; + + if (!isRecord(blueprint)) { + errors.push('Blueprint must be a JSON object.'); + return { valid: false, errors }; + } + + validateVersion(blueprint, errors); + validateTopLevelTypes(blueprint, errors); + validateStepNames(blueprint, errors); + + return { valid: errors.length === 0, errors }; +} + +// ------------------------------------------------------------------ +// Internal validation helpers +// ------------------------------------------------------------------ + +function validateVersion( + blueprint: Record, + errors: string[] +): void { + if (!('version' in blueprint)) { + errors.push( + 'Missing required property "version". ' + + 'A V2 blueprint must include "version": 2.' + ); + return; + } + if (blueprint.version !== 2) { + errors.push( + `Invalid version: expected 2, got ${JSON.stringify(blueprint.version)}.` + ); + } +} + +function validateTopLevelTypes( + blueprint: Record, + errors: string[] +): void { + validateOptionalType(blueprint, 'plugins', 'array', errors); + validateOptionalType(blueprint, 'themes', 'array', errors); + validateOptionalType(blueprint, 'muPlugins', 'array', errors); + validateOptionalType(blueprint, 'media', 'array', errors); + validateOptionalType(blueprint, 'content', 'array', errors); + validateOptionalType(blueprint, 'users', 'array', errors); + validateOptionalType(blueprint, 'roles', 'array', errors); + validateOptionalType( + blueprint, + 'additionalStepsAfterExecution', + 'array', + errors + ); + validateOptionalType(blueprint, 'siteOptions', 'object', errors); + validateOptionalType(blueprint, 'constants', 'object', errors); + validateOptionalType(blueprint, 'applicationOptions', 'object', errors); + validateOptionalType(blueprint, 'blueprintMeta', 'object', errors); + validateOptionalType(blueprint, 'postTypes', 'object', errors); + validateOptionalType(blueprint, 'fonts', 'object', errors); + validateOptionalStringOrObject(blueprint, 'phpVersion', errors); + validateOptionalStringOrObject(blueprint, 'wordpressVersion', errors); + validateOptionalStringLike(blueprint, 'siteLanguage', errors); +} + +function validateStepNames( + blueprint: Record, + errors: string[] +): void { + const steps = blueprint.additionalStepsAfterExecution; + if (!Array.isArray(steps)) { + return; + } + for (const entry of steps) { + if (!isRecord(entry) || typeof entry.step !== 'string') { + continue; + } + const name = entry.step; + if (!KNOWN_V2_STEP_NAMES.has(name)) { + const suggestion = findClosestStepName(name); + const hint = suggestion ? ` Did you mean "${suggestion}"?` : ''; + errors.push(`Unknown step name "${name}".${hint}`); + } + } +} + +// ------------------------------------------------------------------ +// Property-type assertion helpers +// ------------------------------------------------------------------ + +function validateOptionalType( + obj: Record, + key: string, + expected: 'array' | 'object', + errors: string[] +): void { + if (!(key in obj) || obj[key] === undefined) { + return; + } + const value = obj[key]; + if (expected === 'array' && !Array.isArray(value)) { + errors.push(`Property "${key}" must be an array.`); + } else if ( + expected === 'object' && + (typeof value !== 'object' || value === null || Array.isArray(value)) + ) { + errors.push(`Property "${key}" must be an object.`); + } +} + +function validateOptionalStringOrObject( + obj: Record, + key: string, + errors: string[] +): void { + if (!(key in obj) || obj[key] === undefined) { + return; + } + const value = obj[key]; + if ( + typeof value !== 'string' && + (typeof value !== 'object' || value === null || Array.isArray(value)) + ) { + errors.push(`Property "${key}" must be a string or an object.`); + } +} + +function validateOptionalStringLike( + obj: Record, + key: string, + errors: string[] +): void { + if (!(key in obj) || obj[key] === undefined) { + return; + } + if (typeof obj[key] !== 'string') { + errors.push(`Property "${key}" must be a string.`); + } +} + +// ------------------------------------------------------------------ +// Fuzzy matching +// ------------------------------------------------------------------ + +/** + * Finds the closest known step name to the given input using + * Levenshtein distance. Returns `undefined` when no name is + * close enough (distance > half the target length). + */ +function findClosestStepName(input: string): string | undefined { + let best: string | undefined; + let bestDistance = Infinity; + + for (const candidate of KNOWN_V2_STEP_NAMES) { + const d = levenshteinDistance( + input.toLowerCase(), + candidate.toLowerCase() + ); + if (d < bestDistance) { + bestDistance = d; + best = candidate; + } + } + + // Only suggest if the distance is reasonable — at most + // half the longer string's length. + const maxLen = Math.max(input.length, best?.length ?? 0); + if (bestDistance <= Math.ceil(maxLen / 2)) { + return best; + } + return undefined; +} + +/** + * Standard Levenshtein distance between two strings. + */ +function levenshteinDistance(a: string, b: string): number { + const m = a.length; + const n = b.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => + new Array(n + 1).fill(0) + ); + + for (let i = 0; i <= m; i++) { + dp[i][0] = i; + } + for (let j = 0; j <= n; j++) { + dp[0][j] = j; + } + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + dp[i][j] = Math.min( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + cost + ); + } + } + + return dp[m][n]; +} + +// ------------------------------------------------------------------ +// Utility +// ------------------------------------------------------------------ + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/playground/blueprints/src/lib/v2/data-references/resolver.spec.ts b/packages/playground/blueprints/src/lib/v2/data-references/resolver.spec.ts new file mode 100644 index 00000000000..24c7932d2b6 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/data-references/resolver.spec.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DataReferenceResolverImpl } from './resolver'; +import { + isInlineFile, + isInlineDirectory, + isUrlReference, + isExecutionContextPath, + isGitPath, + parseSlugWithVersion, + normalizePath, +} from './resolver'; +import { DataReferenceResolutionError } from '../types'; + +describe('DataReferenceResolverImpl', () => { + let resolver: DataReferenceResolverImpl; + + beforeEach(() => { + resolver = new DataReferenceResolverImpl(); + vi.restoreAllMocks(); + }); + + describe('resolveFile', () => { + it('resolves an inline file reference', async () => { + const ref = { + filename: 'hello.php', + content: '', + }; + const result = await resolver.resolveFile(ref); + expect(result.name).toBe('hello.php'); + expect(new TextDecoder().decode(result.contents)).toBe( + '' + ); + }); + + it('resolves a URL reference', async () => { + const body = new Uint8Array([1, 2, 3, 4]); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(body, { status: 200 }) + ); + + const result = await resolver.resolveFile( + 'https://example.com/archive.zip' + ); + expect(result.name).toBe('archive.zip'); + expect(result.contents).toEqual(body); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://example.com/archive.zip' + ); + }); + + it('uses corsProxy when configured', async () => { + resolver = new DataReferenceResolverImpl({ + corsProxy: 'https://proxy.example.com/', + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(new Uint8Array([5]), { status: 200 }) + ); + + await resolver.resolveFile('https://example.com/file.zip'); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://proxy.example.com/' + 'https://example.com/file.zip' + ); + }); + + it('throws DataReferenceResolutionError on fetch failure', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { + status: 404, + statusText: 'Not Found', + }) + ); + await expect( + resolver.resolveFile('https://example.com/missing.zip') + ).rejects.toThrow(DataReferenceResolutionError); + }); + + it('resolves an execution context path', async () => { + const fileContents = new Uint8Array([10, 20, 30]); + resolver = new DataReferenceResolverImpl({ + executionContext: { + readFileAsBuffer: vi.fn().mockResolvedValue(fileContents), + listFiles: vi.fn(), + }, + }); + const result = await resolver.resolveFile('./my-plugin.zip'); + expect(result.name).toBe('my-plugin.zip'); + expect(result.contents).toEqual(fileContents); + }); + + it('throws when execution context is missing for path ref', async () => { + await expect( + resolver.resolveFile('./some-file.txt') + ).rejects.toThrow(DataReferenceResolutionError); + }); + + it('throws for git path references (not yet supported)', async () => { + const ref = { + gitRepository: 'https://github.com/example/repo' as const, + }; + await expect(resolver.resolveFile(ref)).rejects.toThrow( + DataReferenceResolutionError + ); + }); + }); + + describe('resolveDirectory', () => { + it('resolves an inline directory reference', async () => { + const ref = { + directoryName: 'my-plugin', + files: { + 'index.php': '; + }; + expect(subdir.name).toBe('lib'); + expect( + new TextDecoder().decode(subdir.files['util.php'] as Uint8Array) + ).toBe(' { + it('resolves a simple plugin slug', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(new Uint8Array([1]), { status: 200 }) + ); + const result = await resolver.resolvePluginReference('jetpack'); + expect(result.name).toBe('jetpack.zip'); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://downloads.wordpress.org/plugin/jetpack.zip' + ); + }); + + it('resolves a versioned plugin slug', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(new Uint8Array([2]), { status: 200 }) + ); + const result = + await resolver.resolvePluginReference('jetpack@6.4.3'); + expect(result.name).toBe('jetpack.6.4.3.zip'); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://downloads.wordpress.org/plugin/' + 'jetpack.6.4.3.zip' + ); + }); + }); + + describe('resolveThemeReference', () => { + it('resolves a simple theme slug', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(new Uint8Array([3]), { status: 200 }) + ); + const result = + await resolver.resolveThemeReference('twentytwentyfour'); + expect(result.name).toBe('twentytwentyfour.zip'); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://downloads.wordpress.org/theme/' + + 'twentytwentyfour.zip' + ); + }); + + it('resolves a versioned theme slug', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(new Uint8Array([4]), { status: 200 }) + ); + const result = + await resolver.resolveThemeReference('adventurer@4.6.0'); + expect(result.name).toBe('adventurer.4.6.0.zip'); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://downloads.wordpress.org/theme/' + + 'adventurer.4.6.0.zip' + ); + }); + }); +}); + +describe('type guard helpers', () => { + it('isInlineFile detects inline files', () => { + expect(isInlineFile({ filename: 'a.txt', content: 'hello' })).toBe( + true + ); + expect(isInlineFile('https://example.com' as any)).toBe(false); + expect( + isInlineFile({ + directoryName: 'd', + files: {}, + } as any) + ).toBe(false); + }); + + it('isInlineDirectory detects inline directories', () => { + expect( + isInlineDirectory({ + directoryName: 'dir', + files: {}, + }) + ).toBe(true); + expect( + isInlineDirectory({ + filename: 'a.txt', + content: '', + } as any) + ).toBe(false); + }); + + it('isUrlReference detects HTTP(S) URLs', () => { + expect(isUrlReference('https://example.com/f.zip')).toBe(true); + expect(isUrlReference('http://example.com/f.zip')).toBe(true); + expect(isUrlReference('./local-file.zip')).toBe(false); + expect(isUrlReference('/absolute-path')).toBe(false); + }); + + it('isExecutionContextPath detects ./ and / paths', () => { + expect(isExecutionContextPath('./file.txt')).toBe(true); + expect(isExecutionContextPath('/file.txt')).toBe(true); + expect(isExecutionContextPath('https://example.com')).toBe(false); + expect(isExecutionContextPath('slug-name')).toBe(false); + }); + + it('isGitPath detects git repository references', () => { + expect( + isGitPath({ + gitRepository: 'https://github.com/x/y', + } as any) + ).toBe(true); + expect( + isGitPath({ + filename: 'a.txt', + content: '', + } as any) + ).toBe(false); + }); +}); + +describe('parseSlugWithVersion', () => { + it('parses unversioned slug', () => { + expect(parseSlugWithVersion('jetpack')).toEqual({ + name: 'jetpack', + version: undefined, + }); + }); + + it('parses versioned slug', () => { + expect(parseSlugWithVersion('jetpack@6.4.3')).toEqual({ + name: 'jetpack', + version: '6.4.3', + }); + }); + + it('parses slug with two-part version', () => { + expect(parseSlugWithVersion('akismet@5.3')).toEqual({ + name: 'akismet', + version: '5.3', + }); + }); +}); + +describe('normalizePath', () => { + it('strips ./ prefix', () => { + expect(normalizePath('./my-file.txt')).toBe('my-file.txt'); + }); + + it('strips / prefix', () => { + expect(normalizePath('/my-file.txt')).toBe('my-file.txt'); + }); + + it('throws on .. traversal', () => { + expect(() => normalizePath('./../escape')).toThrow( + DataReferenceResolutionError + ); + }); + + it('throws on embedded .. traversal', () => { + expect(() => normalizePath('./dir/../../escape')).toThrow( + DataReferenceResolutionError + ); + }); +}); diff --git a/packages/playground/blueprints/src/lib/v2/data-references/resolver.ts b/packages/playground/blueprints/src/lib/v2/data-references/resolver.ts new file mode 100644 index 00000000000..cb6976af7e1 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/data-references/resolver.ts @@ -0,0 +1,307 @@ +import { Semaphore, basename } from '@php-wasm/util'; +import type { + DataReferenceResolver, + ResolvedFile, + ResolvedDirectory, + ExecutionContextBackend, +} from '../types'; +import { DataReferenceResolutionError } from '../types'; +import type { DataSources } from '../wep-1-blueprint-v2-schema/appendix-B-data-sources'; +import type { DataReferenceResolverConfig } from './types'; + +const WORDPRESS_PLUGIN_DOWNLOAD_URL = 'https://downloads.wordpress.org/plugin/'; +const WORDPRESS_THEME_DOWNLOAD_URL = 'https://downloads.wordpress.org/theme/'; + +/** + * Default concurrency limiter used when none is provided. + */ +const DEFAULT_SEMAPHORE = new Semaphore({ concurrency: 5 }); + +/** + * Resolves V2 data references into concrete file and directory + * contents. Handles URLs, execution-context paths, inline + * content, git repository paths, and WordPress.org slugs. + */ +export class DataReferenceResolverImpl implements DataReferenceResolver { + private semaphore: Semaphore; + private corsProxy: string; + private executionContext?: ExecutionContextBackend; + + constructor(config: DataReferenceResolverConfig = {}) { + this.semaphore = config.semaphore ?? DEFAULT_SEMAPHORE; + this.corsProxy = config.corsProxy ?? ''; + this.executionContext = config.executionContext; + } + + async resolveFile(ref: DataSources.DataReference): Promise { + if (isInlineFile(ref)) { + return resolveInlineFile(ref); + } + if (typeof ref === 'string' && isUrlReference(ref)) { + return this.fetchUrl(ref); + } + if (typeof ref === 'string' && isExecutionContextPath(ref)) { + return this.readExecutionContextFile(ref); + } + if (typeof ref === 'object' && isGitPath(ref)) { + throw new DataReferenceResolutionError( + JSON.stringify(ref), + 'Git repository references are not yet supported' + ); + } + throw new DataReferenceResolutionError( + typeof ref === 'string' ? ref : JSON.stringify(ref), + 'Unrecognized data reference type' + ); + } + + async resolveDirectory( + ref: DataSources.DataReference + ): Promise { + if (isInlineDirectory(ref)) { + return resolveInlineDirectory(ref); + } + if (typeof ref === 'string' && isExecutionContextPath(ref)) { + return this.readExecutionContextDirectory(ref); + } + throw new DataReferenceResolutionError( + typeof ref === 'string' ? ref : JSON.stringify(ref), + 'Cannot resolve reference as a directory' + ); + } + + /** + * Resolves a WordPress.org plugin slug (optionally + * versioned) to a downloaded zip file. + * + * @param slug - e.g. "jetpack" or "jetpack@6.4.3" + */ + async resolvePluginReference( + slug: DataSources.PluginDirectoryReference + ): Promise { + const { name, version } = parseSlugWithVersion(slug); + const versionSuffix = version ? `.${version}` : ''; + const url = + `${WORDPRESS_PLUGIN_DOWNLOAD_URL}` + `${name}${versionSuffix}.zip`; + return this.fetchUrl(url); + } + + /** + * Resolves a WordPress.org theme slug (optionally versioned) + * to a downloaded zip file. + * + * @param slug - e.g. "twentytwentyfour" or "adventurer@4.6.0" + */ + async resolveThemeReference( + slug: DataSources.ThemeDirectoryReference + ): Promise { + const { name, version } = parseSlugWithVersion(slug); + const versionSuffix = version ? `.${version}` : ''; + const url = + `${WORDPRESS_THEME_DOWNLOAD_URL}` + `${name}${versionSuffix}.zip`; + return this.fetchUrl(url); + } + + private async fetchUrl(url: string): Promise { + const effectiveUrl = this.corsProxy ? `${this.corsProxy}${url}` : url; + return this.semaphore.run(async () => { + const response = await fetch(effectiveUrl); + if (!response.ok) { + throw new DataReferenceResolutionError( + url, + `Failed to fetch ${url}: ` + + `${response.status} ${response.statusText}` + ); + } + const buffer = await response.arrayBuffer(); + const name = fileNameFromUrl(url); + return { name, contents: new Uint8Array(buffer) }; + }); + } + + private async readExecutionContextFile( + path: string + ): Promise { + if (!this.executionContext) { + throw new DataReferenceResolutionError( + path, + 'No execution context backend available ' + + 'to resolve path references' + ); + } + const normalized = normalizePath(path); + const contents = + await this.executionContext.readFileAsBuffer(normalized); + const name = basename(normalized); + return { name, contents }; + } + + private async readExecutionContextDirectory( + path: string + ): Promise { + if (!this.executionContext) { + throw new DataReferenceResolutionError( + path, + 'No execution context backend available ' + + 'to resolve path references' + ); + } + const normalized = normalizePath(path); + const entries = await this.executionContext.listFiles(normalized); + const files: Record = {}; + for (const entry of entries) { + const entryPath = `${normalized}/${entry}`; + try { + const contents = + await this.executionContext.readFileAsBuffer(entryPath); + files[entry] = contents; + } catch { + // If reading as file fails, try as directory. + files[entry] = + await this.readExecutionContextDirectory(entryPath); + } + } + return { name: basename(normalized), files }; + } +} + +// ----------------------------------------------------------------- +// Type guard helpers (exported for testing) +// ----------------------------------------------------------------- + +/** + * Checks whether a reference matches the InlineFile shape: + * `{ filename, content }`. + */ +export function isInlineFile( + ref: DataSources.DataReference +): ref is DataSources.InlineFile { + return ( + typeof ref === 'object' && + ref !== null && + 'filename' in ref && + 'content' in ref + ); +} + +/** + * Checks whether a reference matches the InlineDirectory shape: + * `{ directoryName, files }`. + */ +export function isInlineDirectory( + ref: DataSources.DataReference +): ref is DataSources.InlineDirectory { + return ( + typeof ref === 'object' && + ref !== null && + 'directoryName' in ref && + 'files' in ref + ); +} + +/** + * Checks whether a string reference is an HTTP or HTTPS URL. + */ +export function isUrlReference(ref: string): ref is DataSources.URLReference { + return ref.startsWith('http://') || ref.startsWith('https://'); +} + +/** + * Checks whether a string reference is a path within the + * blueprint execution context (starts with `./` or `/`). + */ +export function isExecutionContextPath( + ref: string +): ref is DataSources.ExecutionContextPath { + return ref.startsWith('./') || ref.startsWith('/'); +} + +/** + * Checks whether a reference matches the GitPath shape: + * `{ gitRepository }`. + */ +export function isGitPath( + ref: DataSources.DataReference +): ref is DataSources.GitPath { + return typeof ref === 'object' && ref !== null && 'gitRepository' in ref; +} + +/** + * Splits a versioned slug like `"jetpack@6.4.3"` into name and + * version components. Returns `{ name, version: undefined }` for + * unversioned slugs like `"jetpack"`. + */ +export function parseSlugWithVersion(slug: string): { + name: string; + version: string | undefined; +} { + const atIndex = slug.indexOf('@'); + if (atIndex === -1) { + return { name: slug, version: undefined }; + } + return { + name: slug.substring(0, atIndex), + version: slug.substring(atIndex + 1), + }; +} + +/** + * Normalizes an execution-context path by stripping a leading + * `./` prefix and preventing `../` traversal attempts. + * + * @param path - A path starting with `./` or `/`. + * @returns A cleaned path relative to the context root. + * @throws DataReferenceResolutionError on traversal attempts. + */ +export function normalizePath(path: string): string { + let normalized = path; + if (normalized.startsWith('./')) { + normalized = normalized.substring(2); + } else if (normalized.startsWith('/')) { + normalized = normalized.substring(1); + } + if (normalized.includes('..')) { + throw new DataReferenceResolutionError( + path, + 'Path traversal via ".." is not allowed' + ); + } + return normalized; +} + +// ----------------------------------------------------------------- +// Internal helpers +// ----------------------------------------------------------------- + +function resolveInlineFile(ref: DataSources.InlineFile): ResolvedFile { + const encoder = new TextEncoder(); + return { + name: ref.filename, + contents: encoder.encode(ref.content), + }; +} + +function resolveInlineDirectory( + ref: DataSources.InlineDirectory +): ResolvedDirectory { + const encoder = new TextEncoder(); + const files: Record = {}; + for (const [key, value] of Object.entries(ref.files)) { + if (typeof value === 'string') { + files[key] = encoder.encode(value); + } else { + files[key] = resolveInlineDirectory(value); + } + } + return { name: ref.directoryName, files }; +} + +function fileNameFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname; + const name = basename(pathname); + return name || 'download'; + } catch { + return 'download'; + } +} diff --git a/packages/playground/blueprints/src/lib/v2/data-references/types.ts b/packages/playground/blueprints/src/lib/v2/data-references/types.ts new file mode 100644 index 00000000000..956239c8272 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/data-references/types.ts @@ -0,0 +1,8 @@ +import type { Semaphore } from '@php-wasm/util'; +import type { ExecutionContextBackend } from '../types'; + +export interface DataReferenceResolverConfig { + semaphore?: Semaphore; + corsProxy?: string; + executionContext?: ExecutionContextBackend; +} diff --git a/packages/playground/blueprints/src/lib/v2/get-v2-runner.ts b/packages/playground/blueprints/src/lib/v2/get-v2-runner.ts index f1ae813799f..5664d9544cd 100644 --- a/packages/playground/blueprints/src/lib/v2/get-v2-runner.ts +++ b/packages/playground/blueprints/src/lib/v2/get-v2-runner.ts @@ -1,15 +1,3 @@ -export async function getV2Runner(): Promise { - /** - * Dynamically read the file on demand. - * - * In production, this is encoded as base64 which increases the file size by ~30%. This is - * not ideal, but there's no other standard solution for shipping static files with isomorphic - * npm CJS+ESM packages so this will have to do until a better solution emerges. - */ - const blueprintsPharBytes = // @ts-ignore - (await import('../../../blueprints.phar?base64')).default; - - return new File([blueprintsPharBytes], `blueprints.phar`, { - type: 'application/zip', - }); -} +// This file has been intentionally emptied. +// The PHP .phar runner has been replaced by the TypeScript +// compilation pipeline (compileBlueprintV2). diff --git a/packages/playground/blueprints/src/lib/v2/index.ts b/packages/playground/blueprints/src/lib/v2/index.ts new file mode 100644 index 00000000000..df2823dca5e --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/index.ts @@ -0,0 +1,42 @@ +// V2 types +// Note: BlueprintV2Declaration is intentionally NOT re-exported +// here — it comes from blueprint-v2-declaration.ts to avoid +// duplicate identifier errors in DTS bundle generation. +export type { + CompiledBlueprintV2, + CompiledBlueprintV2RunOptions, + CompiledV2Step, + CompileBlueprintV2Options, + V2RuntimeConfig, + V2VersionConstraint, + V2StepHandler, + StepExecutionContext, + StepProgressHints, + DataReferenceResolver, + ResolvedFile, + ResolvedDirectory, + ExecutionContextBackend, +} from './types'; + +export { + InvalidBlueprintV2Error, + BlueprintV2StepExecutionError, + DataReferenceResolutionError, + BlueprintMergeConflictError, +} from './types'; + +// V2 compilation +export { compileBlueprintV2, extractRuntimeConfig } from './compile/compile'; + +// V1 → V2 transpilation +export { transpileV1toV2 } from './compile/v1-to-v2-transpiler'; + +// V2 blueprint composition +export { mergeBlueprintsV2 } from './compile/merge'; + +// V2 validation +export { validateBlueprintV2, KNOWN_V2_STEP_NAMES } from './compile/validate'; +export type { BlueprintValidationV2Result } from './compile/validate'; + +// V2 step handlers (for extensibility) +export { v2StepHandlers, registerV2StepHandler } from './steps/index'; diff --git a/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts b/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts index 8899efdd2b2..5664d9544cd 100644 --- a/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts +++ b/packages/playground/blueprints/src/lib/v2/run-blueprint-v2.ts @@ -1,227 +1,3 @@ -import { logger } from '@php-wasm/logger'; -import { - type StreamedPHPResponse, - type UniversalPHP, -} from '@php-wasm/universal'; -import { phpVar } from '@php-wasm/util'; -import { getV2Runner } from './get-v2-runner'; -import { - type RawBlueprintV2Data, - type ParsedBlueprintV1orV2String, - parseBlueprintDeclaration, -} from './blueprint-v2-declaration'; -import type { BlueprintV1Declaration } from '../v1/types'; - -export type PHPExceptionDetails = { - exception: string; - message: string; - file: string; - line: number; - trace: string; -}; - -export type BlueprintMessage = - | { type: 'blueprint.target_resolved' } - | { type: 'blueprint.progress'; progress: number; caption: string } - | { - type: 'blueprint.error'; - message: string; - details?: PHPExceptionDetails; - } - | { type: 'blueprint.completion'; message: string }; - -interface RunV2Options { - php: UniversalPHP; - cliArgs?: string[]; - blueprint: - | RawBlueprintV2Data - | ParsedBlueprintV1orV2String - | BlueprintV1Declaration; - blueprintOverrides?: { - wordpressVersion?: string; - additionalSteps?: any[]; - }; - onMessage?: (message: BlueprintMessage) => void | Promise; -} - -export async function runBlueprintV2( - options: RunV2Options -): Promise { - const cliArgs = options.cliArgs || []; - for (const arg of cliArgs) { - if (arg.startsWith('--site-path=')) { - throw new Error( - 'The --site-path CLI argument must not be provided. In Playground, it is always set to /wordpress.' - ); - } - } - cliArgs.push('--site-path=/wordpress'); - - /** - * Divergence from blueprints.phar – the default database engine is - * SQLite. Why? Because in Playground we'll use SQLite far more often than - * MySQL. - */ - const dbEngine = cliArgs.find((arg) => arg.startsWith('--db-engine=')); - if (!dbEngine) { - cliArgs.push('--db-engine=sqlite'); - } - - const php = options.php; - const onMessage = options?.onMessage || (() => {}); - - const file = await getV2Runner(); - php.writeFile( - '/tmp/blueprints.phar', - new Uint8Array(await file.arrayBuffer()) - ); - - const parsedBlueprintDeclaration = parseBlueprintDeclaration( - options.blueprint - ); - let blueprintReference = ''; - switch (parsedBlueprintDeclaration.type) { - case 'inline-file': - php.writeFile( - '/tmp/blueprint.json', - parsedBlueprintDeclaration.contents - ); - blueprintReference = '/tmp/blueprint.json'; - break; - case 'file-reference': - blueprintReference = parsedBlueprintDeclaration.reference; - break; - } - - const unbindMessageListener = await php.onMessage(async (message) => { - try { - const parsed = - typeof message === 'string' ? JSON.parse(message) : message; - if (!parsed) { - return; - } - - // Make sure stdout and stderr data is emited before the next message is processed. - // Otherwise a code such as `echo "Hello"; post_message_to_js(json_encode([ - // 'type' => 'blueprint.error', - // 'message' => 'Error' - // ]));` - // might emit the message before we process the stdout data. - // - // This is a workaround to ensure that the message is emitted after the stdout data is processed. - // @TODO: Remove this workaround. Find the root cause why stdout data is delayed and address it - // directly. - await new Promise((resolve) => setTimeout(resolve, 0)); - - if (parsed.type.startsWith('blueprint.')) { - await onMessage(parsed); - } - } catch (e) { - logger.warn('Failed to parse message as JSON:', message, e); - } - }); - - /** - * Prepare hooks, filters, and run the Blueprint: - */ - await php?.writeFile( - '/tmp/run-blueprints.php', - ` 'sockets', - ]); -} -playground_add_filter('blueprint.http_client', 'playground_http_client_factory'); - -function playground_on_blueprint_target_resolved() { - post_message_to_js(json_encode([ - 'type' => 'blueprint.target_resolved', - ])); -} -playground_add_filter('blueprint.target_resolved', 'playground_on_blueprint_target_resolved'); - -playground_add_filter('blueprint.resolved', 'playground_on_blueprint_resolved'); -function playground_on_blueprint_resolved($blueprint) { - $additional_blueprint_steps = json_decode(${phpVar( - JSON.stringify(options.blueprintOverrides?.additionalSteps || []) - )}, true); - if(count($additional_blueprint_steps) > 0) { - $blueprint['additionalStepsAfterExecution'] = array_merge( - $blueprint['additionalStepsAfterExecution'] ?? [], - $additional_blueprint_steps - ); - } - - $wp_version_override = json_decode(${phpVar( - JSON.stringify(options.blueprintOverrides?.wordpressVersion || null) - )}, true); - if($wp_version_override) { - $blueprint['wordpressVersion'] = $wp_version_override; - } - return $blueprint; -} - -function playground_progress_reporter() { - class PlaygroundProgressReporter implements ProgressReporter { - - public function reportProgress(float $progress, string $caption): void { - $this->writeJsonMessage([ - 'type' => 'blueprint.progress', - 'progress' => round($progress, 2), - 'caption' => $caption - ]); - } - - public function reportError(string $message, ?Throwable $exception = null): void { - $errorData = [ - 'type' => 'blueprint.error', - 'message' => $message - ]; - - if ($exception) { - $errorData['details'] = [ - 'exception' => get_class($exception), - 'message' => $exception->getMessage(), - 'file' => $exception->getFile(), - 'line' => $exception->getLine(), - 'trace' => $exception->getTraceAsString() - ]; - } - - $this->writeJsonMessage($errorData); - } - - public function reportCompletion(string $message): void { - $this->writeJsonMessage([ - 'type' => 'blueprint.completion', - 'message' => $message - ]); - } - - public function close(): void {} - - private function writeJsonMessage(array $data): void { - post_message_to_js(json_encode($data)); - } - } - return new PlaygroundProgressReporter(); -} -playground_add_filter('blueprint.progress_reporter', 'playground_progress_reporter'); -require( "/tmp/blueprints.phar" ); -` - ); - const streamedResponse = (await (php as any).cli([ - '/internal/shared/bin/php', - '/tmp/run-blueprints.php', - 'exec', - blueprintReference, - ...cliArgs, - ])) as StreamedPHPResponse; - - streamedResponse.finished.finally(unbindMessageListener); - - return streamedResponse; -} +// This file has been intentionally emptied. +// The PHP .phar runner has been replaced by the TypeScript +// compilation pipeline (compileBlueprintV2). diff --git a/packages/playground/blueprints/src/lib/v2/steps/activate-plugin.ts b/packages/playground/blueprints/src/lib/v2/steps/activate-plugin.ts new file mode 100644 index 00000000000..cb4469f224d --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/activate-plugin.ts @@ -0,0 +1,43 @@ +import type { V2StepHandler } from '../types'; +import { phpVar } from '@php-wasm/util'; +import { registerV2StepHandler } from './index'; + +interface ActivatePluginArgs { + pluginPath: string; +} + +/** + * Activates an already-installed WordPress plugin. + * + * The pluginPath should be either an absolute path to the + * plugin directory or the plugin entry file relative to the + * plugins directory (e.g. "plugin-name/plugin-name.php"). + */ +const handler: V2StepHandler = async (args, context) => { + const { php } = context; + const docroot = await php.documentRoot; + + await php.run({ + code: ` 'Administrator'))[0]->ID +); + +$plugin_path = ${phpVar(args.pluginPath)}; +$response = activate_plugin($plugin_path); + +if (is_wp_error($response)) { + throw new Exception( + 'Failed to activate plugin: ' . + $response->get_error_message() + ); +} +`, + }); +}; + +registerV2StepHandler('activatePlugin', handler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/activate-theme.ts b/packages/playground/blueprints/src/lib/v2/steps/activate-theme.ts new file mode 100644 index 00000000000..d5dd81cc20d --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/activate-theme.ts @@ -0,0 +1,52 @@ +import type { V2StepHandler } from '../types'; +import { joinPaths, phpVar } from '@php-wasm/util'; +import { registerV2StepHandler } from './index'; + +interface ActivateThemeArgs { + themeDirectoryName: string; +} + +/** + * Activates an already-installed WordPress theme by its + * folder name inside wp-content/themes/. + */ +const handler: V2StepHandler = async (args, context) => { + const { php } = context; + const docroot = await php.documentRoot; + const themeFolderName = args.themeDirectoryName; + + const themeFolderPath = joinPaths( + docroot, + 'wp-content', + 'themes', + themeFolderName + ); + if (!(await php.fileExists(themeFolderPath))) { + throw new Error( + `Cannot activate theme "${themeFolderName}": ` + + `not found at ${themeFolderPath}` + ); + } + + await php.run({ + code: ` 'Administrator'))[0]->ID +); + +switch_theme(${phpVar(themeFolderName)}); + +if (wp_get_theme()->get_stylesheet() !== ${phpVar(themeFolderName)}) { + throw new Exception( + 'Theme ' . ${phpVar(themeFolderName)} . + ' could not be activated.' + ); +} +`, + }); +}; + +registerV2StepHandler('activateTheme', handler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/define-constants.ts b/packages/playground/blueprints/src/lib/v2/steps/define-constants.ts new file mode 100644 index 00000000000..1ad3f228a8b --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/define-constants.ts @@ -0,0 +1,24 @@ +import type { V2StepHandler } from '../types'; +import { registerV2StepHandler } from './index'; +import { joinPaths } from '@php-wasm/util'; +import { defineWpConfigConstants } from '@wp-playground/wordpress'; + +/** + * Defines PHP constants in wp-config.php by rewriting the file. + * + * Uses the `WP_Config_Transformer` utility (via + * `defineWpConfigConstants` from `@wp-playground/wordpress`) to + * safely insert or update `define()` calls in wp-config.php. + * Existing constants are updated in place; new constants are + * added before the "stop editing" marker. + */ +export const defineConstantsHandler: V2StepHandler = async (args, context) => { + const { constants } = args as { + constants: Record; + }; + const documentRoot = await context.php.documentRoot; + const wpConfigPath = joinPaths(documentRoot, 'wp-config.php'); + await defineWpConfigConstants(context.php, wpConfigPath, constants); +}; + +registerV2StepHandler('defineConstants', defineConstantsHandler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/filesystem.ts b/packages/playground/blueprints/src/lib/v2/steps/filesystem.ts new file mode 100644 index 00000000000..19efc1f3d76 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/filesystem.ts @@ -0,0 +1,140 @@ +import type { V2StepHandler } from '../types'; +import { registerV2StepHandler } from './index'; +import { joinPaths, phpVar } from '@php-wasm/util'; + +/** + * Resolves a `site:` prefixed path to an absolute path on the + * virtual filesystem. Paths that start with `site:` are relative + * to the WordPress document root. + */ +function resolveSitePath(path: string, documentRoot: string): string { + if (path.startsWith('site:')) { + return joinPaths(documentRoot, path.slice(5)); + } + return path; +} + +/** + * Copies a file from one location to another using PHP's + * `copy()` function. + */ +export const cpHandler: V2StepHandler = async (args, context) => { + const { fromPath, toPath } = args as { fromPath: string; toPath: string }; + const documentRoot = await context.php.documentRoot; + const from = resolveSitePath(fromPath, documentRoot); + const to = resolveSitePath(toPath, documentRoot); + + const result = await context.php.run({ + code: ` { + const { fromPath, toPath } = args as { fromPath: string; toPath: string }; + const documentRoot = await context.php.documentRoot; + const from = resolveSitePath(fromPath, documentRoot); + const to = resolveSitePath(toPath, documentRoot); + + const result = await context.php.run({ + code: ` { + const { path } = args as { path: string }; + const documentRoot = await context.php.documentRoot; + const resolved = resolveSitePath(path, documentRoot); + + const result = await context.php.run({ + code: ` { + const { path } = args as { path: string }; + const documentRoot = await context.php.documentRoot; + const resolved = resolveSitePath(path, documentRoot); + + await context.php.unlink(resolved); +}; + +/** + * Recursively removes a directory and all its contents using a + * PHP helper that walks the directory tree. + */ +export const rmdirHandler: V2StepHandler = async (args, context) => { + const { path } = args as { path: string }; + const documentRoot = await context.php.documentRoot; + const resolved = resolveSitePath(path, documentRoot); + + const result = await context.php.run({ + code: `>; + [key: string]: unknown; +} + +/** + * Imports content into WordPress. Supports three modes: + * + * - wxr: Resolves a WXR (WordPress eXtended RSS) file and + * imports it via the WP_Import class. + * - mysql-dump: Resolves a SQL file and executes its + * statements via $wpdb->query(). + * - posts: Takes an array of post objects and inserts each + * one via wp_insert_post(). + */ +const handler: V2StepHandler = async (args, context) => { + const contentType = args.type || 'wxr'; + + switch (contentType) { + case 'wxr': + await importWxr(args, context); + break; + case 'mysql-dump': + await importMysqlDump(args, context); + break; + case 'posts': + await importPosts(args, context); + break; + default: + throw new Error( + `Unsupported content import type: "${contentType}"` + ); + } +}; + +/** + * Imports a WXR file via the WordPress importer. + */ +async function importWxr( + args: ImportContentArgs, + context: Parameters[1] +): Promise { + const { php, dataReferenceResolver } = context; + const docroot = await php.documentRoot; + + if (!args.source) { + throw new Error( + 'importContent with type "wxr" requires a "source" argument' + ); + } + + const file = await dataReferenceResolver.resolveFile(args.source); + const importPath = '/tmp/import.wxr'; + await php.writeFile(importPath, file.contents); + + await php.run({ + code: ` 'Administrator') +)[0]->ID; +wp_set_current_user($admin_id); + +$wp_import = new WP_Import(); +$import_data = $wp_import->parse(${phpVar(importPath)}); + +$wp_import->get_authors_from_import($import_data); +unset($import_data); + +$wp_import->fetch_attachments = true; + +$_GET = array( + 'import' => 'wordpress', + 'step' => 2, +); +$_POST = array( + 'imported_authors' => array(), + 'user_map' => array(), + 'fetch_attachments' => $wp_import->fetch_attachments, +); + +$wp_import->import(${phpVar(importPath)}, [ + 'rewrite_urls' => true, +]); +`, + $_SERVER: { + HTTPS: (await php.absoluteUrl).startsWith('https://') ? 'on' : '', + }, + }); +} + +/** + * Imports a MySQL dump by resolving the SQL source and + * executing the statements via $wpdb->query(). + */ +async function importMysqlDump( + args: ImportContentArgs, + context: Parameters[1] +): Promise { + const { php, dataReferenceResolver } = context; + const docroot = await php.documentRoot; + + if (!args.source) { + throw new Error( + 'importContent with type "mysql-dump" requires ' + + 'a "source" argument' + ); + } + + const file = await dataReferenceResolver.resolveFile(args.source); + const sqlPath = '/tmp/import.sql'; + await php.writeFile(sqlPath, file.contents); + + await php.run({ + code: `query($statement); + } +} +unlink(${phpVar(sqlPath)}); +`, + }); +} + +/** + * Inserts posts using wp_insert_post() for each post + * object in the "posts" array. + */ +async function importPosts( + args: ImportContentArgs, + context: Parameters[1] +): Promise { + const { php } = context; + const docroot = await php.documentRoot; + const posts = args.posts || []; + + if (posts.length === 0) { + return; + } + + const postsJson = JSON.stringify(posts); + + await php.run({ + code: ` 'Administrator'))[0]->ID +); + +$posts = json_decode(${phpVar(postsJson)}, true); +foreach ($posts as $post_data) { + $result = wp_insert_post($post_data, true); + if (is_wp_error($result)) { + throw new Exception( + 'Failed to insert post: ' . + $result->get_error_message() + ); + } +} +`, + }); +} + +registerV2StepHandler('importContent', handler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/import-media.ts b/packages/playground/blueprints/src/lib/v2/steps/import-media.ts new file mode 100644 index 00000000000..b406320d282 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/import-media.ts @@ -0,0 +1,89 @@ +import type { V2StepHandler } from '../types'; +import type { DataSources } from '../wep-1-blueprint-v2-schema/appendix-B-data-sources'; +import { joinPaths, phpVar } from '@php-wasm/util'; +import { registerV2StepHandler } from './index'; + +interface ImportMediaArgs { + source: DataSources.DataReference; + title?: string; + description?: string; + alt?: string; + caption?: string; +} + +/** + * Imports a media file into the WordPress media library. + * + * Resolves the source data reference, writes the file to + * wp-content/uploads/, then registers it as an attachment + * via wp_insert_attachment() and generates metadata. + */ +const handler: V2StepHandler = async (args, context) => { + const { php, dataReferenceResolver } = context; + const docroot = await php.documentRoot; + + const file = await dataReferenceResolver.resolveFile(args.source); + const uploadsDir = joinPaths(docroot, 'wp-content', 'uploads'); + const filePath = joinPaths(uploadsDir, file.name); + + // Ensure the uploads directory exists. + if (!(await php.fileExists(uploadsDir))) { + await php.mkdir(uploadsDir); + } + + await php.writeFile(filePath, file.contents); + + const title = args.title || file.name.replace(/\.[^.]+$/, ''); + const description = args.description || ''; + const alt = args.alt || ''; + const caption = args.caption || ''; + + await php.run({ + code: ` 'Administrator'))[0]->ID +); + +$file_path = ${phpVar(filePath)}; +$file_name = ${phpVar(file.name)}; +$file_type = wp_check_filetype($file_name); + +$attachment = array( + 'post_title' => ${phpVar(title)}, + 'post_content' => ${phpVar(description)}, + 'post_excerpt' => ${phpVar(caption)}, + 'post_mime_type' => $file_type['type'], + 'post_status' => 'inherit', +); + +$attach_id = wp_insert_attachment( + $attachment, + $file_path +); + +if (is_wp_error($attach_id)) { + throw new Exception( + 'Failed to insert attachment: ' . + $attach_id->get_error_message() + ); +} + +$attach_data = wp_generate_attachment_metadata( + $attach_id, + $file_path +); +wp_update_attachment_metadata($attach_id, $attach_data); + +if (!empty(${phpVar(alt)})) { + update_post_meta($attach_id, '_wp_attachment_image_alt', ${phpVar(alt)}); +} +`, + }); +}; + +registerV2StepHandler('importMedia', handler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/import-theme-starter-content.ts b/packages/playground/blueprints/src/lib/v2/steps/import-theme-starter-content.ts new file mode 100644 index 00000000000..755f59158e6 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/import-theme-starter-content.ts @@ -0,0 +1,72 @@ +import type { V2StepHandler } from '../types'; +import { phpVar } from '@php-wasm/util'; +import { registerV2StepHandler } from './index'; + +interface ImportThemeStarterContentArgs { + themeSlug?: string; +} + +/** + * Imports the active theme's starter content into WordPress. + * + * Simulates the customizer environment so that + * import_theme_starter_content() runs correctly, then + * publishes the resulting changeset. + */ +const handler: V2StepHandler = async ( + args, + context +) => { + const { php } = context; + const docroot = await php.documentRoot; + const themeSlug = args.themeSlug || ''; + + context.progress.setCaption('Importing theme starter content'); + + await php.run({ + code: ` 'Administrator'))[0] + ); + + add_filter('pre_option_fresh_site', '__return_true'); + + $_REQUEST['wp_customize'] = 'on'; + $_REQUEST['customize_theme'] = + ${phpVar(themeSlug)} ?: get_stylesheet(); + + $_REQUEST['action'] = 'customize_save'; + add_filter('wp_doing_ajax', '__return_true'); + + $_GET = $_REQUEST; +} +playground_add_filter( + 'plugins_loaded', + 'importThemeStarterContent_plugins_loaded', + 0 +); + +require ${phpVar(docroot)} . '/wp-load.php'; + +if (!get_theme_starter_content()) { + return; +} + +$wp_customize->import_theme_starter_content(); + +wp_publish_post($wp_customize->changeset_post_id()); +`, + }); +}; + +registerV2StepHandler('importThemeStarterContent', handler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/index.ts b/packages/playground/blueprints/src/lib/v2/steps/index.ts new file mode 100644 index 00000000000..a86d4e27cb1 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/index.ts @@ -0,0 +1,35 @@ +import type { V2StepHandler } from '../types'; + +/** + * Registry of all V2 step handlers, keyed by step name. + * Handlers are added as they are implemented. + */ +export const v2StepHandlers: Record = {}; + +/** + * Register a step handler. Called by each step module. + */ +export function registerV2StepHandler( + stepName: string, + handler: V2StepHandler +): void { + v2StepHandlers[stepName] = handler; +} + +// Side-effect imports: each module self-registers its handler. +import './filesystem'; +import './define-constants'; +import './set-site-options'; +import './run-php'; +import './wp-cli'; +import './write-files'; +import './install-plugin'; +import './activate-plugin'; +import './install-theme'; +import './activate-theme'; +import './set-site-language'; +import './unzip'; +import './import-content'; +import './import-media'; +import './import-theme-starter-content'; +import './run-sql'; diff --git a/packages/playground/blueprints/src/lib/v2/steps/install-plugin.ts b/packages/playground/blueprints/src/lib/v2/steps/install-plugin.ts new file mode 100644 index 00000000000..4cf4552dd32 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/install-plugin.ts @@ -0,0 +1,293 @@ +import type { V2StepHandler, ResolvedDirectory } from '../types'; +import type { DataSources } from '../wep-1-blueprint-v2-schema/appendix-B-data-sources'; +import { DataReferenceResolverImpl } from '../data-references/resolver'; +import { joinPaths, phpVar } from '@php-wasm/util'; +import { registerV2StepHandler } from './index'; + +interface InstallPluginArgs { + source: DataSources.DataReference | DataSources.PluginDirectoryReference; + active?: boolean; + activationOptions?: Record; + targetDirectoryName?: string; + onError?: 'throw' | 'skip'; +} + +/** + * Installs a WordPress plugin. + * + * The source can be a WordPress.org slug (e.g. "jetpack"), + * a URL to a zip file, an execution context path, or an + * inline file/directory object. + */ +const handler: V2StepHandler = async (args, context) => { + const { php } = context; + const docroot = await php.documentRoot; + const pluginsDir = joinPaths(docroot, 'wp-content', 'plugins'); + + const source = args.source; + const active = args.active !== undefined ? args.active : true; + const targetDirectoryName = args.targetDirectoryName; + + try { + if (isPluginSlug(source)) { + await installFromSlug( + source, + pluginsDir, + targetDirectoryName, + context + ); + } else { + await installFromDataReference( + source as DataSources.DataReference, + pluginsDir, + targetDirectoryName, + context + ); + } + + if (active) { + await activateInstalledPlugin( + pluginsDir, + targetDirectoryName, + context + ); + } + } catch (error) { + if (args.onError === 'skip') { + return; + } + throw error; + } +}; + +/** + * Installs a plugin from a WordPress.org slug by resolving + * the slug via the resolver implementation. + */ +async function installFromSlug( + slug: string, + pluginsDir: string, + targetDirectoryName: string | undefined, + context: Parameters[1] +): Promise { + const resolver = context.dataReferenceResolver as DataReferenceResolverImpl; + const file = await resolver.resolvePluginReference(slug); + await extractZipToPlugins( + file.contents, + pluginsDir, + targetDirectoryName, + context + ); +} + +/** + * Installs a plugin from a generic data reference (URL, + * execution context path, inline file/directory, etc.). + */ +async function installFromDataReference( + ref: DataSources.DataReference, + pluginsDir: string, + targetDirectoryName: string | undefined, + context: Parameters[1] +): Promise { + const { dataReferenceResolver } = context; + + // Try resolving as a directory first (for inline directories + // or execution-context directory paths). + if (isDirectoryLikeReference(ref)) { + const dir = await dataReferenceResolver.resolveDirectory(ref); + const dirName = targetDirectoryName || dir.name; + const targetPath = joinPaths(pluginsDir, dirName); + await writeResolvedDirectory(dir, targetPath, context); + return; + } + + // Otherwise resolve as a file (zip or URL). + const file = await dataReferenceResolver.resolveFile(ref); + if (looksLikeZip(file.contents)) { + await extractZipToPlugins( + file.contents, + pluginsDir, + targetDirectoryName, + context + ); + } else { + // Single PHP file — write directly to plugins directory. + const fileName = targetDirectoryName + ? joinPaths(pluginsDir, targetDirectoryName, file.name) + : joinPaths(pluginsDir, file.name); + await context.php.writeFile(fileName, file.contents); + } +} + +/** + * Extracts a zip file into the plugins directory using PHP + * ZipArchive. + */ +async function extractZipToPlugins( + zipContents: Uint8Array, + pluginsDir: string, + targetDirectoryName: string | undefined, + context: Parameters[1] +): Promise { + const { php } = context; + const tempZipPath = '/tmp/plugin-install.zip'; + await php.writeFile(tempZipPath, zipContents); + + const extractDir = targetDirectoryName + ? joinPaths(pluginsDir, targetDirectoryName) + : pluginsDir; + + await php.run({ + code: `open(${phpVar(tempZipPath)}); +if ($res !== true) { + throw new Exception('Failed to open zip: error code ' . $res); +} +$zip->extractTo(${phpVar(extractDir)}); +$zip->close(); +unlink(${phpVar(tempZipPath)}); +`, + }); +} + +/** + * Activates the most recently installed plugin by scanning + * the target directory for PHP files with plugin headers. + */ +async function activateInstalledPlugin( + pluginsDir: string, + targetDirectoryName: string | undefined, + context: Parameters[1] +): Promise { + const { php } = context; + const docroot = await php.documentRoot; + + // When a targetDirectoryName is given we know the exact + // folder. Otherwise we ask PHP to find the plugin file + // in the plugins directory. + const searchDir = targetDirectoryName + ? joinPaths(pluginsDir, targetDirectoryName) + : pluginsDir; + + await php.run({ + code: ` 'Administrator'))[0]->ID +); + +$search_dir = ${phpVar(searchDir)}; +$plugin_dir = ${phpVar(pluginsDir)}; + +// Try to activate the directory directly first. +if (is_dir($search_dir) && $search_dir !== $plugin_dir) { + $relative = str_replace( + rtrim($plugin_dir, '/') . '/', + '', + $search_dir + ); + foreach (glob($search_dir . '/*.php') ?: [] as $file) { + $info = get_plugin_data($file, false, false); + if (!empty($info['Name'])) { + $relative_file = $relative . '/' . basename($file); + activate_plugin($relative_file); + return; + } + } +} + +// Fallback: scan top-level subdirectories inside the +// plugins directory for newly added plugin directories. +foreach (glob($plugin_dir . '/*', GLOB_ONLYDIR) ?: [] as $dir) { + foreach (glob($dir . '/*.php') ?: [] as $file) { + $info = get_plugin_data($file, false, false); + if (!empty($info['Name'])) { + $relative_file = basename($dir) . '/' . basename($file); + if (!is_plugin_active($relative_file)) { + activate_plugin($relative_file); + return; + } + } + } +} +`, + }); +} + +/** + * Writes a resolved directory tree to a target path + * on the PHP filesystem. + */ +async function writeResolvedDirectory( + dir: ResolvedDirectory, + targetPath: string, + context: Parameters[1] +): Promise { + const { php } = context; + await php.mkdir(targetPath); + for (const [name, entry] of Object.entries(dir.files)) { + const entryPath = joinPaths(targetPath, name); + if (entry instanceof Uint8Array) { + await php.writeFile(entryPath, entry); + } else { + await writeResolvedDirectory( + entry as ResolvedDirectory, + entryPath, + context + ); + } + } +} + +/** + * Determines whether a source string looks like a + * WordPress.org plugin slug rather than a URL, execution + * context path, or inline object. + */ +function isPluginSlug(source: unknown): source is string { + if (typeof source !== 'string') { + return false; + } + if (source.startsWith('http://') || source.startsWith('https://')) { + return false; + } + if (source.startsWith('./') || source.startsWith('/')) { + return false; + } + return true; +} + +/** + * Determines whether a data reference is likely a directory + * (inline directory or execution-context directory path + * ending with `/`). + */ +function isDirectoryLikeReference(ref: DataSources.DataReference): boolean { + if (typeof ref === 'object' && ref !== null && 'directoryName' in ref) { + return true; + } + return false; +} + +/** + * Checks the first four bytes of a buffer for the ZIP file + * signature (PK\x03\x04). + */ +function looksLikeZip(contents: Uint8Array): boolean { + if (contents.length < 4) { + return false; + } + return ( + contents[0] === 0x50 && + contents[1] === 0x4b && + contents[2] === 0x03 && + contents[3] === 0x04 + ); +} + +registerV2StepHandler('installPlugin', handler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/install-theme.ts b/packages/playground/blueprints/src/lib/v2/steps/install-theme.ts new file mode 100644 index 00000000000..44b0395bd15 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/install-theme.ts @@ -0,0 +1,174 @@ +import type { V2StepHandler, ResolvedDirectory } from '../types'; +import type { DataSources } from '../wep-1-blueprint-v2-schema/appendix-B-data-sources'; +import { DataReferenceResolverImpl } from '../data-references/resolver'; +import { joinPaths, phpVar } from '@php-wasm/util'; +import { registerV2StepHandler } from './index'; + +interface InstallThemeArgs { + source: DataSources.DataReference | DataSources.ThemeDirectoryReference; + targetDirectoryName?: string; +} + +/** + * Installs a WordPress theme. + * + * The source can be a WordPress.org theme slug, + * a URL to a zip file, an execution context path, or an + * inline file/directory object. + */ +const handler: V2StepHandler = async (args, context) => { + const { php } = context; + const docroot = await php.documentRoot; + const themesDir = joinPaths(docroot, 'wp-content', 'themes'); + + const source = args.source; + const targetDirectoryName = args.targetDirectoryName; + + if (isThemeSlug(source)) { + await installFromSlug(source, themesDir, targetDirectoryName, context); + } else { + await installFromDataReference( + source as DataSources.DataReference, + themesDir, + targetDirectoryName, + context + ); + } +}; + +/** + * Installs a theme from a WordPress.org slug by resolving + * the slug via the resolver implementation. + */ +async function installFromSlug( + slug: string, + themesDir: string, + targetDirectoryName: string | undefined, + context: Parameters[1] +): Promise { + const resolver = context.dataReferenceResolver as DataReferenceResolverImpl; + const file = await resolver.resolveThemeReference(slug); + await extractZipToThemes( + file.contents, + themesDir, + targetDirectoryName, + context + ); +} + +/** + * Installs a theme from a generic data reference (URL, + * execution context path, inline file/directory, etc.). + */ +async function installFromDataReference( + ref: DataSources.DataReference, + themesDir: string, + targetDirectoryName: string | undefined, + context: Parameters[1] +): Promise { + const { dataReferenceResolver } = context; + + // Try resolving as a directory first. + if (isDirectoryLikeReference(ref)) { + const dir = await dataReferenceResolver.resolveDirectory(ref); + const dirName = targetDirectoryName || dir.name; + const targetPath = joinPaths(themesDir, dirName); + await writeResolvedDirectory(dir, targetPath, context); + return; + } + + // Otherwise resolve as a file (zip). + const file = await dataReferenceResolver.resolveFile(ref); + await extractZipToThemes( + file.contents, + themesDir, + targetDirectoryName, + context + ); +} + +/** + * Extracts a zip file into the themes directory using PHP + * ZipArchive. + */ +async function extractZipToThemes( + zipContents: Uint8Array, + themesDir: string, + targetDirectoryName: string | undefined, + context: Parameters[1] +): Promise { + const { php } = context; + const tempZipPath = '/tmp/theme-install.zip'; + await php.writeFile(tempZipPath, zipContents); + + const extractDir = targetDirectoryName + ? joinPaths(themesDir, targetDirectoryName) + : themesDir; + + await php.run({ + code: `open(${phpVar(tempZipPath)}); +if ($res !== true) { + throw new Exception('Failed to open zip: error code ' . $res); +} +$zip->extractTo(${phpVar(extractDir)}); +$zip->close(); +unlink(${phpVar(tempZipPath)}); +`, + }); +} + +/** + * Writes a resolved directory tree to a target path + * on the PHP filesystem. + */ +async function writeResolvedDirectory( + dir: ResolvedDirectory, + targetPath: string, + context: Parameters[1] +): Promise { + const { php } = context; + await php.mkdir(targetPath); + for (const [name, entry] of Object.entries(dir.files)) { + const entryPath = joinPaths(targetPath, name); + if (entry instanceof Uint8Array) { + await php.writeFile(entryPath, entry); + } else { + await writeResolvedDirectory( + entry as ResolvedDirectory, + entryPath, + context + ); + } + } +} + +/** + * Determines whether a source string looks like a + * WordPress.org theme slug. + */ +function isThemeSlug(source: unknown): source is string { + if (typeof source !== 'string') { + return false; + } + if (source.startsWith('http://') || source.startsWith('https://')) { + return false; + } + if (source.startsWith('./') || source.startsWith('/')) { + return false; + } + return true; +} + +/** + * Determines whether a data reference is likely a directory. + */ +function isDirectoryLikeReference(ref: DataSources.DataReference): boolean { + if (typeof ref === 'object' && ref !== null && 'directoryName' in ref) { + return true; + } + return false; +} + +registerV2StepHandler('installTheme', handler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/run-php.ts b/packages/playground/blueprints/src/lib/v2/steps/run-php.ts new file mode 100644 index 00000000000..34a3d94ce35 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/run-php.ts @@ -0,0 +1,53 @@ +import type { V2StepHandler } from '../types'; +import type { DataSources } from '../wep-1-blueprint-v2-schema/appendix-B-data-sources'; +import { registerV2StepHandler } from './index'; + +/** + * Executes a PHP script provided as a data reference. + * + * The `code` argument is resolved through the data reference + * resolver to obtain the PHP source. The source is written to a + * temporary file on the VFS and then executed via + * `context.php.run()`. Optional environment variables can be + * passed and are set via `putenv()` before the script runs. + */ +export const runPHPHandler: V2StepHandler = async (args, context) => { + const { code, env } = args as { + code: DataSources.DataReference; + env?: Record; + }; + + const resolved = await context.dataReferenceResolver.resolveFile(code); + const phpCode = new TextDecoder().decode(resolved.contents); + + // Build a wrapper that sets environment variables before + // executing the user-provided PHP code. + let wrapper = 'query(). + */ +const handler: V2StepHandler = async (args, context) => { + const { php, dataReferenceResolver } = context; + const docroot = await php.documentRoot; + + const file = await dataReferenceResolver.resolveFile(args.source); + const sqlPath = '/tmp/run-sql.sql'; + await php.writeFile(sqlPath, file.contents); + + await php.run({ + code: `query($statement); + if ($result === false) { + throw new Exception( + 'SQL error: ' . $wpdb->last_error . + ' in statement: ' . substr($statement, 0, 200) + ); + } + } +} +unlink(${phpVar(sqlPath)}); +`, + }); +}; + +registerV2StepHandler('runSQL', handler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/set-site-language.ts b/packages/playground/blueprints/src/lib/v2/steps/set-site-language.ts new file mode 100644 index 00000000000..671f2bbb396 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/set-site-language.ts @@ -0,0 +1,196 @@ +import type { V2StepHandler } from '../types'; +import { phpVar } from '@php-wasm/util'; +import { registerV2StepHandler } from './index'; + +interface SetSiteLanguageArgs { + language: string; +} + +/** + * Sets the WordPress site language and downloads translation + * packs for WordPress core, installed plugins, and installed + * themes. + */ +const handler: V2StepHandler = async (args, context) => { + const { php } = context; + const docroot = await php.documentRoot; + const language = args.language; + + context.progress.setCaption('Setting site language'); + + // 1. Set the WPLANG option. + await php.run({ + code: ` $plugin['TextDomain'], + 'version' => $plugin['Version'] + ]; + }, + array_filter( + get_plugins(), + function($plugin) { + return !empty($plugin['TextDomain']); + } + ) + ) + ) +); +`, + }); + + const themeListResult = await php.run({ + code: ` $theme->get('TextDomain'), + 'version' => $theme->get('Version') + ]; + }, + wp_get_themes() + ) + ) +); +`, + }); + + const plugins: Array<{ slug: string; version: string }> = + pluginListResult.json ?? []; + const themes: Array<{ slug: string; version: string }> = + themeListResult.json ?? []; + + // 4. Ensure language directories exist. + const langDir = `${docroot}/wp-content/languages`; + if (!(await php.fileExists(langDir))) { + await php.mkdir(langDir); + } + const pluginsLangDir = `${langDir}/plugins`; + if (!(await php.fileExists(pluginsLangDir))) { + await php.mkdir(pluginsLangDir); + } + const themesLangDir = `${langDir}/themes`; + if (!(await php.fileExists(themesLangDir))) { + await php.mkdir(themesLangDir); + } + + // 5. Download and extract translations. + await downloadAndExtract(coreTranslationUrl, langDir, php); + + for (const { slug, version } of plugins) { + const url = + `https://downloads.wordpress.org/translation/plugin/` + + `${slug}/${version}/${language}.zip`; + try { + await downloadAndExtract(url, pluginsLangDir, php); + } catch { + // Not all plugins have translations for every + // language. This is expected — skip silently. + } + } + + for (const { slug, version } of themes) { + const url = + `https://downloads.wordpress.org/translation/theme/` + + `${slug}/${version}/${language}.zip`; + try { + await downloadAndExtract(url, themesLangDir, php); + } catch { + // Not all themes have translations for every + // language. This is expected — skip silently. + } + } +}; + +/** + * Fetches the core translation package URL from the + * WordPress.org translations API. + */ +async function fetchCoreTranslationUrl( + wpVersion: string, + language: string +): Promise { + const apiUrl = + `https://api.wordpress.org/translations/core/1.0/` + + `?version=${wpVersion}`; + const response = await fetch(apiUrl); + const data = await response.json(); + + const match = data.translations.find( + (t: { language: string }) => + t.language.toLowerCase() === language.toLowerCase() + ); + if (!match) { + throw new Error( + `Translation package for "${language}" not found ` + + `for WordPress ${wpVersion}. Check that the ` + + `language code is correct.` + ); + } + return match.package; +} + +/** + * Downloads a zip file from a URL and extracts it into the + * given destination directory using PHP ZipArchive. + */ +async function downloadAndExtract( + url: string, + destDir: string, + php: Parameters[1]['php'] +): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to download translation: ${url} ` + + `(${response.status} ${response.statusText})` + ); + } + const buffer = await response.arrayBuffer(); + const tempPath = '/tmp/translation.zip'; + await php.writeFile(tempPath, new Uint8Array(buffer)); + await php.run({ + code: `open(${phpVar(tempPath)}); +if ($res !== true) { + throw new Exception('Failed to open zip: error code ' . $res); +} +$zip->extractTo(${phpVar(destDir)}); +$zip->close(); +unlink(${phpVar(tempPath)}); +`, + }); +} + +registerV2StepHandler('setSiteLanguage', handler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/set-site-options.ts b/packages/playground/blueprints/src/lib/v2/steps/set-site-options.ts new file mode 100644 index 00000000000..448c52e3fce --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/set-site-options.ts @@ -0,0 +1,35 @@ +import type { V2StepHandler } from '../types'; +import { registerV2StepHandler } from './index'; +import { phpVar } from '@php-wasm/util'; + +/** + * Sets WordPress site options via `update_option()`. + * + * For each key-value pair in `options`, calls + * `update_option($key, $value)`. When the + * `permalink_structure` option is set, rewrite rules are + * flushed afterward so the new structure takes effect + * immediately. + */ +export const setSiteOptionsHandler: V2StepHandler = async (args, context) => { + const { options } = args as { options: Record }; + const documentRoot = await context.php.documentRoot; + + const result = await context.php.run({ + code: ` $value) { + update_option($name, $value); + } + if (array_key_exists('permalink_structure', $site_options)) { + flush_rewrite_rules(); + } + `, + }); + if (result.errors) { + throw new Error(result.errors); + } +}; + +registerV2StepHandler('setSiteOptions', setSiteOptionsHandler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/unzip.ts b/packages/playground/blueprints/src/lib/v2/steps/unzip.ts new file mode 100644 index 00000000000..3f5051de921 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/unzip.ts @@ -0,0 +1,51 @@ +import type { V2StepHandler } from '../types'; +import type { DataSources } from '../wep-1-blueprint-v2-schema/appendix-B-data-sources'; +import { joinPaths, phpVar } from '@php-wasm/util'; +import { registerV2StepHandler } from './index'; + +interface UnzipArgs { + source: DataSources.DataReference; + target: string; +} + +/** + * Resolves a data reference to a zip file, writes it to a + * temporary location, then extracts it to the target path + * using PHP ZipArchive. + */ +const handler: V2StepHandler = async (args, context) => { + const { php, dataReferenceResolver } = context; + const docroot = await php.documentRoot; + const target = resolveSitePath(args.target, docroot); + + const file = await dataReferenceResolver.resolveFile(args.source); + const tempZipPath = '/tmp/unzip-step.zip'; + await php.writeFile(tempZipPath, file.contents); + + await php.run({ + code: `open(${phpVar(tempZipPath)}); +if ($res !== true) { + throw new Exception('Failed to open zip: error code ' . $res); +} +@mkdir(${phpVar(target)}, 0777, true); +$zip->extractTo(${phpVar(target)}); +$zip->close(); +unlink(${phpVar(tempZipPath)}); +`, + }); +}; + +/** + * Resolves a "site:" prefixed path to an absolute path + * relative to the WordPress document root. + */ +function resolveSitePath(path: string, documentRoot: string): string { + if (path.startsWith('site:')) { + return joinPaths(documentRoot, path.slice(5)); + } + return path; +} + +registerV2StepHandler('unzip', handler); diff --git a/packages/playground/blueprints/src/lib/v2/steps/wp-cli.ts b/packages/playground/blueprints/src/lib/v2/steps/wp-cli.ts new file mode 100644 index 00000000000..771f535cb2e --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/steps/wp-cli.ts @@ -0,0 +1,72 @@ +import type { V2StepHandler } from '../types'; +import { registerV2StepHandler } from './index'; +import { joinPaths, phpVar } from '@php-wasm/util'; +import { + splitShellCommand, + defaultWpCliPath, + assertWpCli, +} from '../../steps/wp-cli'; + +/** + * Executes a WP-CLI command against the WordPress installation. + * + * The handler reuses the V1 `splitShellCommand` parser and + * `assertWpCli` guard. It writes a PHP bootstrap script that + * sets up the expected WP-CLI environment (argv, stdio streams) + * and then requires `wp-cli.phar`. + */ +export const wpCliHandler: V2StepHandler = async (args, context) => { + const { command, wpCliPath = defaultWpCliPath } = args as { + command: string; + wpCliPath?: string; + }; + + await assertWpCli(context.php, wpCliPath); + + const parsedArgs = splitShellCommand(command.trim()); + const cmd = parsedArgs.shift(); + if (cmd !== 'wp') { + throw new Error('The first argument must be "wp".'); + } + + const documentRoot = await context.php.documentRoot; + + await context.php.writeFile('/tmp/stdout', ''); + await context.php.writeFile('/tmp/stderr', ''); + await context.php.writeFile( + joinPaths(documentRoot, 'run-cli.php'), + ` { + const { files } = args as { + files: Record; + }; + + const documentRoot = await context.php.documentRoot; + + for (const [targetPath, dataRef] of Object.entries(files)) { + const resolved = + await context.dataReferenceResolver.resolveFile(dataRef); + const absolutePath = resolveSitePath(targetPath, documentRoot); + await context.php.writeFile(absolutePath, resolved.contents); + } +}; + +/** + * Resolves a `site:` prefixed path to an absolute path on the + * virtual filesystem. + */ +function resolveSitePath(path: string, documentRoot: string): string { + if (path.startsWith('site:')) { + return joinPaths(documentRoot, path.slice(5)); + } + return path; +} + +registerV2StepHandler('writeFiles', writeFilesHandler); diff --git a/packages/playground/blueprints/src/lib/v2/types.ts b/packages/playground/blueprints/src/lib/v2/types.ts new file mode 100644 index 00000000000..27d096fbe61 --- /dev/null +++ b/packages/playground/blueprints/src/lib/v2/types.ts @@ -0,0 +1,326 @@ +import type { ProgressTracker } from '@php-wasm/progress'; +import type { UniversalPHP } from '@php-wasm/universal'; +import type { Semaphore } from '@php-wasm/util'; +import type { BlueprintV2Declaration } from './blueprint-v2-declaration'; +import type { DataSources } from './wep-1-blueprint-v2-schema/appendix-B-data-sources'; + +// Re-export for convenience within the v2/ subtree. +export type { BlueprintV2Declaration }; + +// ===================================================================== +// Compiled Blueprint V2 +// ===================================================================== + +/** + * A Blueprint V2 that has been compiled from a declaration into + * an executable form. Compilation resolves version constraints, + * transpiles declarative properties into step sequences, and + * validates the schema. + */ +export interface CompiledBlueprintV2 { + /** Runtime configuration extracted from the declaration. */ + runtimeConfig: V2RuntimeConfig; + /** Ordered list of compiled steps ready for execution. */ + steps: CompiledV2Step[]; + /** + * Executes the compiled blueprint against a PHP instance. + * + * @param php - The PHP runtime to execute against. + * @param options - Optional execution parameters. + */ + run( + php: UniversalPHP, + options?: CompiledBlueprintV2RunOptions + ): Promise; +} + +/** + * Options accepted by `CompiledBlueprintV2.run()`. + */ +export interface CompiledBlueprintV2RunOptions { + /** Progress tracker for reporting execution progress. */ + progress?: ProgressTracker; + /** Abort signal for cancelling execution. */ + signal?: AbortSignal; +} + +// ===================================================================== +// Runtime Configuration +// ===================================================================== + +/** + * Runtime configuration derived from a V2 Blueprint declaration. + * Contains version constraints and application-level options that + * the host environment uses to set up PHP and WordPress before + * step execution begins. + */ +export interface V2RuntimeConfig { + /** PHP version constraint, if specified. */ + phpVersion?: V2VersionConstraint; + /** WordPress version constraint, if specified. */ + wordpressVersion?: V2VersionConstraint; + /** + * Application-specific options from the blueprint's + * `applicationOptions` property. Keyed by application + * name (e.g. `"wordpress-playground"`). + */ + applicationOptions?: BlueprintV2Declaration['applicationOptions']; +} + +/** + * A semver-style version constraint supporting min/max bounds + * and a preferred version. + */ +export interface V2VersionConstraint { + /** Minimum acceptable version (inclusive). */ + min?: string; + /** Maximum acceptable version (inclusive). */ + max?: string; + /** + * Preferred version to use when the host has a choice. + * Falls back to "latest" when unspecified. + */ + preferred?: string; +} + +// ===================================================================== +// Compiled Steps +// ===================================================================== + +/** + * A single step that has been compiled from either a declarative + * blueprint property (e.g. `plugins`) or from an explicit + * `additionalStepsAfterExecution` entry. + */ +export interface CompiledV2Step { + /** The step handler name (e.g. "installPlugin"). */ + step: string; + /** + * The resolved arguments for the step handler. The shape + * depends on the specific step type. + */ + args: Record; + /** + * Optional hints for the progress tracker, such as a + * human-readable caption and relative weight. + */ + progressHints?: StepProgressHints; +} + +/** + * Hints that the compiler attaches to a compiled step so that + * the execution loop can report meaningful progress. + */ +export interface StepProgressHints { + /** + * Human-readable caption displayed during execution + * (e.g. "Installing Jetpack plugin"). + */ + caption?: string; + /** + * Relative weight of this step for progress calculation. + * Higher values indicate longer-running steps. + * @default 1 + */ + weight?: number; +} + +// ===================================================================== +// Step Execution Context +// ===================================================================== + +/** + * The context passed to every step handler during execution. + * Provides access to the PHP runtime, progress reporting, data + * resolution, and cancellation. + */ +export interface StepExecutionContext { + /** The PHP runtime to execute against. */ + php: UniversalPHP; + /** Progress tracker scoped to the current step. */ + progress: ProgressTracker; + /** Resolver for data references (URLs, paths, inline). */ + dataReferenceResolver: DataReferenceResolver; + /** Abort signal for cooperative cancellation. */ + signal?: AbortSignal; +} + +// ===================================================================== +// Data Reference Resolution +// ===================================================================== + +/** + * Resolves V2 data references into concrete file/directory + * contents. Data references can be URLs, execution-context + * paths, inline content, or git repository paths. + */ +export interface DataReferenceResolver { + /** + * Resolves a data reference to a single file. + * + * @param ref - A V2 data reference (URL, path, inline, etc.) + * @returns The resolved file contents. + */ + resolveFile(ref: DataSources.DataReference): Promise; + + /** + * Resolves a data reference to a directory tree. + * + * @param ref - A V2 data reference pointing to a directory. + * @returns The resolved directory structure. + */ + resolveDirectory( + ref: DataSources.DataReference + ): Promise; +} + +/** + * A resolved file: a name and its binary contents. + */ +export interface ResolvedFile { + /** The file name (without directory path). */ + name: string; + /** The file contents as a byte array. */ + contents: Uint8Array; +} + +/** + * A resolved directory containing files and subdirectories. + */ +export interface ResolvedDirectory { + /** The directory name. */ + name: string; + /** + * Entries in this directory, keyed by name. Leaf entries + * are `Uint8Array` (files), nested entries are + * `ResolvedDirectory`. + */ + files: Record; +} + +// ===================================================================== +// Step Handler +// ===================================================================== + +/** + * A function that executes a single V2 blueprint step. + * + * Step handlers are registered in the step handler registry + * and dispatched by the execution loop based on the step name. + * + * @typeParam TArgs - The shape of the step's arguments. + */ +export type V2StepHandler> = ( + args: TArgs, + context: StepExecutionContext +) => Promise; + +// ===================================================================== +// Compilation Options +// ===================================================================== + +/** + * Backend interface providing read access to the blueprint's + * execution context (the directory where blueprint.json lives + * and any co-located resources). + */ +export interface ExecutionContextBackend { + /** + * Reads a file from the execution context as raw bytes. + * + * @param path - Path relative to the execution context root. + */ + readFileAsBuffer(path: string): Promise; + + /** + * Lists files in a directory within the execution context. + * + * @param path - Path relative to the execution context root. + * @returns An array of file/directory names. + */ + listFiles(path: string): Promise; +} + +/** + * Options for `compileBlueprintV2()`. + */ +export interface CompileBlueprintV2Options { + /** Progress tracker for reporting execution progress. */ + progress?: ProgressTracker; + /** Concurrency limiter for parallel downloads. */ + semaphore?: Semaphore; + /** CORS proxy URL prefix for cross-origin fetches. */ + corsProxy?: string; + /** + * Execution context backend for resolving paths starting + * with "./" or "/" in data references. + */ + executionContext?: ExecutionContextBackend; + /** Called after each step finishes executing. */ + onStepCompleted?: (step: string, index: number) => void; +} + +// ===================================================================== +// Error Classes +// ===================================================================== + +/** + * Thrown when a V2 Blueprint declaration fails schema validation + * or contains structurally invalid data. + */ +export class InvalidBlueprintV2Error extends Error { + /** Detailed validation errors, if available. */ + validationErrors: string[]; + + constructor(message: string, validationErrors: string[] = []) { + super(message); + this.name = 'InvalidBlueprintV2Error'; + this.validationErrors = validationErrors; + } +} + +/** + * Thrown when a V2 Blueprint step fails during execution. + */ +export class BlueprintV2StepExecutionError extends Error { + /** The name of the step that failed. */ + stepName: string; + + constructor(stepName: string, message: string, cause?: unknown) { + super(message, { cause }); + this.name = 'BlueprintV2StepExecutionError'; + this.stepName = stepName; + } +} + +/** + * Thrown when a data reference cannot be resolved (e.g. a URL + * that returns 404 or an execution-context path that does not + * exist). + */ +export class DataReferenceResolutionError extends Error { + /** String representation of the reference that failed. */ + reference: string; + + constructor(reference: string, message: string, cause?: unknown) { + super(message, { cause }); + this.name = 'DataReferenceResolutionError'; + this.reference = reference; + } +} + +/** + * Thrown when two blueprints cannot be merged because of + * conflicting declarations that have no automatic resolution + * strategy (e.g. incompatible PHP version constraints). + */ +export class BlueprintMergeConflictError extends Error { + /** The blueprint property where the conflict occurred. */ + conflictPath: string; + + constructor(conflictPath: string, message: string) { + super(message); + this.name = 'BlueprintMergeConflictError'; + this.conflictPath = conflictPath; + } +} diff --git a/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts b/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts index dbd0b7dd15d..c7ea44bd21a 100644 --- a/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts +++ b/packages/playground/cli/src/blueprints-v2/worker-thread-v2.ts @@ -16,8 +16,6 @@ import type { SpawnHandler, } from '@php-wasm/universal'; import { - PHPExecutionFailureError, - PHPResponse, PHPWorker, releaseApiProxy, consumeAPI, @@ -29,10 +27,11 @@ import { } from '@php-wasm/universal'; import { joinPaths, sprintf } from '@php-wasm/util'; import { - type BlueprintMessage, - runBlueprintV2, + compileBlueprintV2, + type BlueprintV2Declaration, type BlueprintV1Declaration, } from '@wp-playground/blueprints'; +import { ProgressTracker } from '@php-wasm/progress'; import { type ParsedBlueprintV2String, type RawBlueprintV2Data, @@ -327,113 +326,46 @@ export class PlaygroundCliBlueprintV2Worker extends PHPWorker { } try { - const cliArgsToPass: (keyof WorkerRunBlueprintArgs)[] = [ - 'mode', - 'db-engine', - 'db-host', - 'db-user', - 'db-pass', - 'db-name', - 'db-path', - 'truncate-new-site-directory', - 'allow', - ]; - const cliArgs = cliArgsToPass - .filter((arg) => arg in args) - .map((arg) => `--${arg}=${args[arg]}`); - cliArgs.push(`--site-url=${args.siteUrl}`); - - const streamedResponse = await runBlueprintV2({ - php, - blueprint: args.blueprint, - blueprintOverrides: { - additionalSteps: args['additional-blueprint-steps'], - wordpressVersion: args.wp, - }, - cliArgs, - onMessage: async (message: BlueprintMessage) => { - switch (message.type) { - case 'blueprint.target_resolved': { - if (!this.blueprintTargetResolved) { - this.blueprintTargetResolved = true; - await this.applyPostInstallMountsToAllWorkers( - workerPostInstallMountsPort - ); - } - break; - } - case 'blueprint.progress': { - const progressMessage = `${message.caption.trim()} – ${message.progress.toFixed( - 2 - )}%`; - output.progress(progressMessage); - break; - } - case 'blueprint.error': { - const red = '\x1b[31m'; - const bold = '\x1b[1m'; - const reset = '\x1b[0m'; - if (args.verbosity === 'debug' && message.details) { - output.stderr( - `${red}${bold}Fatal error:${reset} Uncaught ${message.details.exception}: ${message.details.message}\n` + - ` at ${message.details.file}:${message.details.line}\n` + - (message.details.trace - ? message.details.trace + '\n' - : '') - ); - } else { - output.stderr( - `${red}${bold}Error:${reset} ${message.message}\n` - ); - } - break; - } - } - }, - }); - /** - * When we're debugging, every bit of information matters – let's immediately output - * everything we get from the PHP output streams. - */ - if (args.verbosity === 'debug') { - streamedResponse!.stdout.pipeTo( - new WritableStream({ - write(chunk) { - process.stdout.write(chunk); - }, - }) - ); - streamedResponse!.stderr.pipeTo( - new WritableStream({ - write(chunk) { - process.stderr.write(chunk); - }, - }) - ); - } - await streamedResponse!.finished; - if ((await streamedResponse!.exitCode) !== 0) { - // exitCode != 1 means the blueprint execution failed. Let's throw an error. - // and clean up. - const syncResponse = - await PHPResponse.fromStreamedResponse(streamedResponse); - throw new PHPExecutionFailureError( - `PHP.run() failed with exit code ${syncResponse.exitCode}. ${syncResponse.errors} ${syncResponse.text}`, - syncResponse, - 'request' + const progress = new ProgressTracker(); + progress.addEventListener('progress', ((event: CustomEvent) => { + const caption = (event.detail.caption ?? '').trim(); + const pct = (event.detail.progress * 100).toFixed(2); + output.progress(`${caption} – ${pct}%`); + }) as EventListener); + + const compiled = await compileBlueprintV2( + args.blueprint as BlueprintV2Declaration, + { progress } + ); + + // Apply post-install mounts once we know the + // blueprint target has been resolved. + if (!this.blueprintTargetResolved) { + this.blueprintTargetResolved = true; + await this.applyPostInstallMountsToAllWorkers( + workerPostInstallMountsPort ); } + + await compiled.run(php); } catch (error) { - // Capture the PHP error log details to provide more context for debugging. + // Capture the PHP error log details for debugging. let phpLogs = ''; try { - // @TODO: Don't assume errorLogPath starts with /wordpress/ - // ...or maybe we can assume that in Playground CLI? phpLogs = php.readFileAsText(errorLogPath); } catch { // Ignore errors reading the PHP error log. } (error as any).phpLogs = phpLogs; + + const red = '\x1b[31m'; + const bold = '\x1b[1m'; + const reset = '\x1b[0m'; + output.stderr( + `${red}${bold}Error:${reset} ${ + error instanceof Error ? error.message : String(error) + }\n` + ); throw error; } finally { reap(); diff --git a/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v2.ts b/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v2.ts index be6c2d8117a..d906f7c577b 100644 --- a/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v2.ts +++ b/packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v2.ts @@ -4,8 +4,11 @@ import { PlaygroundWorkerEndpoint, type WorkerBootOptions, } from './playground-worker-endpoint'; -import { runBlueprintV2 } from '@wp-playground/blueprints'; -import type { BlueprintV2Declaration } from '@wp-playground/blueprints'; +import { + compileBlueprintV2, + type BlueprintV2Declaration, +} from '@wp-playground/blueprints'; +import { ProgressTracker } from '@php-wasm/progress'; /* @ts-ignore */ import { corsProxyUrl as defaultCorsProxyUrl } from 'virtual:cors-proxy-url'; @@ -58,18 +61,35 @@ class PlaygroundWorkerEndpointV2 extends PlaygroundWorkerEndpoint { ); } - const streamed = await runBlueprintV2({ - php: primaryPhp, - cliArgs: ['--site-url=' + siteUrl], - blueprint: blueprint as BlueprintV2Declaration, - onMessage: async (message: any) => { - this.dispatchEvent({ - type: 'blueprint.message', - message, - }); + const progress = new ProgressTracker(); + progress.addEventListener('progress', ((event: CustomEvent) => { + this.dispatchEvent({ + type: 'blueprint.message', + message: { + type: 'blueprint.progress', + progress: event.detail.progress * 100, + caption: event.detail.caption ?? '', + }, + }); + }) as EventListener); + + const compiled = await compileBlueprintV2( + blueprint as BlueprintV2Declaration, + { + progress, + corsProxy: corsProxyUrl, + } + ); + + this.dispatchEvent({ + type: 'blueprint.message', + message: { + type: 'blueprint.target_resolved', + runtimeConfig: compiled.runtimeConfig, }, }); - await streamed.finished; + + await compiled.run(primaryPhp); await this.finalizeAfterBoot( requestHandler,