From 815578e797b0d73604f6416afb2450def34fadd0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 21 Jun 2026 11:34:12 -0400 Subject: [PATCH] Add generic routing materialization plan rows --- php-transformer/README.md | 2 +- .../docs/contracts/result-envelope.md | 2 + .../StaticSite/MaterializationPlanBuilder.php | 232 +++++++++++++++++- php-transformer/tests/contract/run.php | 10 +- .../parity/compiled-site-contract.json | 19 +- 5 files changed, 261 insertions(+), 4 deletions(-) diff --git a/php-transformer/README.md b/php-transformer/README.md index 29484f3..562816a 100644 --- a/php-transformer/README.md +++ b/php-transformer/README.md @@ -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 `
` 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.
 
diff --git a/php-transformer/docs/contracts/result-envelope.md b/php-transformer/docs/contracts/result-envelope.md
index f10222a..ca01de5 100644
--- a/php-transformer/docs/contracts/result-envelope.md
+++ b/php-transformer/docs/contracts/result-envelope.md
@@ -36,6 +36,8 @@ Required top-level keys for successful transformations:
 
 Artifact compiler callers that need a compiled site view should read `source_reports.compiled_site`. It exposes a generic `blocks-engine/php-transformer/compiled-site/v1` report with normalized `pages`, per-page `metadata`, full page `block_markup`, `assets`, `template_parts`, `visual_repair`, and `theme` buckets for stylesheets, scripts, fonts, images, template parts, and generated block types. Product adapters should map from this report plus the top-level `documents` and `assets` arrays instead of depending on product-specific artifact semantics.
 
+Artifact compiler callers may also read `source_reports.materialization_plan` for generic site-structure planning. The plan 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.
diff --git a/php-transformer/src/StaticSite/MaterializationPlanBuilder.php b/php-transformer/src/StaticSite/MaterializationPlanBuilder.php
index a060bd8..8736a5f 100644
--- a/php-transformer/src/StaticSite/MaterializationPlanBuilder.php
+++ b/php-transformer/src/StaticSite/MaterializationPlanBuilder.php
@@ -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(
@@ -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,
@@ -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),
             ),
@@ -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),
         );
     }
 
@@ -102,6 +114,108 @@ private function pages(array $pages): array
         return $planned;
     }
 
+    /**
+     * @param array> $pages
+     * @return array>
+     */
+    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> $pages
+     * @param array> $templateParts
+     * @param array> $routes
+     * @return array>
+     */
+    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> $navigationLinks
+     * @return array>
+     */
+    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 $templateParts
      * @return array>
@@ -238,4 +352,120 @@ private function assetRewriteCandidates(array $pages, array $templateParts, arra
 
         return $candidates;
     }
+
+    /**
+     * @param array $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
+     */
+    private function anchorLinks(string $markup): array
+    {
+        if ( '' === trim($markup) || ! preg_match_all('/]*>(.*?)<\/nav>/is', $markup, $navMatches) ) {
+            return array();
+        }
+
+        $links = array();
+        foreach ( $navMatches[1] as $navHtml ) {
+            if ( ! preg_match_all('/]*)>(.*?)<\/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> $rows
+     * @return array>
+     */
+    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;
+    }
 }
diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php
index b632baa..04562fc 100644
--- a/php-transformer/tests/contract/run.php
+++ b/php-transformer/tests/contract/run.php
@@ -139,7 +139,8 @@ function serialize_blocks(array $blocks): string
         'entrypoint' => 'index.html',
         'files'      => array(
             'index.html' => '
Logo
', - 'parts/header.html' => '
Logo
', + 'parts/header.html' => '
Logo
', + 'about.html' => '

About

', 'assets/logo.png' => array( 'content_base64' => base64_encode("\x89PNG\r\n\x1a\n"), 'mime_type' => 'image/png', @@ -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'); $productsPlan = ( new MaterializationPlanBuilder() )->fromCompiledSite( array( diff --git a/php-transformer/tests/fixtures/parity/compiled-site-contract.json b/php-transformer/tests/fixtures/parity/compiled-site-contract.json index 6d2d30d..38e33c4 100644 --- a/php-transformer/tests/fixtures/parity/compiled-site-contract.json +++ b/php-transformer/tests/fixtures/parity/compiled-site-contract.json @@ -26,7 +26,7 @@ }, { "path": "public/parts/header.html", - "content": "
", + "content": "
", "role": "template-part" }, { @@ -79,6 +79,23 @@ { "path": "source_reports.compiled_site.theme.template_parts.0", "assert": "equals", "value": "public/parts/header.html" }, { "path": "source_reports.compiled_site.template_parts.0.area", "assert": "equals", "value": "header" }, { "path": "source_reports.compiled_site.template_parts.0.block_markup", "assert": "contains", "value": "
" }, + { "path": "source_reports.materialization_plan.routes", "assert": "count", "count": 3 }, + { "path": "source_reports.materialization_plan.routes.0.kind", "assert": "equals", "value": "route" }, + { "path": "source_reports.materialization_plan.routes.0.source_path", "assert": "equals", "value": "public/index.html" }, + { "path": "source_reports.materialization_plan.routes.0.target_path", "assert": "equals", "value": "/" }, + { "path": "source_reports.materialization_plan.routes.1.target_path", "assert": "equals", "value": "/about" }, + { "path": "source_reports.materialization_plan.routes.2.target_slug", "assert": "equals", "value": "guide" }, + { "path": "source_reports.materialization_plan.navigation_links", "assert": "count", "count": 3 }, + { "path": "source_reports.materialization_plan.navigation_links.0.kind", "assert": "equals", "value": "navigation_link" }, + { "path": "source_reports.materialization_plan.navigation_links.0.source_path", "assert": "equals", "value": "public/parts/header.html" }, + { "path": "source_reports.materialization_plan.navigation_links.0.target_path", "assert": "equals", "value": "/" }, + { "path": "source_reports.materialization_plan.navigation_links.1.label", "assert": "equals", "value": "About" }, + { "path": "source_reports.materialization_plan.navigation_links.1.target_slug", "assert": "equals", "value": "about" }, + { "path": "source_reports.materialization_plan.navigation_links.2.target_path", "assert": "equals", "value": "/guide" }, + { "path": "source_reports.materialization_plan.menus", "assert": "count", "count": 1 }, + { "path": "source_reports.materialization_plan.menus.0.kind", "assert": "equals", "value": "menu" }, + { "path": "source_reports.materialization_plan.menus.0.source_path", "assert": "equals", "value": "public/parts/header.html" }, + { "path": "source_reports.materialization_plan.menus.0.items", "assert": "equals", "value": 3 }, { "path": "source_reports.compiled_site.visual_repair.stylesheets.0.path", "assert": "equals", "value": "public/assets/visual-repair.css" }, { "path": "source_reports.compiled_site.visual_repair.css", "assert": "contains", "value": "min-height:100vh" }, { "path": "source_reports.conversion_report.presentation_gaps.0.type", "assert": "equals", "value": "presentation_stylesheet" },