Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions php-transformer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ The artifact compiler accepts loose generated-site bundles and normalizes them i

Unsupported or unsafe artifact inputs are reported through diagnostics instead of hidden best-effort behavior. Empty, absolute, or root-escaping paths are rejected; oversized files are ignored according to the source report limits; and a bundle with neither an HTML entry nor source documents fails with `missing_entry_html`.

## WordPress Site Plans

`WordPressSitePlan` projects a self-contained `blocks-engine/wordpress-site-plan/v2` materialization contract from a compiler result. Its normalized document metadata and generic reporting summaries let a consumer project reports from a resolved plan plus its own receipt, without reading the compiler envelope or source files. See [WordPress Site Plan v2](docs/wordpress-site-plan.md) for the metadata shape, ordering, supported attributes, and resolver boundary.

## Parity Checks

Run the package contract, parity fixtures, and clean package-install proof with `composer test`. The checked-in fixtures assert current transformer behavior, and the install proof verifies that Composer can install `automattic/blocks-engine-php-transformer` from the `php-transformer/` package root without symlinking back to the working tree.
Expand Down
53 changes: 53 additions & 0 deletions php-transformer/docs/wordpress-site-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# WordPress Site Plan v2

`blocks-engine/wordpress-site-plan/v2` is a destination-independent materialization contract. A consumer resolves it with `WordPressSitePlanResolver` and combines the resolved plan with its own materialization receipt. Destination IDs, paths outside declared writes, and product report formats remain consumer-owned.

## Document Metadata

Every page and template part has `document_metadata`. It is normalized compiler output, not source HTML:

```php
array(
'source_context' => array('source_path' => 'nested/about.html', 'kind' => 'html'),
'title' => 'About',
'title_declaration' => array('order' => 0, 'placement' => 'head'),
'meta' => array(),
'links' => array(),
'scripts' => array(),
)
```

`meta`, `links`, and `scripts` are ordered source rows. Their zero-based `order` equals their array index. `placement` is `head` or `body`; `title_declaration` always has `order: 0` and `placement: head`. `source_context` identifies the compiler document that supplied the declarations.

Meta rows preserve `charset`, `name`, `property`, `http_equiv`, and `content`. Link rows preserve `rel`, `type`, `media`, `integrity`, `crossorigin`, `referrerpolicy`, `as`, `fetchpriority`, and `sizes`. Script rows preserve `type`, `integrity`, `crossorigin`, `referrerpolicy`, and `fetchpriority`, plus independent booleans for `async`, `defer`, `module`, and `nomodule`. Explicitly present empty values are retained as `''` so consumers can distinguish them from absent attributes, except `crossorigin`: its empty or boolean HTML state is normalized to `anonymous`, matching browser CORS semantics. `effective_loading` records browser loading semantics: `async` wins over `defer`; non-async module scripts are `defer`; other scripts are `blocking`. Inline scripts carry `source_kind: inline` and `body_hash`, not their source body.

URL-bearing link and external-script declarations contain either an explicit absolute or protocol-relative `url`, or an `asset_reference` token. Local artifact URLs must use `asset_reference`; undeclared local URLs are invalid. Resolver output adds `resolved_url` for each `asset_reference`. That URL is exactly the URL of a declared resolved theme-asset write. Explicit external URLs remain unchanged.

Document metadata is reporting-only. It preserves source declaration facts for consumers that need reports or manifests; it does not alter generated theme bootstrap behavior or claim runtime execution parity.

## Reporting

`reporting` is a compiler-output summary:

```php
array(
'source_documents' => array(
array(
'source_path' => 'index.html',
'kind' => 'html',
'body_format' => 'blocks',
'block_document' => true,
'provenance' => array(),
),
),
'metrics' => array(
'source_document_count' => 1,
'block_document_count' => 1,
'native_block_count' => 0,
'fallback_count' => 0,
),
'diagnostic_codes' => array(),
)
```

It provides generic source-document identity, native/block and fallback metrics, provenance, and diagnostic linkage. It intentionally excludes destination IDs, filesystem locations, and consumer report paths. A consumer can project its own stable report from the resolved plan and a receipt that confirms every declared write and page reconciliation identity.
95 changes: 91 additions & 4 deletions php-transformer/src/ArtifactCompiler/ArtifactCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -1572,11 +1572,58 @@ private function scriptAppendedRootSelectors(string $script): array

private function htmlAttribute(string $tag, string $name): string
{
if ( preg_match('/\s' . preg_quote($name, '/') . '\s*=\s*(["\'])(.*?)\1/is', $tag, $match) ) {
return html_entity_decode((string) $match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
return $this->htmlAttributes($tag)[strtolower($name)] ?? '';
}

return '';
private function hasHtmlAttribute(string $tag, string $name): bool
{
return array_key_exists(strtolower($name), $this->htmlAttributes($tag));
}

/** @return array<string,string> */
private function htmlAttributes(string $tag): array
{
$length = strlen($tag);
$offset = strpos($tag, '<');
if (false === $offset) {
$offset = 0;
} else {
++$offset;
while ($offset < $length && ctype_space($tag[$offset])) ++$offset;
if ($offset < $length && '/' === $tag[$offset]) ++$offset;
while ($offset < $length && !ctype_space($tag[$offset]) && !in_array($tag[$offset], array('>', '/'), true)) ++$offset;
}
$attributes = array();
while ($offset < $length) {
while ($offset < $length && ctype_space($tag[$offset])) ++$offset;
if ($offset >= $length || '>' === $tag[$offset] || '/' === $tag[$offset]) break;
$start = $offset;
while ($offset < $length && !ctype_space($tag[$offset]) && !in_array($tag[$offset], array('=', '>', '/', '"', "'", '<'), true)) ++$offset;
if ($start === $offset) break;
$name = strtolower(substr($tag, $start, $offset - $start));
while ($offset < $length && ctype_space($tag[$offset])) ++$offset;
$value = '';
if ($offset < $length && '=' === $tag[$offset]) {
++$offset;
while ($offset < $length && ctype_space($tag[$offset])) ++$offset;
if ($offset >= $length) break;
if (in_array($tag[$offset], array('"', "'"), true)) {
$quote = $tag[$offset++]; $start = $offset;
while ($offset < $length && $tag[$offset] !== $quote) ++$offset;
if ($offset >= $length) break;
$value = substr($tag, $start, $offset - $start); ++$offset;
} else {
$start = $offset;
while ($offset < $length && !ctype_space($tag[$offset]) && '>' !== $tag[$offset]) {
if (in_array($tag[$offset], array('"', "'", '<'), true)) break 2;
++$offset;
}
$value = substr($tag, $start, $offset - $start);
}
}
if (!isset($attributes[$name])) $attributes[$name] = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
return $attributes;
}

/**
Expand Down Expand Up @@ -1648,6 +1695,7 @@ private function compiledSiteReport(array $artifact, string $entryPath, array $d
'slug' => $slug,
'title' => $title,
'metadata' => $this->documentMetadata($path, 'html', (string) ($file['role'] ?? 'document'), $slug, $title, $bodyFormat),
'document_metadata' => $this->fullDocumentMetadata($content, $path, $artifact['files']),
'html' => $file['content'] ?? '',
'body_format' => $bodyFormat,
'block_markup' => $blockMarkup,
Expand Down Expand Up @@ -1904,6 +1952,45 @@ private function documentMetadata(string $sourcePath, string $kind, string $role
);
}

/** @param array<int, array<string, mixed>> $files @return array<string, mixed> */
private function fullDocumentMetadata(string $html, string $sourcePath, array $files): array
{
$headEnd = preg_match('/<head\b[^>]*>.*?<\/head\s*>/is', $html, $head) ? (int) strpos($html, $head[0]) + strlen($head[0]) : 0;
$reference = function (string $value) use ($sourcePath, $files): array {
$asset = $this->findAssetByHtmlReference($value, $sourcePath, $files);
return is_array($asset) ? array('asset_source_path' => (string) $asset['path']) : array('url' => $value);
};
$attributes = function (string $tag, array $names): array {
$values = array();
foreach ($names as $name) {
if (!$this->hasHtmlAttribute($tag, $name)) continue;
$value = $this->htmlAttribute($tag, $name);
// HTML's empty and invalid-value CORS states both select anonymous.
$values[str_replace('-', '_', $name)] = 'crossorigin' === $name && '' === $value ? 'anonymous' : $value;
}
return $values;
};
$placement = static fn(int $offset): string => $offset < $headEnd ? 'head' : 'body';
$meta = array(); $links = array(); $scripts = array();
if (preg_match_all('/<meta\b[^>]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) foreach ($matches[0] as $match) {
$tag = (string) $match[0];
$row = $attributes($tag, array('charset', 'name', 'property', 'http-equiv', 'content'));
if (array() !== $row) { $row = array_merge(array('order' => count($meta), 'placement' => $placement((int) $match[1])), $row); $meta[] = $row; }
}
if (preg_match_all('/<link\b[^>]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) foreach ($matches[0] as $match) {
$tag = (string) $match[0]; $href = $this->htmlAttribute($tag, 'href');
if ('' === $href) continue;
$links[] = array_merge(array('order' => count($links), 'placement' => $placement((int) $match[1])), $attributes($tag, array('rel', 'type', 'media', 'integrity', 'crossorigin', 'referrerpolicy', 'as', 'fetchpriority', 'sizes')), $reference($href));
}
if (preg_match_all('/<script\b[^>]*>(?:.*?)<\/script\s*>/is', $html, $matches, PREG_OFFSET_CAPTURE)) foreach ($matches[0] as $match) {
$tag = (string) $match[0]; $open = strstr($tag, '>', true) . '>'; $src = $this->htmlAttribute($open, 'src');
$async = $this->hasHtmlAttribute($open, 'async'); $defer = $this->hasHtmlAttribute($open, 'defer'); $module = 'module' === strtolower($this->htmlAttribute($open, 'type'));
$scripts[] = array_merge(array('order' => count($scripts), 'placement' => $placement((int) $match[1]), 'async' => $async, 'defer' => $defer, 'module' => $module, 'nomodule' => $this->hasHtmlAttribute($open, 'nomodule'), 'effective_loading' => $async ? 'async' : (($defer || $module) ? 'defer' : 'blocking')), $attributes($open, array('type', 'integrity', 'crossorigin', 'referrerpolicy', 'fetchpriority')), '' !== $src ? $reference($src) : array('source_kind' => 'inline', 'body_hash' => hash('sha256', (string) preg_replace('/^.*?>|<\/script\s*>$/is', '', $tag))));
}
$title = preg_match('/<title\b[^>]*>(.*?)<\/title\s*>/is', $html, $match) ? trim(html_entity_decode(strip_tags((string) $match[1]), ENT_QUOTES | ENT_HTML5, 'UTF-8')) : $this->titleFromHtml($html, $sourcePath);
return array('source_context' => array('source_path' => $sourcePath, 'kind' => 'html'), 'title' => $title, 'title_declaration' => array('order' => 0, 'placement' => 'head'), 'meta' => $meta, 'links' => $links, 'scripts' => $scripts);
}

/**
* @param array<int, array<string, mixed>> $files
* @return array<int, array<string, mixed>>
Expand Down
Loading
Loading