diff --git a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php index fb0e1547..559ca15f 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactNormalizer.php @@ -99,14 +99,15 @@ public function normalize(array $artifact): array $seenPaths[$path] = true; $mimeType = $this->mimeType((string) ($file['mime_type'] ?? $file['mime'] ?? $file['media_type'] ?? (str_contains((string) ($file['type'] ?? ''), '/') ? $file['type'] : '')), $path); $kind = $this->kind((string) ($file['kind'] ?? $file['type'] ?? ''), $path, $payload['content'], $mimeType); - $role = $this->role((string) ($file['role'] ?? ''), $kind, $mimeType, $path); + $declaredRole = $this->sanitizeKey((string) ($file['role'] ?? '')); + $role = $this->role($declaredRole, $kind, $mimeType, $path); $intent = $this->intent((string) ($file['intent'] ?? ''), $kind, $role); $binary = $payload['binary'] || ( ! $this->isTextKind($kind) && $this->isBinaryMimeType($mimeType) ); $contentBase64 = $payload['content_base64']; if ( $binary && '' === $contentBase64 ) { $contentBase64 = base64_encode($payload['content']); } - $entrypoint = in_array($path, $safeEntrypoints, true) || ! empty($file['entrypoint']) || 'entry' === $role; + $entrypoint = in_array($path, $safeEntrypoints, true) || ! empty($file['entrypoint']) || 'entry' === $declaredRole; if ( $entrypoint && ! in_array($path, $safeEntrypoints, true) ) { $safeEntrypoints[] = $path; } diff --git a/php-transformer/src/StaticSite/MaterializationPlanBuilder.php b/php-transformer/src/StaticSite/MaterializationPlanBuilder.php index ca84e49a..76b0fe1a 100644 --- a/php-transformer/src/StaticSite/MaterializationPlanBuilder.php +++ b/php-transformer/src/StaticSite/MaterializationPlanBuilder.php @@ -37,7 +37,7 @@ 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); + $routes = $this->routes($pages, (string) ($compiledSite['entry_path'] ?? '')); $navigationLinks = $this->navigationLinks($pages, $templateParts, $routes); $menus = $this->menus($navigationLinks); $assetRewriteCandidates = $this->assetRewriteCandidates($pages, $templateParts, $assets); @@ -119,9 +119,10 @@ private function pages(array $pages): array /** * @param array> $pages + * @param string $entryPath * @return array> */ - private function routes(array $pages): array + private function routes(array $pages, string $entryPath = ''): array { $routes = array(); foreach ( $pages as $index => $page ) { @@ -134,7 +135,7 @@ private function routes(array $pages): array $routes[] = array_filter(array( 'kind' => 'route', 'source_path' => $sourcePath, - 'target_path' => $this->routePath($page), + 'target_path' => $this->routePath($page, $entryPath), 'target_slug' => $targetSlug, 'title' => (string) ($page['title'] ?? ''), 'parent_source_path' => (string) (($page['metadata']['parent_source_path'] ?? '') ?: ''), @@ -392,13 +393,23 @@ private function assetRewriteCandidates(array $pages, array $templateParts, arra /** * @param array $page */ - private function routePath(array $page): string + private function routePath(array $page, string $entryPath = ''): string { $sourcePath = (string) ($page['source_path'] ?? ''); $slug = (string) ($page['slug'] ?? ''); - if ( ! empty($page['entrypoint']) || preg_match('#(^|/)index\.[A-Za-z0-9]+$#', $sourcePath) ) { + if ( ! empty($page['entrypoint']) ) { return '/'; } + if ( preg_match('#(^|/)index\.[A-Za-z0-9]+$#', $sourcePath) ) { + $directory = trim((string) pathinfo($sourcePath, PATHINFO_DIRNAME), '.'); + $entryDirectory = trim((string) pathinfo($entryPath, PATHINFO_DIRNAME), '.'); + if ( '' !== $entryDirectory && str_starts_with($directory . '/', $entryDirectory . '/') ) { + $directory = substr($directory, strlen($entryDirectory) + 1); + } + if ( '' !== $directory ) { + return '/' . trim($directory, '/'); + } + } return '/' . trim('' !== $slug ? $slug : $this->slugFromPath($sourcePath), '/'); } diff --git a/php-transformer/tests/contract/wordpress-site-plan.php b/php-transformer/tests/contract/wordpress-site-plan.php index 05c5d241..36a171e7 100644 --- a/php-transformer/tests/contract/wordpress-site-plan.php +++ b/php-transformer/tests/contract/wordpress-site-plan.php @@ -235,6 +235,13 @@ $packagedPages = array(); foreach ($packagedRoutes['pages'] as $page) $packagedPages[$page['source_path']] = $page; $packagedCreates = array(); foreach ($packagedRoutes['operations'] as $operation) if ('create_page' === ($operation['kind'] ?? null)) $packagedCreates[$operation['source_path']] = $operation; $assert('/' === ($packagedPages['website/index.html']['route']['path'] ?? null) && '/contact' === ($packagedPages['website/contact.html']['route']['path'] ?? null) && '' === ($packagedPages['website/contact.html']['parent_source_path'] ?? null) && '/contact' === ($packagedCreates['website/contact.html']['route_path'] ?? null) && str_contains((string) ($packagedPages['website/index.html']['canonical_block_markup'] ?? ''), 'href="/contact"'), 'The entrypoint directory is an artifact packaging root for routes and root-relative document links, not a public route segment or phantom page parent.'); +$nestedEntrypointLast = (new ArtifactCompiler())->compile(array('entrypoint' => 'website/index.html', 'files' => array(array('path' => 'website/api/index.html', 'content' => '
API
'), array('path' => 'website/guides/index.html', 'content' => '
Guides
'), array('path' => 'website/index.html', 'content' => '
Home
'))))->toArray(); +$nestedEntrypointLastPlan = $nestedEntrypointLast['source_reports']['materialization_plan'] ?? array(); +$nestedEntrypointLastPages = array(); foreach ($nestedEntrypointLastPlan['pages'] ?? array() as $page) $nestedEntrypointLastPages[$page['source_path']] = $page; +$nestedEntrypointLastRoutes = array(); foreach ($nestedEntrypointLastPlan['routes'] ?? array() as $route) $nestedEntrypointLastRoutes[$route['source_path']] = $route['target_path']; +$assert('website/index.html' === ($nestedEntrypointLast['source_reports']['artifact']['entry_path'] ?? null) && array('website/index.html') === ($nestedEntrypointLast['source_reports']['artifact']['entrypoints'] ?? null) && true === ($nestedEntrypointLastPages['website/index.html']['entrypoint'] ?? null) && false === ($nestedEntrypointLastPages['website/api/index.html']['entrypoint'] ?? null) && false === ($nestedEntrypointLastPages['website/guides/index.html']['entrypoint'] ?? null) && 'entry' === ($nestedEntrypointLastPages['website/api/index.html']['metadata']['role'] ?? null) && 'entry' === ($nestedEntrypointLastPages['website/guides/index.html']['metadata']['role'] ?? null) && '/' === ($nestedEntrypointLastRoutes['website/index.html'] ?? null) && '/api' === ($nestedEntrypointLastRoutes['website/api/index.html'] ?? null) && '/guides' === ($nestedEntrypointLastRoutes['website/guides/index.html'] ?? null), 'Artifact-declared entrypoints remain authoritative when ordered last; inferred nested index roles remain documents with distinct materialization routes.'); +$explicitFileEntrypoint = (new ArtifactCompiler())->compile(array('files' => array(array('path' => 'website/index.html', 'content' => '
Home
'), array('path' => 'website/admin.html', 'content' => '
Admin
', 'entrypoint' => true), array('path' => 'website/preview.html', 'content' => '
Preview
', 'role' => 'entry'))))->toArray(); +$assert(array('website/admin.html', 'website/preview.html') === ($explicitFileEntrypoint['source_reports']['artifact']['entrypoints'] ?? null), 'Explicit per-file entrypoint flags and entry roles remain supported without an artifact-level declaration.'); $outsidePackagedRoot = (new ArtifactCompiler())->compile(array('entrypoint' => 'website/index.html', 'files' => array('website/index.html' => '
Home
', 'other/contact.html' => '
Outside
')))->toArray(); $assert(isset($outsidePackagedRoot['source_reports']['wordpress_site_plan_diagnostics']), 'HTML documents outside a nested entrypoint content root fail closed instead of leaking into public routes.'); $forgedSyntheticPage = $packagedRoutes; foreach ($forgedSyntheticPage['pages'] as &$page) if ('website/contact.html' === ($page['source_path'] ?? null)) $page['synthetic'] = true; unset($page);