From 1ca336c5e53b864b03c639ad88ebbbbfa7d7d38e Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sun, 7 Jun 2026 10:55:53 +0300 Subject: [PATCH] Fix inline tag links for nested pages --- config/common/di/content-pipeline.php | 1 + docs/deployment.md | 2 +- docs/templates.md | 25 ++- psalm-baseline.xml | 38 ---- src/Build/Asset.php | 2 +- src/Build/AuthorPageWriter.php | 8 +- src/Build/CollectionListingWriter.php | 8 +- src/Build/DateArchiveWriter.php | 10 +- src/Build/EntryRenderer.php | 13 +- src/Build/FeedGenerator.php | 12 +- src/Build/MetaTagsBuilder.php | 19 +- src/Build/NotFoundPageWriter.php | 27 +-- src/Build/PageActionUrlFormatter.php | 12 +- src/Build/PublicUrlResolver.php | 131 +---------- src/Build/RedirectPageWriter.php | 4 +- src/Build/RelativePathHelper.php | 11 +- src/Build/RobotsTxtGenerator.php | 4 +- src/Build/SitemapGenerator.php | 14 +- src/Build/TaxonomyPageWriter.php | 6 +- src/Build/TemplateHelpers.php | 5 + src/Build/UrlResolver.php | 211 ++++++++++++++++++ src/Content/CrossReferenceResolver.php | 8 +- src/Processor/ContentProcessorPipeline.php | 5 +- .../RootPathAwareProcessorInterface.php | 10 + src/Processor/TagLinkProcessor.php | 10 +- src/Render/NavigationRenderer.php | 4 +- tests/Unit/Build/EntryRendererTest.php | 24 ++ tests/Unit/Build/FeedGeneratorTest.php | 54 +++++ tests/Unit/Build/PublicUrlResolverTest.php | 34 ++- tests/Unit/Console/BuildCommandTest.php | 86 +++++++ themes/minimal/archive_index.php | 3 +- themes/minimal/archive_monthly.php | 3 +- themes/minimal/archive_yearly.php | 3 +- themes/minimal/collection_listing.php | 5 +- themes/minimal/entry.php | 5 +- themes/minimal/errors/404.php | 3 +- themes/minimal/partials/head.php | 5 +- themes/minimal/partials/header.php | 5 +- themes/minimal/taxonomy_index.php | 3 +- 39 files changed, 546 insertions(+), 287 deletions(-) create mode 100644 src/Build/UrlResolver.php create mode 100644 src/Processor/RootPathAwareProcessorInterface.php diff --git a/config/common/di/content-pipeline.php b/config/common/di/content-pipeline.php index a6c930f..0a1ca6d 100644 --- a/config/common/di/content-pipeline.php +++ b/config/common/di/content-pipeline.php @@ -50,6 +50,7 @@ 'class' => ContentProcessorPipeline::class, '__construct()' => [ Reference::to(MarkdownProcessor::class), + Reference::to(TagLinkProcessor::class), ], ], BuildCommand::class => [ diff --git a/docs/deployment.md b/docs/deployment.md index 5df0f74..18906d2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -88,7 +88,7 @@ Enable GitHub Pages in your repository settings: go to **Settings → Pages** an > **Tip:** Replace `X.Y.Z` with a real YiiPress version tag for reproducible, stable builds. The action builds to `_site` by default for GitHub Pages. See [GitHub Actions](github-actions.md) for custom output directories and other inputs. -For project sites such as `https://user.github.io/project/`, set `base_url` to the full deployed URL including the project path. YiiPress uses that path when rendering root-relative redirect targets, so `redirect_to: /blog/` points to `/project/blog/` in browser-facing redirect HTML. Root-relative local asset links in rendered HTML are emitted relative to each page so images and other copied assets stay valid under the same deployment path. +For project sites such as `https://user.github.io/project/`, set `base_url` to the full deployed URL including the project path. YiiPress uses that path when rendering root-relative redirect targets, so `redirect_to: /blog/` points to `/project/blog/` in browser-facing redirect HTML. Internal links generated by built-in templates and processors are emitted relative to each page, while feeds, sitemaps, canonical URLs, and redirect pages use absolute or browser-facing URLs with the configured project path. Custom templates should use the `$url()` helper for internal links and `Asset::url()` for copied assets. > **Real-world example:** YiiPress documentation is built from the nightly binary image after the package workflow succeeds — see [`.github/workflows/build-docs.yml`](https://github.com/yiipress/engine/blob/master/.github/workflows/build-docs.yml) in this repository. Site repositories should use the release action above with a fixed version. diff --git a/docs/templates.md b/docs/templates.md index b9e3454..6f3c8ea 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -232,7 +232,7 @@ Example:

``` @@ -324,7 +324,7 @@ Partials are reusable template fragments stored in a `partials/` subdirectory of ### Usage ```php - $entryTitle . ' — ' . $siteTitle]) ?> + $entryTitle . ' — ' . $siteTitle, 'rootPath' => $rootPath]) ?> ``` ## Asset helper @@ -354,12 +354,19 @@ Create a PHP file in `themes//partials/`: ```php <?= $h($title) ?> - + ``` ### Variable isolation @@ -399,6 +406,16 @@ All templates receive the following helper functions as local variables: | `$partial` | `(string $name, array $variables = []): string` | Render a partial template from the `partials/` directory | | `$h` | `(string $string, int $flags = ENT_QUOTES | ENT_SUBSTITUTE, ?string $encoding = 'UTF-8', bool $doubleEncode = true): string` | Escape HTML output | | `$t` | `(string $key, array $params = []): string` | Translate a theme UI-text key via the injected `$ui` | +| `$url` | `(string $path): string` | Build an internal site URL relative to the current output page root | + +Use `$url()` for internal links generated by templates: + +```php +#php +Home +``` + +It keeps links valid for subdirectory deployments such as GitHub Pages project sites. Additional helpers available via static methods: diff --git a/psalm-baseline.xml b/psalm-baseline.xml index e1948bf..aecee89 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -87,14 +87,6 @@ - - - - - - templateResolver->resolve('errors/404', $siteConfig->theme)]]> - - @@ -178,36 +170,6 @@ - - - - - - resolvePath($path); - $permalink = $this->fileToPermalink[$resolved] ?? null; - - if ($permalink === null) { - return $matches[0]; - } - - if ($this->currentPermalink !== '') { - $rootPath = RelativePathHelper::rootPath($this->currentPermalink); - $permalink = RelativePathHelper::relativize($permalink, $rootPath); - } - - return '[' . $text . '](' . $permalink . $fragment . ')'; - }, - $markdown, - )]]> - - diff --git a/src/Build/Asset.php b/src/Build/Asset.php index 859e562..d753325 100644 --- a/src/Build/Asset.php +++ b/src/Build/Asset.php @@ -15,7 +15,7 @@ public static function url( $resolved = $assetManifest?->resolve($path) ?? $path; if ($rootPath !== '' && $rootPath !== '/') { - return $rootPath . $resolved; + return UrlResolver::sitePath($resolved, $rootPath); } if ($rootPath === '/') { diff --git a/src/Build/AuthorPageWriter.php b/src/Build/AuthorPageWriter.php index f1cd4d9..4b1519d 100644 --- a/src/Build/AuthorPageWriter.php +++ b/src/Build/AuthorPageWriter.php @@ -111,14 +111,14 @@ private function writeIndexPage( ?Navigation $navigation, bool $noWrite, ): void { - $rootPath = RelativePathHelper::rootPath('/authors/'); + $rootPath = UrlResolver::rootPath('/authors/'); $uiViewData = UiViewData::forSite($siteConfig, $this->templateResolver, $siteConfig->theme); $authorList = []; foreach ($authors as $slug => $author) { $authorList[] = [ 'title' => $author->title, - 'url' => $rootPath . 'authors/' . $slug . '/', + 'url' => UrlResolver::sitePath('/authors/' . $slug . '/', $rootPath), 'avatar' => $author->avatar, ]; } @@ -158,7 +158,7 @@ private function writeAuthorPage( ?Navigation $navigation, bool $noWrite, ): void { - $rootPath = RelativePathHelper::rootPath('/authors/' . $author->slug . '/'); + $rootPath = UrlResolver::rootPath('/authors/' . $author->slug . '/'); $uiViewData = UiViewData::forSite($siteConfig, $this->templateResolver, $siteConfig->theme); $authorTitle = $author->title; @@ -174,7 +174,7 @@ private function writeAuthorPage( foreach ($entries as $entry) { $collection = $collections[$entry->collection] ?? null; $url = $collection !== null - ? RelativePathHelper::relativize(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath) + ? UrlResolver::sitePath(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath) : '#'; $entryData[] = [ diff --git a/src/Build/CollectionListingWriter.php b/src/Build/CollectionListingWriter.php index 95cd82b..ceaaa9c 100644 --- a/src/Build/CollectionListingWriter.php +++ b/src/Build/CollectionListingWriter.php @@ -49,7 +49,7 @@ public function write( $currentPermalink = $pageNumber === 1 ? '/' . $collection->name . '/' : '/' . $collection->name . '/page/' . $pageNumber . '/'; - $rootPath = RelativePathHelper::rootPath($currentPermalink); + $rootPath = UrlResolver::rootPath($currentPermalink); $pagination = [ 'currentPage' => $pageNumber, @@ -117,7 +117,7 @@ private function renderPage( foreach ($entries as $entry) { $entryData[] = [ 'title' => $entry->title, - 'url' => RelativePathHelper::relativize(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath), + 'url' => UrlResolver::sitePath(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath), 'date' => $entry->date?->format($siteConfig->dateFormat) ?? '', 'dateISO' => $entry->date?->format('Y-m-d') ?? '', 'draft' => $entry->draft, @@ -147,9 +147,9 @@ private function resolvePageUrl(string $collectionName, int $pageNumber, int $to } if ($pageNumber === 1) { - return $rootPath . $collectionName . '/'; + return UrlResolver::sitePath('/' . $collectionName . '/', $rootPath); } - return $rootPath . $collectionName . '/page/' . $pageNumber . '/'; + return UrlResolver::sitePath('/' . $collectionName . '/page/' . $pageNumber . '/', $rootPath); } } diff --git a/src/Build/DateArchiveWriter.php b/src/Build/DateArchiveWriter.php index c9b71ab..4916c1c 100644 --- a/src/Build/DateArchiveWriter.php +++ b/src/Build/DateArchiveWriter.php @@ -139,7 +139,7 @@ private function writeArchiveIndexPage( ?Navigation $navigation, bool $noWrite, ): void { - $rootPath = RelativePathHelper::rootPath('/' . $collection->name . '/archive/'); + $rootPath = UrlResolver::rootPath('/' . $collection->name . '/archive/'); $uiViewData = UiViewData::forSite($siteConfig, $this->templateResolver, $siteConfig->theme); $html = $renderer->render('archive_index', [ 'siteTitle' => $siteConfig->title, @@ -179,7 +179,7 @@ private function writeYearlyPage( ?Navigation $navigation, bool $noWrite, ): void { - $rootPath = RelativePathHelper::rootPath('/' . $collection->name . '/' . $year . '/'); + $rootPath = UrlResolver::rootPath('/' . $collection->name . '/' . $year . '/'); $uiViewData = UiViewData::forSite($siteConfig, $this->templateResolver, $siteConfig->theme); rsort($months, SORT_STRING); @@ -188,7 +188,7 @@ private function writeYearlyPage( foreach ($entries as $entry) { $entryData[] = [ 'title' => $entry->title, - 'url' => RelativePathHelper::relativize(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath), + 'url' => UrlResolver::sitePath(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath), 'date' => $entry->date?->format($siteConfig->dateFormat) ?? '', ]; } @@ -234,7 +234,7 @@ private function writeMonthlyPage( ?Navigation $navigation, bool $noWrite, ): void { - $rootPath = RelativePathHelper::rootPath('/' . $collection->name . '/' . $year . '/' . $month . '/'); + $rootPath = UrlResolver::rootPath('/' . $collection->name . '/' . $year . '/' . $month . '/'); $uiViewData = UiViewData::forSite($siteConfig, $this->templateResolver, $siteConfig->theme); $monthName = $uiViewData->ui->monthName((int) $month); @@ -242,7 +242,7 @@ private function writeMonthlyPage( foreach ($entries as $entry) { $entryData[] = [ 'title' => $entry->title, - 'url' => RelativePathHelper::relativize(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath), + 'url' => UrlResolver::sitePath(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath), 'date' => $entry->date?->format($siteConfig->dateFormat) ?? '', ]; } diff --git a/src/Build/EntryRenderer.php b/src/Build/EntryRenderer.php index 2cd34a0..6f6137e 100644 --- a/src/Build/EntryRenderer.php +++ b/src/Build/EntryRenderer.php @@ -64,7 +64,7 @@ public function render( $cacheContext = ''; if ($this->cache !== null) { - $cacheContext = $this->cacheContext($siteConfig, $entry, $navigation, $crossRefResolver, $navigationPager); + $cacheContext = $this->cacheContext($siteConfig, $entry, $permalink, $navigation, $crossRefResolver, $navigationPager); $cached = $this->cache->get($entry->filePath, $cacheContext); if ($cached !== null) { return $this->dispatchRenderFinished($siteConfig, $entry, $permalink, $cached); @@ -79,7 +79,8 @@ public function render( } $body = $resolver->resolve($body); } - $content = $this->pipeline->process($body, $entry); + $rootPath = UrlResolver::rootPath($permalink); + $content = $this->pipeline->process($body, $entry, $rootPath); $headAssets = $this->pipeline->collectHeadAssets($content); $toc = $siteConfig->toc ? $this->pipeline->collectToc() : []; $related = $this->relatedIndex?->forEntry($entry->filePath) ?? []; @@ -108,12 +109,14 @@ private function dispatchRenderFinished(SiteConfig $siteConfig, Entry $entry, st private function cacheContext( SiteConfig $siteConfig, Entry $entry, + string $permalink, ?Navigation $navigation, ?CrossReferenceResolver $crossRefResolver, ?array $navigationPager, ): string { return hash('xxh128', serialize([ 'siteConfig' => $siteConfig, + 'permalink' => $permalink, 'navigation' => $navigation, 'navigationPager' => $navigationPager, 'assets' => $this->assetManifest?->signature() ?? '', @@ -162,7 +165,7 @@ private function renderTemplate(SiteConfig $siteConfig, Entry $entry, string $co } $templateContext = $this->templateContexts[$themeName]; - $rootPath = RelativePathHelper::rootPath($permalink); + $rootPath = UrlResolver::rootPath($permalink); $navigationPager = $this->relativizeNavigationPager($navigationPager, $rootPath); $lastUpdated = $this->lastUpdated($siteConfig, $entry); $editPageUrl = $siteConfig->editPageUrl === null @@ -240,7 +243,7 @@ private function entryAuthors(SiteConfig $siteConfig, Entry $entry, string $root 'slug' => $authorSlug, 'title' => $author instanceof Author ? $author->title : $authorSlug, 'url' => $siteConfig->authorPages && $author instanceof Author - ? $rootPath . 'authors/' . $authorSlug . '/' + ? UrlResolver::sitePath('/authors/' . $authorSlug . '/', $rootPath) : '', ]; } @@ -283,7 +286,7 @@ private function relativizeNavigationPager(?array $navigationPager, string $root foreach (['previous', 'next'] as $key) { if ($navigationPager[$key] !== null && str_starts_with($navigationPager[$key]['url'], '/')) { - $navigationPager[$key]['url'] = RelativePathHelper::relativize($navigationPager[$key]['url'], $rootPath); + $navigationPager[$key]['url'] = UrlResolver::sitePath($navigationPager[$key]['url'], $rootPath); } } diff --git a/src/Build/FeedGenerator.php b/src/Build/FeedGenerator.php index 2b4f664..6d0a394 100644 --- a/src/Build/FeedGenerator.php +++ b/src/Build/FeedGenerator.php @@ -213,7 +213,7 @@ private function writeAtomEntry( $xml->writeElement('summary', $summary); } - $html = $this->renderedContent($entry); + $html = $this->renderedContent($siteConfig, $entry); if ($html !== '') { $xml->startElement('content'); $xml->writeAttribute('type', 'html'); @@ -246,7 +246,7 @@ private function writeRssItem( $xml->writeElement('description', $summary); } - $html = $this->renderedContent($entry); + $html = $this->renderedContent($siteConfig, $entry); if ($html !== '') { $xml->writeElement('content:encoded', $html); } @@ -294,13 +294,15 @@ private function createFileWriter(string $path): XMLWriter return $xml; } - private function renderedContent(Entry $entry): string + private function renderedContent(SiteConfig $siteConfig, Entry $entry): string { - $cacheKey = $entry->filePath . ':' . $entry->slug; + $rootPath = UrlResolver::absoluteUrl($siteConfig, '/'); + $cacheKey = $entry->filePath . ':' . $entry->slug . ':' . $rootPath; return $this->renderedContentCache[$cacheKey] ?? ($this->renderedContentCache[$cacheKey] = $this->pipeline->process( $entry->body(), - $entry + $entry, + $rootPath, )); } } diff --git a/src/Build/MetaTagsBuilder.php b/src/Build/MetaTagsBuilder.php index 6d96f63..98a2d2b 100644 --- a/src/Build/MetaTagsBuilder.php +++ b/src/Build/MetaTagsBuilder.php @@ -8,8 +8,6 @@ use YiiPress\Content\Model\SiteConfig; use YiiPress\Content\Model\Translation; -use function ltrim; -use function rtrim; use function str_starts_with; final class MetaTagsBuilder @@ -23,7 +21,7 @@ public static function forEntry(SiteConfig $siteConfig, Entry $entry, string $pe return new MetaTags( title: $entry->title, description: $entry->summary(), - canonicalUrl: self::absoluteUrl($siteConfig->baseUrl, $permalink), + canonicalUrl: self::canonicalUrl($siteConfig, $permalink), type: 'article', image: $image, twitterCard: $image !== '' ? 'summary_large_image' : 'summary', @@ -43,10 +41,10 @@ private static function buildAlternates(SiteConfig $siteConfig, Entry $entry, st } $currentLanguage = $entry->language !== '' ? $entry->language : $siteConfig->i18n->defaultLanguage; - $alternates = [$currentLanguage => self::absoluteUrl($siteConfig->baseUrl, $permalink)]; + $alternates = [$currentLanguage => self::canonicalUrl($siteConfig, $permalink)]; foreach ($translations as $translation) { - $alternates[$translation->language] = self::absoluteUrl($siteConfig->baseUrl, $translation->permalink); + $alternates[$translation->language] = self::canonicalUrl($siteConfig, $translation->permalink); } if (isset($alternates[$siteConfig->i18n->defaultLanguage])) { @@ -62,7 +60,7 @@ public static function forPage(SiteConfig $siteConfig, string $title, string $de return new MetaTags( title: $title, description: $description, - canonicalUrl: self::absoluteUrl($siteConfig->baseUrl, $permalink), + canonicalUrl: self::canonicalUrl($siteConfig, $permalink), type: 'website', image: $image, twitterCard: $image !== '' ? 'summary_large_image' : 'summary', @@ -79,14 +77,15 @@ private static function resolveImage(string $image, SiteConfig $siteConfig): str if (str_starts_with($resolved, 'http://') || str_starts_with($resolved, 'https://')) { return $resolved; } - return rtrim($siteConfig->baseUrl, '/') . '/' . ltrim($resolved, '/'); + return UrlResolver::absoluteUrl($siteConfig, $resolved); } - private static function absoluteUrl(string $baseUrl, string $permalink): string + private static function canonicalUrl(SiteConfig $siteConfig, string $permalink): string { - if ($baseUrl === '' || $permalink === '') { + if ($siteConfig->baseUrl === '' || $permalink === '') { return ''; } - return rtrim($baseUrl, '/') . $permalink; + + return UrlResolver::absoluteUrl($siteConfig, $permalink); } } diff --git a/src/Build/NotFoundPageWriter.php b/src/Build/NotFoundPageWriter.php index 778f89e..3586036 100644 --- a/src/Build/NotFoundPageWriter.php +++ b/src/Build/NotFoundPageWriter.php @@ -17,26 +17,17 @@ public function __construct( public function write(SiteConfig $siteConfig, string $outputDir, ?Navigation $navigation = null, bool $noWrite = false): void { - $siteTitle = $siteConfig->title; - $nav = $navigation; - $templateContext = new TemplateContext($this->templateResolver, $siteConfig->theme, $this->assetManifest); - $partial = $templateContext->partial(...); $rootPath = './'; - $assetManifest = $this->assetManifest; - $search = $siteConfig->search !== null; - $searchResults = $siteConfig->search?->results ?? 10; - $language = $siteConfig->defaultLanguage; $uiViewData = UiViewData::forSite($siteConfig, $this->templateResolver, $siteConfig->theme); - $ui = $uiViewData->ui; - $uiLanguage = $uiViewData->language; - $uiLanguages = $uiViewData->languages; - $uiCatalogs = $uiViewData->catalogs; - $t = static fn (string $key, array $params = []): string => $ui->get($key, $params); - $h = TemplateHelpers::escape(...); - - ob_start(); - require $this->templateResolver->resolve('errors/404', $siteConfig->theme); - $html = $templateContext->rewriteHtml((string) ob_get_clean(), $rootPath); + $renderer = new PageTemplateRenderer($this->templateResolver, $siteConfig->theme, $this->assetManifest); + $html = $renderer->render('errors/404', [ + 'siteTitle' => $siteConfig->title, + 'nav' => $navigation, + 'rootPath' => $rootPath, + 'search' => $siteConfig->search !== null, + 'searchResults' => $siteConfig->search?->results ?? 10, + 'language' => $siteConfig->defaultLanguage, + ] + $uiViewData->toArray(), $rootPath); if ($noWrite) { return; diff --git a/src/Build/PageActionUrlFormatter.php b/src/Build/PageActionUrlFormatter.php index c2c6ae3..b3990e4 100644 --- a/src/Build/PageActionUrlFormatter.php +++ b/src/Build/PageActionUrlFormatter.php @@ -7,7 +7,6 @@ use YiiPress\Content\Model\Entry; use YiiPress\Content\Model\SiteConfig; -use function ltrim; use function rawurlencode; use function rtrim; use function str_replace; @@ -21,7 +20,7 @@ final class PageActionUrlFormatter public static function format(string $template, SiteConfig $siteConfig, Entry $entry, string $permalink, string $contentDir): string { $path = self::sourcePath($entry, $contentDir); - $absoluteUrl = self::absoluteUrl($siteConfig, $permalink); + $absoluteUrl = UrlResolver::absoluteUrl($siteConfig, $permalink); return strtr($template, [ '{path}' => self::encodePath($path), @@ -41,15 +40,6 @@ private static function sourcePath(Entry $entry, string $contentDir): string return str_replace('\\', '/', $sourcePath); } - private static function absoluteUrl(SiteConfig $siteConfig, string $permalink): string - { - if ($siteConfig->baseUrl === '') { - return $permalink; - } - - return rtrim($siteConfig->baseUrl, '/') . '/' . ltrim($permalink, '/'); - } - private static function encodePath(string $path): string { return str_replace('%2F', '/', rawurlencode($path)); diff --git a/src/Build/PublicUrlResolver.php b/src/Build/PublicUrlResolver.php index f10cbe3..b2544a5 100644 --- a/src/Build/PublicUrlResolver.php +++ b/src/Build/PublicUrlResolver.php @@ -6,145 +6,20 @@ use YiiPress\Content\Model\SiteConfig; -use function is_int; -use function is_string; -use function ltrim; -use function parse_url; -use function rtrim; -use function str_contains; -use function str_ends_with; -use function str_starts_with; -use function strtolower; -use function substr; -use function trim; - final class PublicUrlResolver { public static function browserUrl(SiteConfig $siteConfig, string $url): string { - if (!self::isSiteRootPath($url)) { - return $url; - } - - $basePath = self::basePath($siteConfig); - if ($basePath === '') { - return $url; - } - - return self::joinPaths('/' . $basePath . '/', $url); + return UrlResolver::browserUrl($siteConfig, $url); } public static function absoluteUrl(SiteConfig $siteConfig, string $url): string { - if ($url === '') { - return ''; - } - - if (self::isAbsoluteUrl($url)) { - return $url; - } - - $baseOrigin = self::baseOrigin($siteConfig); - if ($baseOrigin === '') { - return self::browserUrl($siteConfig, $url); - } - - if (self::isSiteRootPath($url)) { - return $baseOrigin . self::browserUrl($siteConfig, $url); - } - - $basePath = self::basePath($siteConfig); - return $baseOrigin . self::joinPaths('/' . $basePath . '/', $url); + return UrlResolver::absoluteUrl($siteConfig, $url); } public static function isSamePublicUrl(SiteConfig $siteConfig, string $sourcePermalink, string $targetUrl): bool { - $source = self::absoluteUrl($siteConfig, $sourcePermalink); - if ($source === '') { - $source = $sourcePermalink; - } - - return self::normalizeForCompare($source) === self::normalizeForCompare(self::absoluteUrl($siteConfig, $targetUrl)); - } - - private static function isSiteRootPath(string $url): bool - { - return str_starts_with($url, '/') && !str_starts_with($url, '//'); - } - - private static function isAbsoluteUrl(string $url): bool - { - return str_contains($url, '://') || str_starts_with($url, '//'); - } - - private static function basePath(SiteConfig $siteConfig): string - { - if ($siteConfig->baseUrl === '') { - return ''; - } - - return trim(self::parseStringComponent($siteConfig->baseUrl, PHP_URL_PATH), '/'); - } - - private static function baseOrigin(SiteConfig $siteConfig): string - { - if ($siteConfig->baseUrl === '') { - return ''; - } - - $scheme = self::parseStringComponent($siteConfig->baseUrl, PHP_URL_SCHEME); - $host = self::parseStringComponent($siteConfig->baseUrl, PHP_URL_HOST); - if ($scheme === '' || $host === '') { - return ''; - } - - $port = parse_url($siteConfig->baseUrl, PHP_URL_PORT); - return $scheme . '://' . $host . (is_int($port) ? ':' . $port : ''); - } - - private static function parseStringComponent(string $url, int $component): string - { - $value = parse_url($url, $component); - - return is_string($value) ? $value : ''; - } - - private static function joinPaths(string $basePath, string $path): string - { - $joined = '/' . trim($basePath, '/') . '/' . ltrim($path, '/'); - if (str_ends_with($path, '/') && !str_ends_with($joined, '/')) { - $joined .= '/'; - } - - return $joined; - } - - private static function normalizeForCompare(string $url): string - { - if ($url === '') { - return ''; - } - - $fragmentPosition = strpos($url, '#'); - if ($fragmentPosition !== false) { - $url = substr($url, 0, $fragmentPosition); - } - - $parts = parse_url($url); - if ($parts === false) { - return rtrim($url, '/') ?: '/'; - } - - $scheme = isset($parts['scheme']) ? strtolower($parts['scheme']) . '://' : ''; - $host = isset($parts['host']) ? strtolower($parts['host']) : ''; - $port = isset($parts['port']) ? ':' . $parts['port'] : ''; - $path = $parts['path'] ?? '/'; - if (str_ends_with($path, '/index.html')) { - $path = substr($path, 0, -10); - } - $path = rtrim($path, '/') ?: '/'; - $query = isset($parts['query']) ? '?' . $parts['query'] : ''; - - return $scheme . $host . $port . $path . $query; + return UrlResolver::isSamePublicUrl($siteConfig, $sourcePermalink, $targetUrl); } } diff --git a/src/Build/RedirectPageWriter.php b/src/Build/RedirectPageWriter.php index 5d7f91a..a5b06a8 100644 --- a/src/Build/RedirectPageWriter.php +++ b/src/Build/RedirectPageWriter.php @@ -25,7 +25,7 @@ public function write( string $sourcePermalink = '', ): void { $htmlLanguage = $language !== '' ? $language : 'en'; - if ($siteConfig !== null && $sourcePermalink !== '' && PublicUrlResolver::isSamePublicUrl($siteConfig, $sourcePermalink, $entry->redirectTo)) { + if ($siteConfig !== null && $sourcePermalink !== '' && UrlResolver::isSamePublicUrl($siteConfig, $sourcePermalink, $entry->redirectTo)) { throw new RuntimeException(sprintf( 'Redirect from "%s" to "%s" resolves to the same public URL.', $sourcePermalink, @@ -34,7 +34,7 @@ public function write( } $target = $siteConfig !== null - ? PublicUrlResolver::browserUrl($siteConfig, $entry->redirectTo) + ? UrlResolver::browserUrl($siteConfig, $entry->redirectTo) : $entry->redirectTo; $targetEscaped = htmlspecialchars($target, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $targetJson = json_encode($target, JSON_THROW_ON_ERROR); diff --git a/src/Build/RelativePathHelper.php b/src/Build/RelativePathHelper.php index ea5d37f..98270d9 100644 --- a/src/Build/RelativePathHelper.php +++ b/src/Build/RelativePathHelper.php @@ -8,18 +8,11 @@ final class RelativePathHelper { public static function rootPath(string $permalink): string { - $trimmed = trim($permalink, '/'); - if ($trimmed === '') { - return './'; - } - - $depth = substr_count($trimmed, '/') + 1; - - return str_repeat('../', $depth); + return UrlResolver::rootPath($permalink); } public static function relativize(string $targetPermalink, string $rootPath): string { - return $rootPath . ltrim($targetPermalink, '/'); + return UrlResolver::sitePath($targetPermalink, $rootPath); } } diff --git a/src/Build/RobotsTxtGenerator.php b/src/Build/RobotsTxtGenerator.php index 984a89b..ae5b087 100644 --- a/src/Build/RobotsTxtGenerator.php +++ b/src/Build/RobotsTxtGenerator.php @@ -7,8 +7,6 @@ use YiiPress\Content\Model\RobotsTxtRule; use YiiPress\Content\Model\SiteConfig; -use function rtrim; - final class RobotsTxtGenerator { public function generate(SiteConfig $siteConfig): string @@ -44,7 +42,7 @@ public function generate(SiteConfig $siteConfig): string } if ($siteConfig->baseUrl !== '') { - $lines[] = 'Sitemap: ' . rtrim($siteConfig->baseUrl, '/') . '/sitemap.xml'; + $lines[] = 'Sitemap: ' . UrlResolver::absoluteUrl($siteConfig, '/sitemap.xml'); } return implode("\n", $lines) . "\n"; diff --git a/src/Build/SitemapGenerator.php b/src/Build/SitemapGenerator.php index 5bd500c..1f7c609 100644 --- a/src/Build/SitemapGenerator.php +++ b/src/Build/SitemapGenerator.php @@ -31,17 +31,15 @@ public function generate( bool $noWrite = false, ): void { $sitemapPath = ($noWrite ? sys_get_temp_dir() : $outputDir) . '/sitemap.xml'; - $baseUrl = rtrim($siteConfig->baseUrl, '/'); - $sitemap = new Sitemap($sitemapPath); $sitemap->setBufferSize(1000); $sitemap->setUseIndent(false); - $sitemap->addItem($baseUrl . '/'); + $sitemap->addItem(UrlResolver::absoluteUrl($siteConfig, '/')); foreach ($collections as $collectionName => $collection) { if ($collection->listing) { - $sitemap->addItem($baseUrl . '/' . $collectionName . '/'); + $sitemap->addItem(UrlResolver::absoluteUrl($siteConfig, '/' . $collectionName . '/')); } $entries = $entriesByCollection[$collectionName] ?? []; @@ -50,7 +48,7 @@ public function generate( $lastmod = $entry->date?->getTimestamp(); $sitemap->addItem( - $baseUrl . $permalink, + UrlResolver::absoluteUrl($siteConfig, $permalink), $lastmod ?? null, ); } @@ -60,13 +58,13 @@ public function generate( $basePermalink = $page->permalink !== '' ? $page->permalink : '/' . $page->slug . '/'; $permalink = PermalinkResolver::applyLanguagePrefix($basePermalink, $page->language, $siteConfig->i18n); $lastmod = $page->date?->getTimestamp(); - $sitemap->addItem($baseUrl . $permalink, $lastmod ?? null); + $sitemap->addItem(UrlResolver::absoluteUrl($siteConfig, $permalink), $lastmod ?? null); } if ($authors !== []) { - $sitemap->addItem($baseUrl . '/authors/'); + $sitemap->addItem(UrlResolver::absoluteUrl($siteConfig, '/authors/')); foreach ($authors as $slug => $author) { - $sitemap->addItem($baseUrl . '/authors/' . $slug . '/'); + $sitemap->addItem(UrlResolver::absoluteUrl($siteConfig, '/authors/' . $slug . '/')); } } diff --git a/src/Build/TaxonomyPageWriter.php b/src/Build/TaxonomyPageWriter.php index 8213ad2..74f0697 100644 --- a/src/Build/TaxonomyPageWriter.php +++ b/src/Build/TaxonomyPageWriter.php @@ -64,7 +64,7 @@ private function writeIndexPage( ?Navigation $navigation, bool $noWrite, ): void { - $rootPath = RelativePathHelper::rootPath('/' . $taxonomyName . '/'); + $rootPath = UrlResolver::rootPath('/' . $taxonomyName . '/'); $uiViewData = UiViewData::forSite($siteConfig, $this->templateResolver, $siteConfig->theme); $taxonomyLabel = $uiViewData->ui->taxonomyLabel($taxonomyName); $html = $renderer->render('taxonomy_index', [ @@ -104,7 +104,7 @@ private function writeTermPage( ?Navigation $navigation, bool $noWrite, ): void { - $rootPath = RelativePathHelper::rootPath('/' . $taxonomyName . '/' . $term . '/'); + $rootPath = UrlResolver::rootPath('/' . $taxonomyName . '/' . $term . '/'); $uiViewData = UiViewData::forSite($siteConfig, $this->templateResolver, $siteConfig->theme); $taxonomyLabel = $uiViewData->ui->taxonomyLabel($taxonomyName); @@ -112,7 +112,7 @@ private function writeTermPage( foreach ($entries as $entry) { $collection = $collections[$entry->collection] ?? null; $url = $collection !== null - ? RelativePathHelper::relativize(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath) + ? UrlResolver::sitePath(PermalinkResolver::resolve($entry, $collection, $siteConfig->i18n), $rootPath) : '#'; $entryData[] = [ diff --git a/src/Build/TemplateHelpers.php b/src/Build/TemplateHelpers.php index 369fcda..942d945 100644 --- a/src/Build/TemplateHelpers.php +++ b/src/Build/TemplateHelpers.php @@ -20,6 +20,11 @@ public static function inject(array $variables): array $variables['h'] = self::escape(...); } + if (!isset($variables['url'])) { + $rootPath = (string) ($variables['rootPath'] ?? ''); + $variables['url'] = static fn (string $path): string => UrlResolver::sitePath($path, $rootPath); + } + $ui = $variables['ui'] ?? null; if ($ui instanceof UiText && !isset($variables['t'])) { $variables['t'] = static fn (string $key, array $params = []): string => $ui->get($key, $params); diff --git a/src/Build/UrlResolver.php b/src/Build/UrlResolver.php new file mode 100644 index 0000000..d23df58 --- /dev/null +++ b/src/Build/UrlResolver.php @@ -0,0 +1,211 @@ +baseUrl === '') { + return ''; + } + + return trim(self::parseStringComponent($siteConfig->baseUrl, PHP_URL_PATH), '/'); + } + + private static function baseOrigin(SiteConfig $siteConfig): string + { + if ($siteConfig->baseUrl === '') { + return ''; + } + + $scheme = self::parseStringComponent($siteConfig->baseUrl, PHP_URL_SCHEME); + $host = self::parseStringComponent($siteConfig->baseUrl, PHP_URL_HOST); + if ($scheme === '' || $host === '') { + return ''; + } + + $port = parse_url($siteConfig->baseUrl, PHP_URL_PORT); + + return $scheme . '://' . $host . (is_int($port) ? ':' . $port : ''); + } + + private static function parseStringComponent(string $url, int $component): string + { + $value = parse_url($url, $component); + + return is_string($value) ? $value : ''; + } + + private static function joinPaths(string $basePath, string $path): string + { + $joined = '/' . trim($basePath, '/') . '/' . ltrim($path, '/'); + if (str_ends_with($path, '/') && !str_ends_with($joined, '/')) { + $joined .= '/'; + } + + return $joined; + } + + private static function normalizeForCompare(string $url): string + { + if ($url === '') { + return ''; + } + + $fragmentPosition = strpos($url, '#'); + if ($fragmentPosition !== false) { + $url = substr($url, 0, $fragmentPosition); + } + + $parts = parse_url($url); + if ($parts === false) { + return rtrim($url, '/') ?: '/'; + } + + $scheme = isset($parts['scheme']) ? strtolower($parts['scheme']) . '://' : ''; + $host = isset($parts['host']) ? strtolower($parts['host']) : ''; + $port = isset($parts['port']) ? ':' . $parts['port'] : ''; + $path = $parts['path'] ?? '/'; + if (str_ends_with($path, '/index.html')) { + $path = substr($path, 0, -10); + } + $path = rtrim($path, '/') ?: '/'; + $query = isset($parts['query']) ? '?' . $parts['query'] : ''; + + return $scheme . $host . $port . $path . $query; + } +} diff --git a/src/Content/CrossReferenceResolver.php b/src/Content/CrossReferenceResolver.php index 3a6f7da..bb8fd60 100644 --- a/src/Content/CrossReferenceResolver.php +++ b/src/Content/CrossReferenceResolver.php @@ -4,7 +4,7 @@ namespace YiiPress\Content; -use YiiPress\Build\RelativePathHelper; +use YiiPress\Build\UrlResolver; use function hash; @@ -57,14 +57,14 @@ function (array $matches): string { } if ($this->currentPermalink !== '') { - $rootPath = RelativePathHelper::rootPath($this->currentPermalink); - $permalink = RelativePathHelper::relativize($permalink, $rootPath); + $rootPath = UrlResolver::rootPath($this->currentPermalink); + $permalink = UrlResolver::sitePath($permalink, $rootPath); } return '[' . $text . '](' . $permalink . $fragment . ')'; }, $markdown, - ); + ) ?? $markdown; } public function signature(): string diff --git a/src/Processor/ContentProcessorPipeline.php b/src/Processor/ContentProcessorPipeline.php index 3b9dca5..efd502e 100644 --- a/src/Processor/ContentProcessorPipeline.php +++ b/src/Processor/ContentProcessorPipeline.php @@ -18,9 +18,12 @@ public function __construct(ContentProcessorInterface ...$processors) $this->processors = array_values($processors); } - public function process(string $content, Entry $entry): string + public function process(string $content, Entry $entry, ?string $rootPath = null): string { foreach ($this->processors as $processor) { + if ($rootPath !== null && $processor instanceof RootPathAwareProcessorInterface) { + $processor->applyRootPath($rootPath); + } $content = $processor->process($content, $entry); } diff --git a/src/Processor/RootPathAwareProcessorInterface.php b/src/Processor/RootPathAwareProcessorInterface.php new file mode 100644 index 0000000..43d4ef2 --- /dev/null +++ b/src/Processor/RootPathAwareProcessorInterface.php @@ -0,0 +1,10 @@ +]*>.*?<\/pre>|]*>.*?<\/code>|]*>.*?<\/a>/is'; @@ -25,6 +26,11 @@ public function __construct( private string $rootPath = '/', ) {} + public function applyRootPath(string $rootPath): void + { + $this->rootPath = $rootPath; + } + public function process(string $content, Entry $entry): string { if (!str_contains($content, '#')) { @@ -70,7 +76,7 @@ private function convertHashtags(string $text): string function (array $matches): string { $tagDisplay = $matches[1]; $tagUrl = mb_strtolower($tagDisplay); - $url = $this->rootPath . 'tags/' . $tagUrl . '/'; + $url = UrlResolver::sitePath('/tags/' . $tagUrl . '/', $this->rootPath); return '#' . htmlspecialchars($tagDisplay, ENT_QUOTES, 'UTF-8') . ''; }, $text diff --git a/src/Render/NavigationRenderer.php b/src/Render/NavigationRenderer.php index a860459..a665b9e 100644 --- a/src/Render/NavigationRenderer.php +++ b/src/Render/NavigationRenderer.php @@ -4,7 +4,7 @@ namespace YiiPress\Render; -use YiiPress\Build\RelativePathHelper; +use YiiPress\Build\UrlResolver; use YiiPress\Content\Model\Navigation; use YiiPress\Content\Model\NavigationItem; use Yiisoft\Html\Html; @@ -75,7 +75,7 @@ private static function renderItems( $html .= ''; $url = str_starts_with($item->url, '/') - ? RelativePathHelper::relativize($item->url, $rootPath) + ? UrlResolver::relativeUrl($item->url, $rootPath) : $item->url; $title = $item->resolveTitle($language, $defaultLanguage); $attributes = self::localizedTitleAttributes($item); diff --git a/tests/Unit/Build/EntryRendererTest.php b/tests/Unit/Build/EntryRendererTest.php index 76283fe..1e6bb99 100644 --- a/tests/Unit/Build/EntryRendererTest.php +++ b/tests/Unit/Build/EntryRendererTest.php @@ -18,6 +18,9 @@ use YiiPress\Hook\RenderFinishedEvent; use YiiPress\Hook\RenderStartedEvent; use YiiPress\Processor\ContentProcessorPipeline; +use YiiPress\Processor\MarkdownProcessor; +use YiiPress\Processor\TagLinkProcessor; +use YiiPress\Render\MarkdownRenderer; use DateTimeImmutable; use FilesystemIterator; use PHPUnit\Framework\TestCase; @@ -382,6 +385,27 @@ public function testInlineTagsAreFilteredFromTagsList(): void assertStringContainsString('"tag">yii', $html); } + public function testInlineTagLinksUseCurrentPageRootPath(): void + { + $entryFile = $this->contentDir . '/blog/post.md'; + file_put_contents($entryFile, "---\ntitle: Tagged Post\n---\n\n#frankenphp #php\n"); + + $entry = $this->createEntry(filePath: $entryFile, title: 'Tagged Post'); + $renderer = new EntryRenderer( + new ContentProcessorPipeline( + new MarkdownProcessor(new MarkdownRenderer()), + new TagLinkProcessor(), + ), + $this->createTemplateResolver(), + contentDir: $this->contentDir, + ); + $html = $renderer->render($this->createSiteConfig(), $entry, '/blog/frankenphp-got-faster/'); + + assertStringContainsString('href="../../tags/frankenphp/" class="tag-link">#frankenphp', $html); + assertStringContainsString('href="../../tags/php/" class="tag-link">#php', $html); + assertStringNotContainsString('href="/tags/frankenphp/"', $html); + } + public function testCustomLayoutReceivesAllVariables(): void { mkdir($this->contentDir . '/templates', 0o755, true); diff --git a/tests/Unit/Build/FeedGeneratorTest.php b/tests/Unit/Build/FeedGeneratorTest.php index 0857471..ea35441 100644 --- a/tests/Unit/Build/FeedGeneratorTest.php +++ b/tests/Unit/Build/FeedGeneratorTest.php @@ -11,6 +11,7 @@ use YiiPress\Processor\ContentProcessorInterface; use YiiPress\Processor\ContentProcessorPipeline; use YiiPress\Processor\MarkdownProcessor; +use YiiPress\Processor\TagLinkProcessor; use YiiPress\Render\MarkdownRenderer; use DateTimeImmutable; use PHPUnit\Framework\TestCase; @@ -232,6 +233,59 @@ public function process(string $content, Entry $entry): string $this->assertSame(1, $processor->calls); } + public function testInlineTagLinksUseAbsolutePublicRootInFeedContent(): void + { + $siteConfig = new SiteConfig( + title: 'Project Site', + description: 'A project site', + baseUrl: 'https://samdark.github.io/blog/', + defaultLanguage: 'en', + charset: 'UTF-8', + defaultAuthor: 'john-doe', + dateFormat: 'F j, Y', + entriesPerPage: 10, + permalink: '/:collection/:slug/', + taxonomies: ['tags'], + params: [], + ); + file_put_contents($this->tempFile, "Testing #php.\n"); + + $entry = new Entry( + filePath: $this->tempFile, + collection: 'blog', + slug: 'first-post', + title: 'First Post', + date: new DateTimeImmutable('2024-03-15'), + draft: false, + tags: ['php'], + categories: [], + authors: ['john-doe'], + summary: '', + permalink: '', + layout: '', + theme: '', + weight: 0, + language: 'en', + redirectTo: '', + extra: [], + bodyOffset: 0, + bodyLength: (int) filesize($this->tempFile), + ); + + $generator = new FeedGenerator(new ContentProcessorPipeline( + new MarkdownProcessor(new MarkdownRenderer()), + new TagLinkProcessor(), + )); + + $atom = $generator->generateAtom($siteConfig, $this->collection, [$entry]); + $rss = $generator->generateRss($siteConfig, $this->collection, [$entry]); + + assertStringContainsString('href="https://samdark.github.io/blog/tags/php/"', $atom); + assertStringContainsString('href="https://samdark.github.io/blog/tags/php/"', $rss); + assertStringNotContainsString('href="/tags/php/"', $atom); + assertStringNotContainsString('href="/tags/php/"', $rss); + } + /** * @return list */ diff --git a/tests/Unit/Build/PublicUrlResolverTest.php b/tests/Unit/Build/PublicUrlResolverTest.php index 63ff848..dc99322 100644 --- a/tests/Unit/Build/PublicUrlResolverTest.php +++ b/tests/Unit/Build/PublicUrlResolverTest.php @@ -4,7 +4,7 @@ namespace YiiPress\Tests\Unit\Build; -use YiiPress\Build\PublicUrlResolver; +use YiiPress\Build\UrlResolver; use YiiPress\Content\Model\SiteConfig; use PHPUnit\Framework\TestCase; @@ -14,33 +14,55 @@ final class PublicUrlResolverTest extends TestCase { + public function testBuildsSitePathRelativeToCurrentPageRoot(): void + { + assertSame('../../tags/php/', UrlResolver::sitePath('/tags/php/', '../../')); + assertSame('./tags/php/', UrlResolver::sitePath('tags/php/', './')); + assertSame('https://example.github.io/blog/tags/php/', UrlResolver::sitePath('/tags/php/', 'https://example.github.io/blog/')); + } + + public function testLeavesExternalAndSpecialSitePathsAlone(): void + { + assertSame('https://other.example/tags/php/', UrlResolver::sitePath('https://other.example/tags/php/', '../../')); + assertSame('#section', UrlResolver::sitePath('#section', '../../')); + assertSame('mailto:test@example.com', UrlResolver::sitePath('mailto:test@example.com', '../../')); + } + public function testPrefixesSiteRootUrlWithBasePath(): void { $siteConfig = $this->createSiteConfig('https://example.github.io/blog/'); - assertSame('/blog/posts/', PublicUrlResolver::browserUrl($siteConfig, '/posts/')); + assertSame('/blog/posts/', UrlResolver::browserUrl($siteConfig, '/posts/')); } public function testLeavesSiteRootUrlAloneWhenBaseUrlHasNoPath(): void { $siteConfig = $this->createSiteConfig('https://example.com/'); - assertSame('/posts/', PublicUrlResolver::browserUrl($siteConfig, '/posts/')); + assertSame('/posts/', UrlResolver::browserUrl($siteConfig, '/posts/')); } public function testLeavesAbsoluteUrlAlone(): void { $siteConfig = $this->createSiteConfig('https://example.github.io/blog/'); - assertSame('https://other.example/posts/', PublicUrlResolver::browserUrl($siteConfig, 'https://other.example/posts/')); + assertSame('https://other.example/posts/', UrlResolver::browserUrl($siteConfig, 'https://other.example/posts/')); + } + + public function testAbsoluteUrlPrefixesOriginAndBasePath(): void + { + $siteConfig = $this->createSiteConfig('https://example.github.io/blog/'); + + assertSame('https://example.github.io/blog/tags/php/', UrlResolver::absoluteUrl($siteConfig, '/tags/php/')); + assertSame('https://example.github.io/blog/tags/php/', UrlResolver::absoluteUrl($siteConfig, 'tags/php/')); } public function testDetectsSelfRedirectAfterBasePathResolution(): void { $siteConfig = $this->createSiteConfig('https://example.github.io/blog/'); - assertTrue(PublicUrlResolver::isSamePublicUrl($siteConfig, '/', '/')); - assertFalse(PublicUrlResolver::isSamePublicUrl($siteConfig, '/', '/blog/')); + assertTrue(UrlResolver::isSamePublicUrl($siteConfig, '/', '/')); + assertFalse(UrlResolver::isSamePublicUrl($siteConfig, '/', '/blog/')); } private function createSiteConfig(string $baseUrl): SiteConfig diff --git a/tests/Unit/Console/BuildCommandTest.php b/tests/Unit/Console/BuildCommandTest.php index 5f1fb8a..7fc4b33 100644 --- a/tests/Unit/Console/BuildCommandTest.php +++ b/tests/Unit/Console/BuildCommandTest.php @@ -1233,6 +1233,92 @@ public function testBuildRewritesRootRelativeContentImageForSubdirectoryDeployme assertStringContainsString('content="https://samdark.github.io/blog/blog/assets/photo.jpg"', $html); } + public function testBuildUsesUniformInternalUrlsForSubdirectoryDeployment(): void + { + $tempDir = sys_get_temp_dir() . '/yiipress-build-project-url-test-' . uniqid(); + $contentDir = $tempDir . '/content'; + $outputDir = $tempDir . '/output'; + mkdir($contentDir . '/blog', 0o755, true); + mkdir($contentDir . '/authors', 0o755, true); + $this->tempContentDirs[] = $tempDir; + + file_put_contents( + $contentDir . '/config.yaml', + "title: Project Site\n" + . "base_url: https://samdark.github.io/blog/\n" + . "languages: [en]\n" + . "author_pages: true\n" + . "search: true\n" + . "taxonomies:\n" + . " - tags\n" + . " - categories\n", + ); + file_put_contents($contentDir . '/blog/_collection.yaml', "title: Blog\npermalink: /blog/:slug/\nfeed: true\n"); + file_put_contents($contentDir . '/authors/john-doe.md', "---\ntitle: John Doe\n---\n\nAuthor bio.\n"); + file_put_contents( + $contentDir . '/blog/post.md', + "---\n" + . "title: Post\n" + . "date: 2024-03-15\n" + . "tags: [php, yii]\n" + . "categories: [performance]\n" + . "authors: [john-doe]\n" + . "---\n\n" + . "Inline #php tag.\n", + ); + + $yii = dirname(__DIR__, 3) . '/yii'; + exec( + $yii . ' build' + . ' --content-dir=' . escapeshellarg($contentDir) + . ' --output-dir=' . escapeshellarg($outputDir) + . ' --no-cache' + . ' 2>&1', + $output, + $exitCode, + ); + + assertSame(0, $exitCode, implode("\n", $output)); + + $entryHtml = file_get_contents($outputDir . '/blog/post/index.html'); + assertNotFalse($entryHtml); + assertStringContainsString('href="../../"', $entryHtml); + assertStringContainsString('data-root="../../"', $entryHtml); + assertStringContainsString('href="../../authors/john-doe/"', $entryHtml); + assertStringContainsString('href="../../tags/php/" class="tag-link">#php', $entryHtml); + assertStringContainsString('href="../../tags/yii/" class="tag-link">#yii', $entryHtml); + assertStringContainsString('href="../../categories/performance/" class="category">performance', $entryHtml); + assertStringNotContainsString('href="/tags/', $entryHtml); + assertStringNotContainsString('href="/categories/', $entryHtml); + assertStringNotContainsString('href="/authors/', $entryHtml); + + $listingHtml = file_get_contents($outputDir . '/blog/index.html'); + assertNotFalse($listingHtml); + assertStringContainsString('href="../blog/archive/"', $listingHtml); + assertStringContainsString('href="../blog/rss.xml"', $listingHtml); + assertStringContainsString('href="../blog/feed.xml"', $listingHtml); + + $tagsIndexHtml = file_get_contents($outputDir . '/tags/index.html'); + assertNotFalse($tagsIndexHtml); + assertStringContainsString('href="../tags/php/"', $tagsIndexHtml); + assertStringContainsString('href="../tags/yii/"', $tagsIndexHtml); + + $authorsIndexHtml = file_get_contents($outputDir . '/authors/index.html'); + assertNotFalse($authorsIndexHtml); + assertStringContainsString('href="../authors/john-doe/"', $authorsIndexHtml); + + $feed = file_get_contents($outputDir . '/blog/feed.xml'); + assertNotFalse($feed); + assertStringContainsString('https://samdark.github.io/blog/blog/post/', $feed); + assertStringContainsString('href="https://samdark.github.io/blog/tags/php/"', $feed); + assertStringNotContainsString('href="/tags/php/"', $feed); + + $sitemap = file_get_contents($outputDir . '/sitemap.xml'); + assertNotFalse($sitemap); + assertStringContainsString('https://samdark.github.io/blog/blog/post/', $sitemap); + assertStringContainsString('https://samdark.github.io/blog/authors/john-doe/', $sitemap); + } + private function manifestPath(): string { return RuntimePaths::cachePath(dirname(__DIR__, 3)) . '/build-manifest-' . hash('xxh128', $this->outputDir) . '.json'; diff --git a/themes/minimal/archive_index.php b/themes/minimal/archive_index.php index edd2c10..8b55125 100644 --- a/themes/minimal/archive_index.php +++ b/themes/minimal/archive_index.php @@ -19,6 +19,7 @@ * @var array> $uiCatalogs * @var YiiPress\I18n\UiText $ui * @var Closure(string, int, ?string, bool): string $h + * @var Closure(string): string $url * @var Closure(string, array): string $t */ @@ -38,7 +39,7 @@

    -
  • +
diff --git a/themes/minimal/archive_monthly.php b/themes/minimal/archive_monthly.php index f1f2559..5c9b771 100644 --- a/themes/minimal/archive_monthly.php +++ b/themes/minimal/archive_monthly.php @@ -22,6 +22,7 @@ * @var array> $uiCatalogs * @var YiiPress\I18n\UiText $ui * @var Closure(string, int, ?string, bool): string $h + * @var Closure(string): string $url * @var Closure(string, array): string $t */ @@ -39,7 +40,7 @@

:

- +
  • diff --git a/themes/minimal/archive_yearly.php b/themes/minimal/archive_yearly.php index de8b473..cc13e60 100644 --- a/themes/minimal/archive_yearly.php +++ b/themes/minimal/archive_yearly.php @@ -21,6 +21,7 @@ * @var array> $uiCatalogs * @var YiiPress\I18n\UiText $ui * @var Closure(string, int, ?string, bool): string $h + * @var Closure(string): string $url */ use YiiPress\Content\Model\Navigation; @@ -41,7 +42,7 @@ diff --git a/themes/minimal/collection_listing.php b/themes/minimal/collection_listing.php index a94947a..77a4240 100644 --- a/themes/minimal/collection_listing.php +++ b/themes/minimal/collection_listing.php @@ -20,6 +20,7 @@ * @var array> $uiCatalogs * @var YiiPress\I18n\UiText $ui * @var Closure(string, int, ?string, bool): string $h + * @var Closure(string): string $url * @var Closure(string, array): string $t */ @@ -42,10 +43,10 @@

    diff --git a/themes/minimal/entry.php b/themes/minimal/entry.php index d32a68d..6e6161b 100644 --- a/themes/minimal/entry.php +++ b/themes/minimal/entry.php @@ -37,6 +37,7 @@ * @var array> $uiCatalogs * @var YiiPress\I18n\UiText $ui * @var Closure(string, int, ?string, bool): string $h + * @var Closure(string): string $url * @var Closure(string, array): string $t */ @@ -142,14 +143,14 @@
    - +
    diff --git a/themes/minimal/errors/404.php b/themes/minimal/errors/404.php index 98554c4..9a20608 100644 --- a/themes/minimal/errors/404.php +++ b/themes/minimal/errors/404.php @@ -15,6 +15,7 @@ * @var array> $uiCatalogs * @var YiiPress\I18n\UiText $ui * @var Closure(string, int, ?string, bool): string $h + * @var Closure(string): string $url * @var Closure(string, array): string $t */ @@ -34,7 +35,7 @@

    404

    -

    +

diff --git a/themes/minimal/partials/head.php b/themes/minimal/partials/head.php index 9e742dc..d13b97e 100644 --- a/themes/minimal/partials/head.php +++ b/themes/minimal/partials/head.php @@ -13,6 +13,7 @@ * @var array> $uiCatalogs * @var YiiPress\I18n\UiText $ui * @var Closure(string, int, ?string, bool): string $h + * @var Closure(string): string $url * @var Closure(string, array): string $t */ @@ -179,8 +180,8 @@ - - + + diff --git a/themes/minimal/partials/header.php b/themes/minimal/partials/header.php index cabcb49..82dbbe1 100644 --- a/themes/minimal/partials/header.php +++ b/themes/minimal/partials/header.php @@ -12,6 +12,7 @@ * @var string $uiLanguage * @var list $uiLanguages * @var Closure(string, int, ?string, bool): string $h + * @var Closure(string): string $url * @var Closure(string, array): string $t * @var Closure(string): string $languageName */ @@ -23,7 +24,7 @@ ?>