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
2 changes: 1 addition & 1 deletion php-transformer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ The result envelope includes generic `metrics` for wrapper reporting: `input_byt

`source_reports.html.source_provenance` exposes bounded source context for converted blocks: selector, safe source attributes, sanitized source fragment, ancestor context, nearby heading text, safe `data-*` attributes, and generic structure hints such as card-like or grid-like wrappers. `source_reports.html.structure_signals` records those card/grid/static layout hints separately so callers can inspect them without relying on block attributes.

`source_reports.conversion_report` exposes a compact generic projection for wrappers that previously reconstructed report slices from lower-level result fields. It includes fallback diagnostics, sanitized fallback context, event attribute projections, source/selector summaries, asset references, navigation candidates, presentation and structure signals, and metrics. It remains product-neutral: callers still own route rewrites, media imports, theme assembly, navigation entity creation, visual repair policy, and acceptance gates.
`source_reports.conversion_report` exposes a compact generic projection for wrappers that previously reconstructed report slices from lower-level result fields. It includes fallback diagnostics, sanitized fallback context, event attribute projections, source/selector summaries, asset references, navigation candidates, presentation and structure signals, and metrics. `source_reports.materialization_plan` exposes generic site-structure planning rows for routes, navigation links, and menus using source paths, target paths/slugs, titles/labels, parent/source relations, order, and kind. These reports remain product-neutral: callers still own route rewrites, media imports, theme assembly, navigation entity creation, visual repair policy, and acceptance gates.

`HtmlTransformer` preserves syntax-highlight spans inside `<pre><code>` when they use safe inline tags and bounded attributes, while plain code remains escaped as text. Figure-wrapped testimonials and quote shapes are normalized to core quote or pullquote blocks with attribution from `cite`, `footer`, or `figcaption` content.

Expand Down
2 changes: 2 additions & 0 deletions php-transformer/docs/contracts/result-envelope.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Artifact compiler callers that need a compiled site view should read `source_rep

Artifact compiler callers that write compiled output should read `source_reports.materialization_plan`. Its `assets` list is product-neutral write intent: each row may include `source`, `path`, `target_path`, `kind`, `role`, `intent`, `media_type`, `mime_type`, `bytes`, `binary`, `content_encoding`, `content`, `content_base64`, and `hash`. `target_path` defaults to the normalized artifact-relative `path`; downstream materializers own any product-specific routing, upload, or rewrite policy. Text rows expose `content` with `content_encoding: "text"`; binary rows expose `content_base64` with `content_encoding: "base64"` when the payload is available. Unsafe SVG text is withheld from write rows and reported through diagnostics.

The materialization plan also includes product-neutral `routes`, `navigation_links`, and `menus` rows derived from compiled pages and navigation markup. These rows use source and target fields such as `source_path`, `target_path`, `target_slug`, `title`, `label`, `parent_source_path`, `source_relation`, `order`, and `kind`; they do not assign WordPress post IDs, terms, menu locations, or persistence policy. Product adapters remain responsible for deciding whether and how to create pages, menus, navigation entities, route rewrites, and imported assets.

Transformers may expose `source_reports.conversion_report` as a generic `blocks-engine/php-transformer/conversion-report/v1` projection over the same result envelope. It summarizes fallback diagnostics, source paths/selectors, artifact-local asset references, navigation candidates, presentation-gap signals, and metrics without applying product rewrite, import, routing, or visual-parity policy.

`components` contains inspectable component candidates discovered before materialization. `block_types` contains generated block type artifacts promoted from `block.json` roots. `legacy_mapping` is transitional metadata for consumer-owned migration mappers and is not a long-term package feature commitment.
Expand Down
232 changes: 231 additions & 1 deletion php-transformer/src/StaticSite/MaterializationPlanBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public function fromCompiledSite(array $compiledSite): array
$templateParts = $this->templateParts((array) ($compiledSite['template_parts'] ?? array()));
$assets = $this->assets((array) ($compiledSite['assets'] ?? array()));
$visualRepair = is_array($compiledSite['visual_repair'] ?? null) ? $compiledSite['visual_repair'] : array();
$routes = $this->routes($pages);
$navigationLinks = $this->navigationLinks($pages, $templateParts, $routes);
$menus = $this->menus($navigationLinks);
$assetRewriteCandidates = $this->assetRewriteCandidates($pages, $templateParts, $assets);

$plan = array(
Expand All @@ -37,6 +40,9 @@ public function fromCompiledSite(array $compiledSite): array
'source_hash' => (string) ($compiledSite['source_hash'] ?? ''),
'entry_path' => (string) ($compiledSite['entry_path'] ?? ''),
'pages' => $pages,
'routes' => $routes,
'navigation_links' => $navigationLinks,
'menus' => $menus,
'template_parts' => $templateParts,
'template_part_writes' => $this->templatePartWrites($templateParts),
'assets' => $assets,
Expand All @@ -47,6 +53,9 @@ public function fromCompiledSite(array $compiledSite): array
'products' => is_array($compiledSite['products'] ?? null) ? $compiledSite['products'] : array(),
'totals' => array(
'pages' => count($pages),
'routes' => count($routes),
'navigation_links' => count($navigationLinks),
'menus' => count($menus),
'template_parts' => count($templateParts),
'assets' => count($assets),
),
Expand All @@ -67,13 +76,16 @@ private function emptyPlan(): array
return array(
'schema' => self::SCHEMA,
'pages' => array(),
'routes' => array(),
'navigation_links' => array(),
'menus' => array(),
'template_parts' => array(),
'template_part_writes' => array(),
'assets' => array(),
'theme' => array(),
'asset_rewrite_candidates' => array(),
'rewrite_candidates' => array(),
'totals' => array('pages' => 0, 'template_parts' => 0, 'assets' => 0),
'totals' => array('pages' => 0, 'routes' => 0, 'navigation_links' => 0, 'menus' => 0, 'template_parts' => 0, 'assets' => 0),
);
}

Expand Down Expand Up @@ -102,6 +114,108 @@ private function pages(array $pages): array
return $planned;
}

/**
* @param array<int,array<string,mixed>> $pages
* @return array<int,array<string,mixed>>
*/
private function routes(array $pages): array
{
$routes = array();
foreach ( $pages as $index => $page ) {
$sourcePath = (string) ($page['source_path'] ?? '');
$targetSlug = (string) ($page['slug'] ?? '');
if ( '' === $targetSlug && '' !== $sourcePath ) {
$targetSlug = $this->slugFromPath($sourcePath);
}

$routes[] = array_filter(array(
'kind' => 'route',
'source_path' => $sourcePath,
'target_path' => $this->routePath($page),
'target_slug' => $targetSlug,
'title' => (string) ($page['title'] ?? ''),
'parent_source_path' => (string) (($page['metadata']['parent_source_path'] ?? '') ?: ''),
'source_relation' => ! empty($page['entrypoint']) ? 'entrypoint' : 'document',
'order' => $index,
), static fn (mixed $value): bool => '' !== $value);
}

return $routes;
}

/**
* @param array<int,array<string,mixed>> $pages
* @param array<int,array<string,mixed>> $templateParts
* @param array<int,array<string,mixed>> $routes
* @return array<int,array<string,mixed>>
*/
private function navigationLinks(array $pages, array $templateParts, array $routes): array
{
$links = array();
$targetSlugsByPath = array();
foreach ( $routes as $route ) {
$targetPath = (string) ($route['target_path'] ?? '');
if ( '' !== $targetPath ) {
$targetSlugsByPath[$targetPath] = (string) ($route['target_slug'] ?? '');
}
}

foreach ( array('template_part' => $templateParts, 'page' => $pages) as $kind => $documents ) {
foreach ( $documents as $document ) {
$sourcePath = (string) ($document['source_path'] ?? '');
$sourceRelation = 'template_part' === $kind ? 'template_part_navigation' : 'page_navigation';
foreach ( $this->anchorLinks((string) ($document['block_markup'] ?? '')) as $index => $anchor ) {
$targetPath = $this->targetPathFromHref($anchor['href']);
$links[] = array_filter(array(
'kind' => 'navigation_link',
'source_path' => $sourcePath,
'menu_source_path' => $sourcePath,
'target_path' => $targetPath,
'target_slug' => $targetSlugsByPath[$targetPath] ?? $this->slugFromHref($anchor['href']),
'label' => $anchor['label'],
'title' => $anchor['label'],
'parent_source_path' => '',
'source_relation' => $sourceRelation,
'order' => $index,
), static fn (mixed $value): bool => '' !== $value);
}
}
}

return $this->dedupeRows($links);
}

/**
* @param array<int,array<string,mixed>> $navigationLinks
* @return array<int,array<string,mixed>>
*/
private function menus(array $navigationLinks): array
{
$menus = array();
$orders = array();
foreach ( $navigationLinks as $link ) {
$sourcePath = (string) ($link['menu_source_path'] ?? $link['source_path'] ?? '');
if ( '' === $sourcePath ) {
continue;
}
if ( ! isset($menus[$sourcePath]) ) {
$orders[$sourcePath] = count($orders);
$menus[$sourcePath] = array(
'kind' => 'menu',
'source_path' => $sourcePath,
'target_slug' => $this->slugFromPath($sourcePath),
'title' => $this->titleFromPath($sourcePath),
'source_relation' => 'navigation_links',
'order' => $orders[$sourcePath],
'items' => 0,
);
}
++$menus[$sourcePath]['items'];
}

return array_values($menus);
}

/**
* @param array<int,mixed> $templateParts
* @return array<int,array<string,mixed>>
Expand Down Expand Up @@ -245,4 +359,120 @@ private function assetRewriteCandidates(array $pages, array $templateParts, arra

return $candidates;
}

/**
* @param array<string,mixed> $page
*/
private function routePath(array $page): string
{
$sourcePath = (string) ($page['source_path'] ?? '');
$slug = (string) ($page['slug'] ?? '');
if ( ! empty($page['entrypoint']) || preg_match('#(^|/)index\.[A-Za-z0-9]+$#', $sourcePath) ) {
return '/';
}

return '/' . trim('' !== $slug ? $slug : $this->slugFromPath($sourcePath), '/');
}

/**
* @return array<int,array{href:string,label:string}>
*/
private function anchorLinks(string $markup): array
{
if ( '' === trim($markup) || ! preg_match_all('/<nav\b[^>]*>(.*?)<\/nav>/is', $markup, $navMatches) ) {
return array();
}

$links = array();
foreach ( $navMatches[1] as $navHtml ) {
if ( ! preg_match_all('/<a\b([^>]*)>(.*?)<\/a>/is', (string) $navHtml, $anchorMatches, PREG_SET_ORDER) ) {
continue;
}
foreach ( $anchorMatches as $anchorMatch ) {
$href = $this->attributeValue((string) $anchorMatch[1], 'href');
$label = trim(html_entity_decode(strip_tags((string) $anchorMatch[2]), ENT_QUOTES | ENT_HTML5));
if ( '' === $href || '' === $label ) {
continue;
}
$links[] = array(
'href' => $href,
'label' => $label,
);
}
}

return $links;
}

private function attributeValue(string $attributes, string $name): string
{
if ( preg_match('/(?:^|\s)' . preg_quote($name, '/') . '\s*=\s*(["\'])(.*?)\1/is', $attributes, $match) ) {
return html_entity_decode((string) $match[2], ENT_QUOTES | ENT_HTML5);
}

if ( preg_match('/(?:^|\s)' . preg_quote($name, '/') . '\s*=\s*([^\s"\'>]+)/is', $attributes, $match) ) {
return html_entity_decode((string) $match[1], ENT_QUOTES | ENT_HTML5);
}

return '';
}

private function targetPathFromHref(string $href): string
{
$path = (string) (parse_url($href, PHP_URL_PATH) ?: '');
if ( '' === $path ) {
return '';
}

$path = '/' . ltrim($path, '/');
$path = preg_replace('#/index\.[A-Za-z0-9]+$#', '/', $path) ?? $path;
$path = preg_replace('/\.[A-Za-z0-9]+$/', '', $path) ?? $path;
if ( '/' !== $path ) {
$path = rtrim($path, '/');
}

return '' === $path ? '/' : $path;
}

private function slugFromHref(string $href): string
{
$targetPath = $this->targetPathFromHref($href);
if ( '/' === $targetPath ) {
return 'index';
}

return $this->slugFromPath($targetPath);
}

private function slugFromPath(string $path): string
{
$base = preg_replace('/\.[A-Za-z0-9]+$/', '', basename($path));
$base = '' === $base || null === $base ? 'document' : $base;
return strtolower((string) preg_replace('/[^a-z0-9-]+/', '-', str_replace(array('_', '.'), '-', $base)));
}

private function titleFromPath(string $path): string
{
return ucwords(str_replace('-', ' ', $this->slugFromPath($path)));
}

/**
* @param array<int,array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
private function dedupeRows(array $rows): array
{
$deduped = array();
$seen = array();
foreach ( $rows as $row ) {
$key = json_encode($row, JSON_UNESCAPED_SLASHES);
if ( ! is_string($key) || isset($seen[$key]) ) {
continue;
}
$seen[$key] = true;
$deduped[] = $row;
}

return $deduped;
}
}
10 changes: 9 additions & 1 deletion php-transformer/tests/contract/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ function serialize_blocks(array $blocks): string
'entrypoint' => 'index.html',
'files' => array(
'index.html' => '<main><img src="assets/logo.png" alt="Logo"></main>',
'parts/header.html' => '<header><img src="assets/logo.png" alt="Logo"></header>',
'parts/header.html' => '<header><nav><a href="/">Home</a><a href="/about.html">About</a></nav><img src="assets/logo.png" alt="Logo"></header>',
'about.html' => '<main><h1>About</h1></main>',
'assets/logo.png' => array(
'content_base64' => base64_encode("\x89PNG\r\n\x1a\n"),
'mime_type' => 'image/png',
Expand All @@ -153,6 +154,13 @@ function serialize_blocks(array $blocks): string
$assert('wp_template_part' === ($staticPlan['template_part_writes'][0]['type'] ?? ''), 'template part writes identify the WordPress write target');
$assert(str_contains((string) ($staticPlan['visual_repair_css'] ?? ''), 'min-height:100vh'), 'materialization plan exposes visual repair CSS');
$assert(! empty(array_filter($staticPlan['asset_rewrite_candidates'] ?? array(), static fn (array $candidate): bool => 'template_part' === ($candidate['scope'] ?? '') && 'assets/logo.png' === ($candidate['asset_path'] ?? ''))), 'materialization plan exposes template part asset rewrite candidates');
$assert('/' === ($staticPlan['routes'][0]['target_path'] ?? ''), 'materialization plan exposes entry route path');
$assert('/about' === ($staticPlan['routes'][1]['target_path'] ?? ''), 'materialization plan exposes document route path');
$assert('navigation_link' === ($staticPlan['navigation_links'][0]['kind'] ?? ''), 'materialization plan exposes generic navigation link rows');
$assert('About' === ($staticPlan['navigation_links'][1]['label'] ?? ''), 'materialization plan exposes navigation link labels');
$assert('/about' === ($staticPlan['navigation_links'][1]['target_path'] ?? ''), 'materialization plan exposes navigation target paths');
$assert('menu' === ($staticPlan['menus'][0]['kind'] ?? ''), 'materialization plan exposes generic menu rows');
$assert(2 === ($staticPlan['menus'][0]['items'] ?? null), 'materialization plan counts menu items');
$logoAssetPlanRow = null;
$cssAssetPlanRow = null;
foreach ( $staticPlan['assets'] ?? array() as $assetPlanRow ) {
Expand Down
Loading
Loading