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:
= $h(ucfirst($taxonomyName)) ?>
```
@@ -324,7 +324,7 @@ Partials are reusable template fragments stored in a `partials/` subdirectory of
### Usage
```php
-= $partial('head', ['title' => $entryTitle . ' — ' . $siteTitle]) ?>
+= $partial('head', ['title' => $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 @@
= $h($collectionTitle) ?> = $h($t('archive')) ?>
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 @@
= $h($collectionTitle) ?>: = $h($monthName) ?> = $h($year) ?>
-
← = $h($t('all_of_year', ['year' => $year])) ?>
+
← = $h($t('all_of_year', ['year' => $year])) ?>
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/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 @@
?>