diff --git a/docs/content/content/i18n.dj b/docs/content/content/i18n.dj index daad59e..a43450d 100644 --- a/docs/content/content/i18n.dj +++ b/docs/content/content/i18n.dj @@ -27,6 +27,11 @@ i18n: label: Nederlands urlPrefix: /nl contentDir: content/nl + site: + title: "Mijn site" + description: "Nederlandstalige omschrijving." + hero: + title: "Hallo!" fr: label: Français urlPrefix: /fr @@ -47,11 +52,66 @@ A map of language code → language options. Each entry accepts: | `label` | string | Human-readable language name (used in language switchers) | | `urlPrefix` | string | URL path prefix for this language (`""` for root, `"/nl"` for `/nl/...`) | | `contentDir` | string | Content directory for this language (relative to project root). Omit to use the project-level `content/` directory | +| `site` | map | Per-language site config overrides (see [Per-language site settings](#per-language-site-settings)) | The default language does not need a `contentDir` — it automatically uses the project-level content directory. Non-default languages without a `contentDir` are skipped during discovery. +## Per-language site settings + +Each language entry can include an optional `site` block that overrides the +project-level `site` configuration for pages in that language. Only the keys you +specify are overridden — everything else falls back to the values in the top-level +`site` block. + +This is useful for translating the site title, meta description, and any custom +metadata (such as `hero` or `nav`) that appears in templates via `$site`. + +```neon +site: + title: "My Site" + description: "An English description." + hero: + title: "Hi!" + subtitle: "Welcome" + nav: + - label: Home + url: / + - label: Blog + url: /blog/ + +i18n: + defaultLanguage: en + languages: + en: + label: English + urlPrefix: "" + nl: + label: Nederlands + urlPrefix: /nl + contentDir: content/nl + site: + title: "Mijn site" + description: "Een Nederlandstalige omschrijving." + hero: + title: "Hallo!" + subtitle: "Welkom" + nav: + - label: Home + url: /nl/ + - label: Blog + url: /nl/blog/ +``` + +For pages in the `nl` language, `$site->title` resolves to `"Mijn site"` and +`$site->siteMeta('hero.title')` resolves to `"Hallo!"`. For all other languages +the project-level values are used. + +Nested meta structures (such as `hero`) are deep-merged, so you only need to +specify the keys that actually differ. Scalar fields (`title`, `description`, +`baseUrl`, `basePath`) are replaced outright when present in the override. + ## Directory structure A typical two-language setup with English as the root language and Dutch under `/nl/`: @@ -249,7 +309,7 @@ code. Useful for generating `` tags. + href="url($translated) ?>"> ``` ### Single translation @@ -268,7 +328,7 @@ On single-language sites this is equivalent to `$this->regularPages()`. ```sugar title ?> + href="url($p) ?>">title ?> ``` ### Translate a string @@ -283,6 +343,67 @@ substitution via `$params`. Falls back to the default language, then returns `$f t('promo', fallback: 'Check out the latest posts') ?> ``` +### Untranslated page fallbacks + +By default, navigation helpers (`regularPages()`, `allPages()`, `pages()`, `type()`) only return pages +in the current language. When a translated page does not yet exist for the current language, that +content is invisible to templates. + +Pass `withUntranslated: true` to automatically fill in pages from the default language that have no +translation in the current language: + +```php +// only NL pages +$this->regularPages() + +// NL pages + EN pages that have no NL translation +$this->regularPages(withUntranslated: true) + +// same — filter by type after merging +$this->type('post', withUntranslated: true) +``` + +You can also pass an explicit list of fallback languages instead of `true`: + +```php +// use EN, then FR as fallback (in order) +$this->regularPages(withUntranslated: ['en', 'fr']) +``` + +When `true` is passed, Glaze uses the configured `i18n.defaultLanguage` as the only fallback source. + +The deduplication logic is based on `translationKey`. A fallback page is only added when no page in +the current language shares the same `translationKey`. + +{.alert .alert-warning} +> **Cross-language URLs:** Pages returned via `withUntranslated` belong to a different language. Always +> pass the `ContentPage` object directly to `$this->url($page)` \u2014 never `$this->url($page->urlPath)` \u2014 +> so that `url()` can resolve the correct language prefix automatically. + +## URL generation in multilingual templates + +Glaze's `url()` helper is language-aware. When passed a `ContentPage` object, it uses the page's own +language to build the URL, ensuring cross-language links are always correct regardless of which +language page the template is currently rendering. + +```php +// correct: resolves /post-1/ for an EN post even when rendering a NL page +title ?> + +// problematic: always applies the current page's language prefix +title ?> +``` + +For static files that must never receive a language prefix (favicons, JS/CSS bundles, JSON feeds, +images in `static/`) use `rawUrl()`: + +```php + + +``` + +`rawUrl()` applies `basePath` (subfolder deployments) but never adds a language segment. + ## The `glaze routes` command The `routes` command shows all discovered routes. For i18n sites, a **Language** diff --git a/docs/content/getting-started/configuration.dj b/docs/content/getting-started/configuration.dj index 8ce6d47..0962868 100644 --- a/docs/content/getting-started/configuration.dj +++ b/docs/content/getting-started/configuration.dj @@ -138,6 +138,9 @@ i18n: label: Nederlands urlPrefix: /nl contentDir: content/nl + site: + title: "Nederlandse site" + description: "Een Nederlandstalige omschrijving." ``` ## `pageTemplate` @@ -188,6 +191,10 @@ Global site metadata. All values are available in templates as properties on the - `baseUrl` -- canonical base URL (used for sitemaps, feed links, etc.) - `basePath` -- URL prefix for subfolder deployments (e.g. `/blog`); prepended to all generated links +Any additional keys you add (e.g. `hero`, `nav`) are collected into the `$site` metadata bag and accessible via `$site->siteMeta('key')` or `$site->meta('meta.key')` in templates. + +When i18n is enabled, individual language entries can include a `site` block to override any of these values for that language's pages. See [Per-language site settings](/content/i18n/#per-language-site-settings). + ## `images` Controls [League Glide](https://glide.thephpleague.com/) image transform behavior. diff --git a/docs/content/templating/index.dj b/docs/content/templating/index.dj index 90c5a2a..329fd3c 100644 --- a/docs/content/templating/index.dj +++ b/docs/content/templating/index.dj @@ -144,7 +144,15 @@ $this->previousInSection(?callable $predicate = null) // ?ContentPage $this->nextInSection(?callable $predicate = null) // ?ContentPage ``` -See [Page collections](page-collections.dj) for filtering, sorting, grouping, and pagination. +On i18n sites, all navigation methods (`pages()`, `regularPages()`, `type()`, `sections()`) automatically scope results to the current page's language. To also include pages from other languages that have no translation for the current language, pass `withUntranslated: true`: + +```php +$this->regularPages(withUntranslated: true) // current language + untranslated fallbacks +$this->type('post', withUntranslated: true) // same, filtered by type +$this->pages(withUntranslated: ['en', 'fr']) // explicit fallback language list +``` + +See [Page collections](page-collections.dj) for filtering, sorting, grouping, and pagination. For full i18n navigation details see [Internationalization](../content/i18n.dj). ### URL helpers @@ -153,7 +161,8 @@ See [Page collections](page-collections.dj) for filtering, sorting, grouping, an ```php $this->url('/blog/') // '/blog/' or '/sub/blog/' when basePath is /sub $this->url('/blog/', true) // 'https://example.com/sub/blog/' with baseUrl configured -$this->url($page->urlPath) // apply basePath to any page URL +$this->url($page) // URL for a ContentPage — uses the page's own language prefix +$this->rawUrl('/favicon.svg') // basePath only, never adds a language prefix $this->canonicalUrl() // fully-qualified URL for the current page ``` @@ -162,10 +171,33 @@ Use `$this->url()` for all internal `href` and `src` attributes. Pass `true` as ```php - + Get started ``` +#### Passing a `ContentPage` to `url()` + +When iterating pages in a multilingual context, prefer passing the `ContentPage` object directly instead of `$post->urlPath`. This lets `url()` resolve the correct language prefix for the page, even when it belongs to a different language than the current page: + +```php +// correct: uses the post's own language prefix +title ?> + +// problematic on i18n sites: always applies the current page's language prefix +title ?> +``` + +#### `rawUrl()` for static assets + +Use `rawUrl()` for paths that should never receive a language prefix — static files in the `/static` folder, favicons, JSON feeds, and similar site-wide assets: + +```php + + +``` + +`rawUrl()` applies `basePath` (for subfolder deployments) but not the current language's `urlPrefix`. + ### Content assets Use content asset helpers to discover non-Djot files from content directories: diff --git a/docs/content/templating/page-collections.dj b/docs/content/templating/page-collections.dj index 677fe53..254d036 100644 --- a/docs/content/templating/page-collections.dj +++ b/docs/content/templating/page-collections.dj @@ -23,6 +23,23 @@ $this->taxonomyTerm('tags', 'php') // pages tagged 'php' {.alert .alert-info} > **On i18n sites**, `pages()`, `regularPages()`, `sections()`, and related navigation helpers only return pages in the **current page's language**. Cross-language results will not appear. Use `$this->localizedPages()` as a self-documenting alias for `regularPages()` when writing multilingual templates. +### Untranslated fallbacks (i18n) + +Pass `withUntranslated: true` to include pages from the default language that have no translation in the current language. A fallback page is only added when no current-language page shares the same `translationKey`. + +```php +$this->regularPages(withUntranslated: true) // current language + default language fallbacks +$this->type('post', withUntranslated: true) // same, filtered by type +$this->pages(withUntranslated: ['en', 'fr']) // explicit ordered fallback language list +``` + +Always pass the `ContentPage` object to `$this->url()` when iterating results that may contain pages from other languages, so the correct language prefix is resolved automatically: + +```sugar +title ?> +``` + ## `ContentPage` properties Every item in a collection is a `ContentPage` object: diff --git a/src/Config/LanguageConfig.php b/src/Config/LanguageConfig.php index f2c1d67..642f3dd 100644 --- a/src/Config/LanguageConfig.php +++ b/src/Config/LanguageConfig.php @@ -27,6 +27,10 @@ * label: Nederlands * urlPrefix: nl * contentDir: content/nl + * site: + * title: "Mijn site" + * hero: + * title: "Hallo!" * ``` */ final class LanguageConfig @@ -38,12 +42,15 @@ final class LanguageConfig * @param string $label Human-readable language label shown in switchers. * @param string $urlPrefix URL path prefix for this language (empty string for the default language at root). * @param string|null $contentDir Relative or absolute content directory for this language. Null uses the project-level content dir. + * @param array $siteOverrides Per-language site config overrides merged on top of the base `site` block at render time. + * Only keys listed here are overridden; all other site config keys fall back to the project-level values. */ public function __construct( public readonly string $code, public readonly string $label = '', public readonly string $urlPrefix = '', public readonly ?string $contentDir = null, + public readonly array $siteOverrides = [], ) { } @@ -62,12 +69,22 @@ public static function fromConfig(string $code, mixed $value): self $label = Normalization::optionalString($value['label'] ?? null) ?? ''; $urlPrefix = self::normalizeUrlPrefix($value['urlPrefix'] ?? null); $contentDir = Normalization::optionalString($value['contentDir'] ?? null); + $raw = $value['site'] ?? null; + $siteOverrides = []; + if (is_array($raw)) { + foreach ($raw as $siteKey => $siteValue) { + if (is_string($siteKey)) { + $siteOverrides[$siteKey] = $siteValue; + } + } + } return new self( code: $code, label: $label, urlPrefix: $urlPrefix, contentDir: $contentDir, + siteOverrides: $siteOverrides, ); } diff --git a/src/Config/SiteConfig.php b/src/Config/SiteConfig.php index 0446819..926f867 100644 --- a/src/Config/SiteConfig.php +++ b/src/Config/SiteConfig.php @@ -80,6 +80,43 @@ public static function fromProjectConfig(mixed $value): self ); } + /** + * Return a new SiteConfig with per-language overrides merged on top of this instance. + * + * Only the keys present in `$overrides` are applied; all other properties + * fall back to the values of the current instance. Typed scalar fields + * (`title`, `description`, `baseUrl`, `basePath`) are overridden when the + * override map provides a non-empty string value. The `meta` bag is + * deep-merged so nested associative structures (e.g. `hero`) can be partially overridden + * without repeating unchanged keys. Numeric list values (e.g. `nav`) are replaced wholesale + * so an override with fewer items does not retain trailing base items. + * + * This is called by the render pipeline when building a page that belongs + * to a language which has a `site` override block in its `LanguageConfig`. + * + * @param array $overrides Raw per-language site override map (the `site` block + * from a `LanguageConfig` entry). + */ + public function withLanguageOverrides(array $overrides): self + { + if ($overrides === []) { + return $this; + } + + $overrideConfig = self::fromProjectConfig($overrides); + + /** @var array $mergedMeta */ + $mergedMeta = self::deepMerge($this->meta, $overrideConfig->meta); + + return new self( + title: $overrideConfig->title ?? $this->title, + description: $overrideConfig->description ?? $this->description, + baseUrl: $overrideConfig->baseUrl ?? $this->baseUrl, + basePath: $overrideConfig->basePath ?? $this->basePath, + meta: $mergedMeta, + ); + } + /** * Convert to template-friendly array. * @@ -193,4 +230,30 @@ private static function normalizeBasePath(mixed $value): ?string return $normalized; } + + /** + * Deep-merge two metadata maps. + * + * Associative sub-arrays are merged recursively so nested structures (e.g. `hero`) can be + * partially overridden without repeating unchanged keys. Numeric list arrays in the override + * replace the corresponding base value wholesale, preventing index-based merging that would + * corrupt ordered lists such as `nav` (a base with 4 items would otherwise retain trailing + * items when the override provides only 2). + * + * @param array $base Base metadata. + * @param array $override Override metadata. + * @return array + */ + private static function deepMerge(array $base, array $override): array + { + foreach ($override as $key => $value) { + if (!array_key_exists($key, $base) || !is_array($value) || !is_array($base[$key]) || array_is_list($value)) { + $base[$key] = $value; + } else { + $base[$key] = self::deepMerge($base[$key], $value); + } + } + + return $base; + } } diff --git a/src/Content/ContentDiscoveryService.php b/src/Content/ContentDiscoveryService.php index a5cf4ba..b1f35be 100644 --- a/src/Content/ContentDiscoveryService.php +++ b/src/Content/ContentDiscoveryService.php @@ -8,6 +8,7 @@ use Cake\Utility\Text; use DateTimeInterface; use Glaze\Utility\Normalization; +use Glaze\Utility\Path; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use RuntimeException; @@ -85,7 +86,7 @@ public function discover( continue; } - $sourcePath = $file->getPathname(); + $sourcePath = Path::normalize($file->getPathname()); $relativePath = $this->relativePath($contentPath, $sourcePath); $parsed = $this->frontMatterParser->parse($this->readFile($sourcePath)); $meta = $this->normalizeMetadata($parsed->metadata); diff --git a/src/Content/LocalizedContentDiscovery.php b/src/Content/LocalizedContentDiscovery.php index 832e0ab..dc05041 100644 --- a/src/Content/LocalizedContentDiscovery.php +++ b/src/Content/LocalizedContentDiscovery.php @@ -53,6 +53,13 @@ public function __construct(protected ContentDiscoveryService $discoveryService) * are enriched with language metadata before being merged into a single * sorted list. * + * When language content directories are nested inside the default language + * content directory (the common `content/nl/` inside `content/` pattern), + * the recursive scan for the default language would otherwise include those + * pages too, causing duplicate entries with an incorrect language code. The + * non-default language paths are pre-collected and used to filter any + * spurious pages from the default language discovery pass. + * * @param \Glaze\Config\BuildConfig $config Build configuration. * @return array<\Glaze\Content\ContentPage> */ @@ -66,6 +73,19 @@ public function discover(BuildConfig $config): array ); } + // Collect resolved content paths for all non-default languages that + // have an explicit contentDir. These are excluded from the default + // language discovery pass to prevent duplicate pages. + $nonDefaultContentPaths = []; + foreach ($config->i18n->languages as $langCode => $langConfig) { + if ($langCode !== $config->i18n->defaultLanguage && $langConfig->contentDir !== null) { + $nonDefaultContentPaths[] = rtrim( + Path::resolve($config->projectRoot, $langConfig->contentDir), + '/', + ); + } + } + $allPages = []; foreach ($config->i18n->languages as $langCode => $langConfig) { @@ -80,6 +100,24 @@ public function discover(BuildConfig $config): array $config->contentTypes, ); + // Strip pages whose sourcePath falls inside a non-default language + // content directory. This avoids the duplication that occurs when + // language dirs are nested under the default language content dir. + if ($langCode === $config->i18n->defaultLanguage && $nonDefaultContentPaths !== []) { + $pages = array_values(array_filter( + $pages, + static function (ContentPage $page) use ($nonDefaultContentPaths): bool { + foreach ($nonDefaultContentPaths as $excludedPath) { + if (str_starts_with($page->sourcePath, $excludedPath . '/')) { + return false; + } + } + + return true; + }, + )); + } + foreach ($pages as $page) { $translationKey = $this->resolveTranslationKey($page); $allPages[] = $page->withLanguage($langCode, $langConfig->urlPrefix, $translationKey); diff --git a/src/Render/PageRenderPipeline.php b/src/Render/PageRenderPipeline.php index 47a6d66..a086de4 100644 --- a/src/Render/PageRenderPipeline.php +++ b/src/Render/PageRenderPipeline.php @@ -7,6 +7,8 @@ use Glaze\Build\Event\EventDispatcher; use Glaze\Build\PageMetaResolver; use Glaze\Config\BuildConfig; +use Glaze\Config\LanguageConfig; +use Glaze\Config\SiteConfig; use Glaze\Content\ContentPage; use Glaze\Support\BuildGlideHtmlRewriter; use Glaze\Support\ResourcePathRewriter; @@ -71,10 +73,12 @@ public function render( $assetResolver = new ContentAssetResolver($config->contentPath(), $config->site->basePath); $siteIndex ??= new SiteIndex([$page], $assetResolver); + $effectiveSiteConfig = $this->resolveEffectiveSiteConfig($config, $page); + $renderResult = $this->djotRenderer->render( source: $page->source, djot: $config->djotOptions, - siteConfig: $config->site, + siteConfig: $effectiveSiteConfig, relativePagePath: $page->relativePath, dispatcher: $dispatcher, page: $page, @@ -88,16 +92,17 @@ public function render( currentPage: $page, extensions: $extensionRegistry ?? new ExtensionRegistry(), assetResolver: $assetResolver, - siteConfig: $config->site, + siteConfig: $effectiveSiteConfig, i18nConfig: $config->i18n, translationsPath: $config->i18n->isEnabled() ? $config->translationsPath() : '', globalIndex: $globalIndex, + baseSiteConfig: $config->site, ); $htmlContent = $renderResult->html; - $pageUrl = $this->resourcePathRewriter->applyBasePathToPath($page->urlPath, $config->site); + $pageUrl = $this->resourcePathRewriter->applyBasePathToPath($page->urlPath, $effectiveSiteConfig); $output = $pageRenderer->render([ 'title' => $page->title, @@ -105,10 +110,10 @@ public function render( 'content' => $htmlContent, 'page' => $page, 'meta' => new ArrayObject( - $this->pageMetaResolver->resolve($page, $config->site), + $this->pageMetaResolver->resolve($page, $effectiveSiteConfig), ArrayObject::ARRAY_AS_PROPS, ), - 'site' => $config->site, + 'site' => $effectiveSiteConfig, 'debug' => $debug, ], $templateContext); @@ -141,4 +146,28 @@ public function extractToc(ContentPage $page, BuildConfig $config): array relativePagePath: $page->relativePath, )->toc; } + + /** + * Return the effective SiteConfig for a given page. + * + * When i18n is enabled and the page's language has a `site` override block + * in its LanguageConfig, the base project SiteConfig is merged with those + * overrides. Otherwise the base project SiteConfig is returned unchanged. + * + * @param \Glaze\Config\BuildConfig $config Build configuration. + * @param \Glaze\Content\ContentPage $page Page being rendered. + */ + protected function resolveEffectiveSiteConfig(BuildConfig $config, ContentPage $page): SiteConfig + { + if ($page->language === '') { + return $config->site; + } + + $langConfig = $config->i18n->language($page->language); + if (!$langConfig instanceof LanguageConfig || $langConfig->siteOverrides === []) { + return $config->site; + } + + return $config->site->withLanguageOverrides($langConfig->siteOverrides); + } } diff --git a/src/Template/SiteContext.php b/src/Template/SiteContext.php index ed95c9d..4e234b9 100644 --- a/src/Template/SiteContext.php +++ b/src/Template/SiteContext.php @@ -4,6 +4,7 @@ namespace Glaze\Template; use Glaze\Config\I18nConfig; +use Glaze\Config\LanguageConfig; use Glaze\Config\SiteConfig; use Glaze\Content\ContentPage; use Glaze\Support\ResourcePathRewriter; @@ -34,6 +35,11 @@ final class SiteContext */ protected ?TranslationLoader $translationLoader = null; + /** + * Memoized URL-resolution SiteConfig with the current language's urlPrefix composed into basePath. + */ + protected ?SiteConfig $resolvedUrlSiteConfig = null; + /** * Constructor. * @@ -43,12 +49,14 @@ final class SiteContext * @param \Glaze\Content\ContentPage $currentPage Current page being rendered. * @param \Glaze\Template\Extension\ExtensionRegistry $extensions Registered project extensions. * @param \Glaze\Template\ContentAssetResolver|null $assetResolver Optional content asset resolver. - * @param \Glaze\Config\SiteConfig $siteConfig Site configuration used for URL helpers. + * @param \Glaze\Config\SiteConfig $siteConfig Effective site configuration for the current page (may include language overrides). * @param \Glaze\Support\ResourcePathRewriter $pathRewriter Path rewriter used by URL helpers. * @param \Glaze\Config\I18nConfig $i18nConfig I18n configuration for language helpers and string translation. * @param string $translationsPath Absolute path to the i18n translations directory. * @param \Glaze\Template\SiteIndex|null $globalIndex Full site-wide index for cross-language lookups. * When null, `$siteIndex` is used for all lookups — correct for single-language builds. + * @param \Glaze\Config\SiteConfig $baseSiteConfig Unresolved base site configuration used as the + * starting point for per-language overrides in cross-language URL helpers. */ public function __construct( protected SiteIndex $siteIndex, @@ -60,6 +68,7 @@ public function __construct( protected I18nConfig $i18nConfig = new I18nConfig(null, []), protected string $translationsPath = '', protected ?SiteIndex $globalIndex = null, + protected SiteConfig $baseSiteConfig = new SiteConfig(), ) { } @@ -80,15 +89,24 @@ public function site(): self } /** - * Return default-sorted regular pages for the current language, excluding unlisted pages. + * Return default-sorted regular page collection, excluding unlisted pages. * * On single-language builds this is identical to the full site index. On i18n builds the * result is already scoped to the current language because `$siteIndex` is a * language-specific index built by `SiteBuilder`. + * + * When `$withUntranslated` is `true`, pages from the default language that have no + * counterpart in the current language (matched by `translationKey`) are appended as + * untranslated fallbacks. Pass an ordered list of language codes to specify fallback + * languages explicitly instead of using the configured default. + * + * @param list|bool $withUntranslated When `true` the configured default language + * is used as the fallback source. Pass a list of language codes to specify one or more + * fallback languages in priority order. */ - public function regularPages(): PageCollection + public function regularPages(bool|array $withUntranslated = false): PageCollection { - return $this->siteIndex->regularPages(); + return $this->withUntranslatedFallback($this->siteIndex->regularPages(), $withUntranslated); } /** @@ -97,28 +115,42 @@ public function regularPages(): PageCollection * Unlike `regularPages()`, this collection retains pages marked as unlisted * (e.g. `_index.dj` section overview pages). On i18n builds only pages for * the current language are included. + * + * Accepts the same `$withUntranslated` parameter as `regularPages()` to append + * untranslated fallback pages from the default or specified language(s). Fallback + * candidates are sourced from `allPages()` on the global index so unlisted pages + * are included as fallbacks, consistent with the method's own scope. + * + * @param list|bool $withUntranslated Fallback language source — see `regularPages()`. */ - public function allPages(): PageCollection + public function allPages(bool|array $withUntranslated = false): PageCollection { - return $this->siteIndex->allPages(); + return $this->withUntranslatedFallback($this->siteIndex->allPages(), $withUntranslated, includeUnlisted: true); } /** * Alias for regular pages. + * + * @param list|bool $withUntranslated Fallback language source — see `regularPages()`. */ - public function pages(): PageCollection + public function pages(bool|array $withUntranslated = false): PageCollection { - return $this->regularPages(); + return $this->regularPages($withUntranslated); } /** * Return pages matching a resolved content type. * + * Accepts the same `$withUntranslated` parameter as `regularPages()` to append + * untranslated fallback pages from the default or specified language(s) before + * filtering by type. + * * @param string $type Content type name. + * @param list|bool $withUntranslated Fallback language source — see `regularPages()`. */ - public function type(string $type): PageCollection + public function type(string $type, bool|array $withUntranslated = false): PageCollection { - return $this->regularPages()->whereType($type); + return $this->regularPages($withUntranslated)->whereType($type); } /** @@ -329,30 +361,84 @@ public function next(?callable $predicate = null): ?ContentPage } /** - * Return a site-root URL path with basePath applied. + * Return a site-root URL path with basePath and language prefix applied. + * + * When a `ContentPage` is passed the URL is built using that page's own language + * configuration, ensuring cross-language links (e.g. EN posts rendered via + * `withUntranslated` on an NL page) resolve to the correct prefix instead of + * inheriting the current page's prefix. * * When `$absolute` is true, the configured `baseUrl` is prepended to produce * a fully-qualified URL. Falls back to a relative path when `baseUrl` is not * configured. * - * Example: + * For static assets that must not receive any language prefix, use `rawUrl()`. + * + * When a `ContentPage` is passed, `url()` uses the page's own language to look up + * the effective site config (with any per-language site overrides applied). Because + * `ContentPage::urlPath` is already prefixed by `withLanguage()`, no additional prefix + * is injected — only `basePath` from the language's site config is applied. This avoids + * the double-prefix that would occur if `basePath` were composed with `urlPrefix` first. + * + * Examples: + * ```php + * $this->url('/about/') // '/nl/about/' (on NL page, urlPrefix='nl') + * $this->url('/about/', true) // 'https://example.com/nl/about/' + * $this->url($enPost) // '/post-1/' (EN post on NL page, no double-prefix) + * $this->url($nlPost) // '/docs/nl/about/' (basePath=/docs, urlPath already has /nl/) + * ``` + * + * @param \Glaze\Content\ContentPage|string $path Site-root path or a content page. + * @param bool $absolute Whether to prepend the configured baseUrl. + */ + public function url(string|ContentPage $path, bool $absolute = false): string + { + if ($path instanceof ContentPage) { + $urlSiteConfig = $this->siteConfigFor($path->language); + $withBase = $this->pathRewriter->applyBasePathToPath($path->urlPath, $urlSiteConfig); + } else { + $urlSiteConfig = $this->urlSiteConfig(); + $withBase = $this->pathRewriter->applyBasePathToPath($path, $urlSiteConfig); + } + + if (!$absolute) { + return $withBase; + } + + $baseUrl = rtrim($urlSiteConfig->baseUrl ?? '', '/'); + if ($baseUrl === '') { + return $withBase; + } + + return $baseUrl . $withBase; + } + + /** + * Return a site-root URL path with basePath applied but WITHOUT any language prefix. + * + * Use this for static assets served from the `/static` folder (images, SVGs, CSS, + * JSON files, favicons, etc.) that are not language-specific and must not be + * prefixed with a language segment like `/nl/`. + * + * Examples: * ```php - * $this->url('/about/') // '/docs/about/' (with basePath=/docs) - * $this->url('/about/', true) // 'https://example.com/docs/about/' + * $this->rawUrl('/favicon.svg') // '/favicon.svg' + * $this->rawUrl('/assets/style.css') // '/docs/assets/style.css' (with basePath=/docs) + * $this->rawUrl('/favicon.svg', true) // 'https://example.com/favicon.svg' * ``` * - * @param string $path Site-root path, for example '/about/'. + * @param string $path Site-root path, for example '/favicon.svg'. * @param bool $absolute Whether to prepend the configured baseUrl. */ - public function url(string $path, bool $absolute = false): string + public function rawUrl(string $path, bool $absolute = false): string { - $withBase = $this->pathRewriter->applyBasePathToPath($path, $this->siteConfig); + $withBase = $this->pathRewriter->applyBasePathToPath($path, $this->baseSiteConfig); if (!$absolute) { return $withBase; } - $baseUrl = rtrim($this->siteConfig->baseUrl ?? '', '/'); + $baseUrl = rtrim($this->baseSiteConfig->baseUrl ?? '', '/'); if ($baseUrl === '') { return $withBase; } @@ -363,7 +449,7 @@ public function url(string $path, bool $absolute = false): string /** * Return the canonical fully-qualified URL for the current page. * - * Shorthand for `url(page()->urlPath, true)`. Returns a relative URL when + * Shorthand for `url($this->currentPage->urlPath, true)`. Returns a relative URL when * `baseUrl` is not configured. */ public function canonicalUrl(): string @@ -374,11 +460,17 @@ public function canonicalUrl(): string /** * Check if the current page URL matches a path. * - * @param string $urlPath Path to compare. + * The argument is resolved through the same language-aware URL config as `url()`, + * so templates can pass an un-prefixed site-root path (e.g. `'/about/'`) and the + * comparison still works correctly on non-default-language pages. + * + * @param string $urlPath Site-root path to compare, e.g. `'/about/'`. */ public function isCurrent(string $urlPath): bool { - return $this->normalizeUrlPath($this->currentPage->urlPath) === $this->normalizeUrlPath($urlPath); + $resolved = $this->pathRewriter->applyBasePathToPath($urlPath, $this->urlSiteConfig()); + + return $this->normalizeUrlPath($this->currentPage->urlPath) === $this->normalizeUrlPath($resolved); } /** @@ -512,7 +604,9 @@ public function t(string $key, array $params = [], string $fallback = ''): strin * * Uses the global site-wide index to find translations across all languages. * Returns null when no translation exists for the requested language or when - * i18n is disabled. The returned URL has the site basePath applied. + * i18n is disabled. The returned URL has the target language's basePath applied, + * which may differ from the current page's basePath when per-language site overrides + * are configured. * * @param string $language Language code. */ @@ -523,7 +617,7 @@ public function languageUrl(string $language): ?string return null; } - return $this->url($translated->urlPath); + return $this->pathRewriter->applyBasePathToPath($translated->urlPath, $this->siteConfigFor($language)); } /** @@ -543,6 +637,138 @@ protected function normalizeUrlPath(string $urlPath): string return $normalized === '/index' ? '/' : $normalized; } + /** + * Append untranslated fallback pages to a collection. + * + * For each fallback language (derived from `$withUntranslated`) every page in the global + * index that belongs to that language and whose `translationKey` is not already represented + * in `$pages` is appended. Pages are processed in fallback-language priority order so the + * first language to cover a key wins; subsequent languages cannot overwrite it. The result + * is a new `PageCollection` containing the original pages followed by all fallbacks. + * + * Returns `$pages` unchanged when `$withUntranslated` is `false`, i18n is disabled, or no + * fallback candidates are found. + * + * @param list|bool $withUntranslated `true` to use the configured default language, + * or a list of language codes to specify fallback languages in priority order. + * @param bool $includeUnlisted When `true`, unlisted pages in the fallback language(s) are + * eligible as fallback candidates. Should match the scope of `$pages` — pass `true` + * when the input collection came from `allPages()` and `false` (default) for `regularPages()`. + */ + protected function withUntranslatedFallback(PageCollection $pages, bool|array $withUntranslated, bool $includeUnlisted = false): PageCollection + { + if ($withUntranslated === false) { + return $pages; + } + + /** @var list $fallbackLanguages */ + $fallbackLanguages = $withUntranslated === true + ? ($this->i18nConfig->defaultLanguage !== null ? [$this->i18nConfig->defaultLanguage] : []) + : array_values($withUntranslated); + + if ($fallbackLanguages === []) { + return $pages; + } + + // Index translationKeys already present in the current-language collection. + $coveredKeys = []; + foreach ($pages->all() as $page) { + if ($page->translationKey !== '') { + $coveredKeys[$page->translationKey] = true; + } + } + + $fallbackPages = []; + foreach ($fallbackLanguages as $fallbackLanguage) { + foreach ($this->resolvedGlobalIndex()->all() as $page) { + if ($page->language !== $fallbackLanguage) { + continue; + } + + if (!$includeUnlisted && $page->unlisted) { + continue; + } + + if ($page->translationKey !== '' && isset($coveredKeys[$page->translationKey])) { + continue; + } + + $fallbackPages[] = $page; + if ($page->translationKey !== '') { + $coveredKeys[$page->translationKey] = true; + } + } + } + + if ($fallbackPages === []) { + return $pages; + } + + return new PageCollection(array_merge($pages->all(), $fallbackPages)); + } + + /** + * Return the effective SiteConfig for URL resolution for the current page. + * + * Composes the current language's `urlPrefix` into `$siteConfig->basePath` so that + * `url()` automatically prepends the language prefix without requiring every template + * call to be aware of i18n. The result is memoized for the lifetime of the context. + * + * Examples (urlPrefix='nl', basePath=null): + * - `url('/')` → `/nl/` + * - `url('/about/')` → `/nl/about/` + * - `url('/nl/about/')` → `/nl/about/` (idempotent via applyBasePathToPath) + * + * Examples (urlPrefix='nl', basePath='/docs'): + * - `url('/about/')` → `/docs/nl/about/` + */ + protected function urlSiteConfig(): SiteConfig + { + if ($this->resolvedUrlSiteConfig instanceof SiteConfig) { + return $this->resolvedUrlSiteConfig; + } + + $langConfig = $this->i18nConfig->language($this->currentPage->language); + $urlPrefix = $langConfig instanceof LanguageConfig ? trim($langConfig->urlPrefix, '/') : ''; + + if ($urlPrefix === '') { + return $this->resolvedUrlSiteConfig = $this->siteConfig; + } + + $prefix = '/' . $urlPrefix; + $basePath = $this->siteConfig->basePath; + $composedBasePath = $basePath !== null && $basePath !== '' + ? rtrim($basePath, '/') . $prefix + : $prefix; + + return $this->resolvedUrlSiteConfig = new SiteConfig( + title: $this->siteConfig->title, + description: $this->siteConfig->description, + baseUrl: $this->siteConfig->baseUrl, + basePath: $composedBasePath, + meta: $this->siteConfig->meta, + ); + } + + /** + * Return the effective SiteConfig for a given language. + * + * Merges the base site configuration with any per-language `site` overrides + * defined in the matching LanguageConfig. Returns the base config unchanged + * when the language has no overrides or is not found. + * + * @param string $language Language code to resolve the config for. + */ + protected function siteConfigFor(string $language): SiteConfig + { + $langConfig = $this->i18nConfig->language($language); + if (!$langConfig instanceof LanguageConfig || $langConfig->siteOverrides === []) { + return $this->baseSiteConfig; + } + + return $this->baseSiteConfig->withLanguageOverrides($langConfig->siteOverrides); + } + /** * Return the global site-wide index for cross-language lookups. * diff --git a/tests/Unit/Config/LanguageConfigTest.php b/tests/Unit/Config/LanguageConfigTest.php index e7c6949..3082fd4 100644 --- a/tests/Unit/Config/LanguageConfigTest.php +++ b/tests/Unit/Config/LanguageConfigTest.php @@ -162,4 +162,63 @@ public function testHasUrlPrefixReturnsTrueForNonEmptyPrefix(): void $this->assertTrue($config->hasUrlPrefix()); } + + // ------------------------------------------------------------------------- + // siteOverrides + // ------------------------------------------------------------------------- + + /** + * Ensure siteOverrides defaults to an empty array. + */ + public function testDefaultSiteOverridesIsEmpty(): void + { + $config = new LanguageConfig('en'); + + $this->assertSame([], $config->siteOverrides); + } + + /** + * Ensure fromConfig() parses a site override block into siteOverrides. + */ + public function testFromConfigParsesSiteOverrideBlock(): void + { + $config = LanguageConfig::fromConfig('nl', [ + 'label' => 'Nederlands', + 'urlPrefix' => 'nl', + 'site' => [ + 'title' => 'Mijn site', + 'hero' => ['title' => 'Hallo!'], + ], + ]); + + $this->assertSame([ + 'title' => 'Mijn site', + 'hero' => ['title' => 'Hallo!'], + ], $config->siteOverrides); + } + + /** + * Ensure fromConfig() stores an empty siteOverrides when no site block is present. + */ + public function testFromConfigStoresEmptySiteOverridesWhenAbsent(): void + { + $config = LanguageConfig::fromConfig('nl', [ + 'label' => 'Nederlands', + 'urlPrefix' => 'nl', + ]); + + $this->assertSame([], $config->siteOverrides); + } + + /** + * Ensure fromConfig() treats a non-array site block as an empty override. + */ + public function testFromConfigIgnoresNonArraySiteBlock(): void + { + $config = LanguageConfig::fromConfig('nl', [ + 'site' => 'not-an-array', + ]); + + $this->assertSame([], $config->siteOverrides); + } } diff --git a/tests/Unit/Config/SiteConfigTest.php b/tests/Unit/Config/SiteConfigTest.php index 361d04e..e1cc1c3 100644 --- a/tests/Unit/Config/SiteConfigTest.php +++ b/tests/Unit/Config/SiteConfigTest.php @@ -193,4 +193,131 @@ public function testFromProjectConfigIgnoresEmptyStringRootMetadataKey(): void $this->assertSame(['hero' => ['title' => 'Build fast']], $config->meta); } + + // ------------------------------------------------------------------------- + // withLanguageOverrides() + // ------------------------------------------------------------------------- + + /** + * Ensure withLanguageOverrides() returns the same instance for an empty override map. + */ + public function testWithLanguageOverridesReturnsBaseForEmptyOverrides(): void + { + $base = new SiteConfig(title: 'My Site', description: 'Desc'); + $result = $base->withLanguageOverrides([]); + + $this->assertSame($base, $result); + } + + /** + * Ensure withLanguageOverrides() overrides only the typed scalar fields that are present. + */ + public function testWithLanguageOverridesOverridesScalarFields(): void + { + $base = new SiteConfig( + title: 'My Site', + description: 'English description', + baseUrl: 'https://example.com', + ); + + $result = $base->withLanguageOverrides([ + 'title' => 'Mijn site', + 'description' => 'Nederlandse omschrijving', + ]); + + $this->assertSame('Mijn site', $result->title); + $this->assertSame('Nederlandse omschrijving', $result->description); + $this->assertSame('https://example.com', $result->baseUrl); + $this->assertNull($result->basePath); + } + + /** + * Ensure withLanguageOverrides() falls back to base values for scalar fields not present in override. + */ + public function testWithLanguageOverridesFallsBackToBaseForMissingScalarFields(): void + { + $base = new SiteConfig(title: 'My Site', description: 'Desc', baseUrl: 'https://example.com'); + $result = $base->withLanguageOverrides(['title' => 'Vertaald']); + + $this->assertSame('Vertaald', $result->title); + $this->assertSame('Desc', $result->description); + $this->assertSame('https://example.com', $result->baseUrl); + } + + /** + * Ensure withLanguageOverrides() deep-merges meta maps so nested keys are combined. + */ + public function testWithLanguageOverridesDeepMergesMeta(): void + { + $base = new SiteConfig( + title: 'My Site', + meta: [ + 'hero' => ['title' => 'Hi!', 'subtitle' => 'Developer'], + 'nav' => [['label' => 'Home', 'url' => '/']], + ], + ); + + $result = $base->withLanguageOverrides([ + 'hero' => ['title' => 'Hallo!'], + ]); + + $this->assertSame('Hallo!', $result->siteMeta('hero.title')); + $this->assertSame('Developer', $result->siteMeta('hero.subtitle')); + } + + /** + * Ensure withLanguageOverrides() replaces a meta key with the override value when the full key is present. + */ + public function testWithLanguageOverridesReplacesMetaKeyWhenFullKeyProvided(): void + { + $base = new SiteConfig( + title: 'My Site', + meta: ['nav' => [['label' => 'Home', 'url' => '/']]], + ); + + $result = $base->withLanguageOverrides([ + 'nav' => [['label' => 'Thuis', 'url' => '/nl/']], + ]); + + $this->assertSame([['label' => 'Thuis', 'url' => '/nl/']], $result->siteMeta('nav')); + } + + /** + * Ensure withLanguageOverrides() replaces a sequential list (numeric array) wholesale + * rather than merging by index. + * + * Overriding a 3-item base nav with a 1-item nav must yield exactly 1 item; + * previously array_replace_recursive would keep the 2 trailing base items. + */ + public function testWithLanguageOverridesReplacesNumericListsWholesale(): void + { + $base = new SiteConfig( + meta: ['nav' => [ + ['label' => 'Home', 'url' => '/'], + ['label' => 'About', 'url' => '/about/'], + ['label' => 'Blog', 'url' => '/blog/'], + ]], + ); + + $result = $base->withLanguageOverrides([ + 'nav' => [['label' => 'Start', 'url' => '/nl/']], + ]); + + /** @var array $nav */ + $nav = $result->siteMeta('nav'); + $this->assertCount(1, $nav); + $this->assertSame([['label' => 'Start', 'url' => '/nl/']], $nav); + } + + /** + * Ensure withLanguageOverrides() leaves the base untouched. + */ + public function testWithLanguageOverridesDoesNotMutateBase(): void + { + $base = new SiteConfig(title: 'My Site', meta: ['hero' => ['title' => 'Hi!']]); + $base->withLanguageOverrides(['title' => 'Vertaald', 'hero' => ['title' => 'Hallo!']]); + + $this->assertSame('My Site', $base->title); + $this->assertSame('Hi!', $base->siteMeta('hero.title')); + } } diff --git a/tests/Unit/Content/LocalizedContentDiscoveryTest.php b/tests/Unit/Content/LocalizedContentDiscoveryTest.php index 3d219db..cdeeedf 100644 --- a/tests/Unit/Content/LocalizedContentDiscoveryTest.php +++ b/tests/Unit/Content/LocalizedContentDiscoveryTest.php @@ -255,6 +255,53 @@ public function testDiscoverSortsPagesStably(): void $this->assertLessThan($nlIndex, $enIndex); } + /** + * Ensure discover() does not include non-default language pages in the default language + * when language directories are nested inside the default content directory. + * + * Without exclusion, the recursive scan of `content/` would pick up files from + * `content/nl/` too, producing duplicate pages tagged with the wrong language code. + */ + public function testDiscoverExcludesNonDefaultLanguagePagesFromDefaultLanguageScan(): void + { + $projectRoot = $this->createTempDirectory(); + + $enContent = $projectRoot . '/content'; + mkdir($enContent, 0755, true); + file_put_contents($enContent . '/index.dj', "# Home\n"); + file_put_contents($enContent . '/about.dj', "# About\n"); + + // Dutch content nested inside the default language content directory + $nlContent = $projectRoot . '/content/nl'; + mkdir($nlContent, 0755, true); + file_put_contents($nlContent . '/index.dj', "# Startpagina\n"); + file_put_contents($nlContent . '/about.dj', "# Over ons\n"); + + $i18n = I18nConfig::fromProjectConfig([ + 'defaultLanguage' => 'en', + 'languages' => [ + 'en' => ['label' => 'English', 'urlPrefix' => ''], + 'nl' => ['label' => 'Nederlands', 'urlPrefix' => 'nl', 'contentDir' => 'content/nl'], + ], + ]); + + $config = new BuildConfig(projectRoot: $projectRoot, i18n: $i18n); + $discovery = new LocalizedContentDiscovery($this->createService()); + + $pages = $discovery->discover($config); + + $enPages = array_values(array_filter($pages, static fn($page): bool => $page->language === 'en')); + $nlPages = array_values(array_filter($pages, static fn($page): bool => $page->language === 'nl')); + + // Exactly 2 English pages (index + about), not 4 — the nl/ subdir is excluded. + $this->assertCount(2, $enPages, 'Default language must not contain pages from non-default language dirs'); + $this->assertCount(2, $nlPages); + + foreach ($enPages as $page) { + $this->assertStringNotContainsString('/nl/', $page->sourcePath); + } + } + /** * Ensure discover() uses default language fallback for content path when contentDir is null. */ diff --git a/tests/Unit/Render/PageRenderPipelineTest.php b/tests/Unit/Render/PageRenderPipelineTest.php index cb3486b..4a18771 100644 --- a/tests/Unit/Render/PageRenderPipelineTest.php +++ b/tests/Unit/Render/PageRenderPipelineTest.php @@ -188,6 +188,108 @@ public function testRenderReturnsPageRenderOutputWithToc(): void $this->assertSame('Installation', $output->page->toc[1]->text); } + /** + * Ensure render uses language-specific site override when the page has a language set. + */ + public function testRenderAppliesLanguageSiteOverridesToSiteConfig(): void + { + $projectRoot = $this->copyFixtureToTemp('projects/basic'); + + file_put_contents( + $projectRoot . '/templates/page.sugar.php', + 'title ?>', + ); + + file_put_contents($projectRoot . '/glaze.neon', <<<'NEON' +site: + title: "English Site" +i18n: + defaultLanguage: en + languages: + en: + label: English + urlPrefix: "" + nl: + label: Nederlands + urlPrefix: nl + contentDir: content + site: + title: "Nederlandse site" +NEON); + + $config = BuildConfig::fromProjectRoot($projectRoot); + $page = (new ContentPage( + sourcePath: $projectRoot . '/content/index.dj', + relativePath: 'index.dj', + slug: 'index', + urlPath: '/nl/index', + outputRelativePath: 'nl/index.html', + title: 'Home', + source: 'Hello.', + draft: false, + meta: [], + ))->withLanguage('nl', 'nl', 'index'); + + $output = $this->createPipeline()->render( + config: $config, + page: $page, + pageTemplate: 'page', + debug: false, + ); + + $this->assertSame('Nederlandse site', $output->html); + } + + /** + * Ensure render uses base site config when no language site override is configured. + */ + public function testRenderUsesBaseSiteConfigWithoutLanguageOverride(): void + { + $projectRoot = $this->copyFixtureToTemp('projects/basic'); + + file_put_contents( + $projectRoot . '/templates/page.sugar.php', + 'title ?>', + ); + + file_put_contents($projectRoot . '/glaze.neon', <<<'NEON' +site: + title: "English Site" +i18n: + defaultLanguage: en + languages: + en: + label: English + urlPrefix: "" + nl: + label: Nederlands + urlPrefix: nl + contentDir: content +NEON); + + $config = BuildConfig::fromProjectRoot($projectRoot); + $page = (new ContentPage( + sourcePath: $projectRoot . '/content/index.dj', + relativePath: 'index.dj', + slug: 'index', + urlPath: '/nl/index', + outputRelativePath: 'nl/index.html', + title: 'Home', + source: 'Hello.', + draft: false, + meta: [], + ))->withLanguage('nl', 'nl', 'index'); + + $output = $this->createPipeline()->render( + config: $config, + page: $page, + pageTemplate: 'page', + debug: false, + ); + + $this->assertSame('English Site', $output->html); + } + /** * Create a PageRenderPipeline instance via the DI container. */ diff --git a/tests/Unit/Template/SiteContextTest.php b/tests/Unit/Template/SiteContextTest.php index 4d123b7..d37dd02 100644 --- a/tests/Unit/Template/SiteContextTest.php +++ b/tests/Unit/Template/SiteContextTest.php @@ -336,6 +336,279 @@ public function testCanonicalUrlReturnsFullyQualifiedPageUrl(): void $this->assertSame('https://example.com/glaze/docs/intro/', $withBoth->canonicalUrl()); } + /** + * Validate url() automatically prepends the current language's urlPrefix when i18n is enabled. + */ + public function testUrlPrependsLanguageUrlPrefix(): void + { + $nlPage = $this->makeLocalizedPage('nl/about', '/nl/about/', 'about.dj', 'nl', 'about.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$nlPage]), + currentPage: $nlPage, + i18nConfig: $i18n, + ); + + $this->assertSame('/nl/', $context->url('/')); + $this->assertSame('/nl/about/', $context->url('/about/')); + } + + /** + * Validate url() with a urlPrefix is idempotent — paths already containing the prefix + * are not double-prefixed. + */ + public function testUrlWithPrefixIsIdempotent(): void + { + $nlPage = $this->makeLocalizedPage('nl/about', '/nl/about/', 'about.dj', 'nl', 'about.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$nlPage]), + currentPage: $nlPage, + i18nConfig: $i18n, + ); + + // Already-prefixed path must not get double-prefixed + $this->assertSame('/nl/about/', $context->url('/nl/about/')); + } + + /** + * Validate url() composes urlPrefix with an existing basePath. + */ + public function testUrlComposesUrlPrefixWithBasePath(): void + { + $nlPage = $this->makeLocalizedPage('nl/about', '/nl/about/', 'about.dj', 'nl', 'about.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$nlPage]), + currentPage: $nlPage, + siteConfig: new SiteConfig(baseUrl: 'https://example.com', basePath: '/docs'), + i18nConfig: $i18n, + ); + + $this->assertSame('/docs/nl/about/', $context->url('/about/')); + $this->assertSame('https://example.com/docs/nl/about/', $context->url('/about/', true)); + } + + /** + * Validate url() does not apply a language prefix for the default language (empty urlPrefix). + */ + public function testUrlDoesNotPrefixDefaultLanguage(): void + { + $enPage = $this->makeLocalizedPage('about', '/about/', 'about.dj', 'en', 'about.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$enPage]), + currentPage: $enPage, + i18nConfig: $i18n, + ); + + $this->assertSame('/', $context->url('/')); + $this->assertSame('/about/', $context->url('/about/')); + } + + /** + * Validate canonicalUrl() works correctly for a language-prefixed page — the urlPath already + * contains the prefix so applyBasePathToPath's idempotency guard prevents double-prefixing. + */ + public function testCanonicalUrlIsNotDoublePrefixedForLocalizedPage(): void + { + $nlPage = $this->makeLocalizedPage('nl/about', '/nl/about/', 'about.dj', 'nl', 'about.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$nlPage]), + currentPage: $nlPage, + siteConfig: new SiteConfig(baseUrl: 'https://example.com'), + i18nConfig: $i18n, + ); + + $this->assertSame('https://example.com/nl/about/', $context->canonicalUrl()); + } + + /** + * Validate isCurrent() returns true when passed a site-root path that resolves to the current + * NL page URL after the language prefix is applied. + */ + public function testIsCurrentIsLanguageAware(): void + { + $nlPage = $this->makeLocalizedPage('nl/about', '/nl/about/', 'about.dj', 'nl', 'about.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$nlPage]), + currentPage: $nlPage, + i18nConfig: $i18n, + ); + + // Un-prefixed path resolves to /nl/about/ — must match the current page + $this->assertTrue($context->isCurrent('/about/')); + // Already-prefixed path is also idempotent + $this->assertTrue($context->isCurrent('/nl/about/')); + // A different path must not match + $this->assertFalse($context->isCurrent('/')); + $this->assertFalse($context->isCurrent('/contact/')); + } + + /** + * Validate isCurrent() still works correctly for a non-prefixed default language page. + */ + public function testIsCurrentDefaultLanguageUnchanged(): void + { + $enPage = $this->makeLocalizedPage('about', '/about/', 'about.dj', 'en', 'about.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$enPage]), + currentPage: $enPage, + i18nConfig: $i18n, + ); + + $this->assertTrue($context->isCurrent('/about/')); + $this->assertFalse($context->isCurrent('/nl/about/')); + } + + /** + * Validate url(ContentPage) uses the page's own language config, so an EN + * ContentPage rendered on an NL-context page does not get the NL prefix. + */ + public function testUrlWithContentPageUsesPageLanguage(): void + { + $enPost = $this->makeLocalizedPage('post-1', '/post-1/', 'post-1.dj', 'en', 'post-1.dj'); + $nlPage = $this->makeLocalizedPage('nl/about', '/nl/about/', 'about.dj', 'nl', 'about.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$nlPage]), + currentPage: $nlPage, + i18nConfig: $i18n, + ); + + // String path on NL context: gets NL prefix + $this->assertSame('/nl/post-1/', $context->url('/post-1/')); + // ContentPage with EN language on NL context: no prefix (EN has no urlPrefix) + $this->assertSame('/post-1/', $context->url($enPost)); + } + + /** + * Validate url(ContentPage) for an NL page returns the NL-prefixed URL. + */ + public function testUrlWithNlContentPageReturnsPrefixedUrl(): void + { + $nlPost = $this->makeLocalizedPage('nl/post-1', '/nl/post-1/', 'post-1.dj', 'nl', 'post-1.dj'); + $nlPage = $this->makeLocalizedPage('nl/about', '/nl/about/', 'about.dj', 'nl', 'about.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$nlPage]), + currentPage: $nlPage, + i18nConfig: $i18n, + ); + + // NL ContentPage on NL context: keeps existing NL prefix (idempotent) + $this->assertSame('/nl/post-1/', $context->url($nlPost)); + } + + /** + * Validate url(ContentPage) does not double-prefix the language segment when a basePath is set. + * + * basePath='/docs' plus a NL page whose urlPath is '/nl/about/' must produce '/docs/nl/about/', + * not '/docs/nl/nl/about/' (which the old urlSiteConfigFor() compose would yield). + */ + public function testUrlWithContentPageDoesNotDoublePrefixWithBasePath(): void + { + $nlPost = $this->makeLocalizedPage('nl/about', '/nl/about/', 'about.dj', 'nl', 'about.dj'); + $nlPage = $this->makeLocalizedPage('nl/home', '/nl/', 'index.dj', 'nl', 'index.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$nlPage]), + currentPage: $nlPage, + i18nConfig: $i18n, + baseSiteConfig: new SiteConfig(basePath: '/docs'), + ); + + // urlPath already carries the /nl/ segment; basePath /docs is applied once + $this->assertSame('/docs/nl/about/', $context->url($nlPost)); + } + + /** + * Validate rawUrl() returns the path without any language prefix, suitable for + * static assets that live in the /static folder. + */ + public function testRawUrlSkipsLanguagePrefix(): void + { + $nlPage = $this->makeLocalizedPage('nl/about', '/nl/about/', 'about.dj', 'nl', 'about.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$nlPage]), + currentPage: $nlPage, + i18nConfig: $i18n, + ); + + $this->assertSame('/favicon.svg', $context->rawUrl('/favicon.svg')); + $this->assertSame('/assets/style.css', $context->rawUrl('/assets/style.css')); + } + + /** + * Validate rawUrl() respects basePath but not the language prefix. + */ + public function testRawUrlAppliesBasePathWithoutLanguagePrefix(): void + { + $nlPage = $this->makeLocalizedPage('nl/about', '/nl/about/', 'about.dj', 'nl', 'about.dj'); + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $context = new SiteContext( + siteIndex: new SiteIndex([$nlPage]), + currentPage: $nlPage, + i18nConfig: $i18n, + baseSiteConfig: new SiteConfig(baseUrl: 'https://example.com', basePath: '/docs'), + ); + + $this->assertSame('/docs/favicon.svg', $context->rawUrl('/favicon.svg')); + $this->assertSame('https://example.com/docs/favicon.svg', $context->rawUrl('/favicon.svg', true)); + } + /** * Create a content page object for test scenarios. * @@ -587,6 +860,34 @@ public function testLanguageUrlReturnsTranslatedPageUrl(): void $this->assertSame('/nl/about/', $context->languageUrl('nl')); } + /** + * Validate languageUrl() applies the target language's basePath when that language + * has per-language site overrides that include a different basePath. + */ + public function testLanguageUrlAppliesTargetLanguageBasePath(): void + { + $enAbout = $this->makeLocalizedPage('about', '/about/', 'about.dj', 'en', 'about.dj', 'About'); + $nlAbout = $this->makeLocalizedPage('nl/about', '/nl/about/', 'about.dj', 'nl', 'about.dj', 'Over ons'); + $index = new SiteIndex([$enAbout, $nlAbout]); + + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en'), + 'nl' => new LanguageConfig('nl', siteOverrides: ['basePath' => '/app']), + ]); + + $context = new SiteContext( + siteIndex: $index, + currentPage: $enAbout, + i18nConfig: $i18n, + baseSiteConfig: new SiteConfig(), + ); + + // NL URL must use the NL basePath (/app), not the default site basePath + $this->assertSame('/app/nl/about/', $context->languageUrl('nl')); + // EN URL from English perspective is unaffected (uses baseSiteConfig, no basePath) + $this->assertSame('/about/', $context->languageUrl('en')); + } + /** * Validate languageUrl() returns null when no translation exists for the requested language. */ @@ -619,6 +920,263 @@ public function testTranslationLoaderIsMemoized(): void $this->assertSame('Value', $context->t('key')); } + // ------------------------------------------------------------------------- + // i18n: withUntranslated language fallbacks + // ------------------------------------------------------------------------- + + /** + * Build a shared dual-index context for withUntranslated tests. + * + * NL site-index: nl/post-a, nl/post-b (both are 'post' type) + * EN global pages: en about, en post-a, en post-b, en post-c, en post-d + * post-a and post-b have NL translations; post-c and post-d do not. + * + * @return array{\Glaze\Config\I18nConfig, \Glaze\Template\SiteIndex, \Glaze\Template\SiteIndex, \Glaze\Content\ContentPage} + */ + protected function buildWithUntranslatedContext(): array + { + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $enAbout = $this->makeLocalizedPage('about', '/about/', 'about.dj', 'en', 'about.dj'); + $enPostA = $this->makeLocalizedPage('post-a', '/post-a/', 'post-a.dj', 'en', 'post-a.dj'); + $enPostB = $this->makeLocalizedPage('post-b', '/post-b/', 'post-b.dj', 'en', 'post-b.dj'); + $enPostC = $this->makeLocalizedPage('post-c', '/post-c/', 'post-c.dj', 'en', 'post-c.dj'); + $enPostD = $this->makeLocalizedPage('post-d', '/post-d/', 'post-d.dj', 'en', 'post-d.dj'); + $nlPostA = $this->makeLocalizedPage('nl/post-a', '/nl/post-a/', 'post-a.dj', 'nl', 'post-a.dj'); + $nlPostB = $this->makeLocalizedPage('nl/post-b', '/nl/post-b/', 'post-b.dj', 'nl', 'post-b.dj'); + + $nlIndex = new SiteIndex([$nlPostA, $nlPostB]); + $globalIndex = new SiteIndex([$enAbout, $enPostA, $enPostB, $enPostC, $enPostD, $nlPostA, $nlPostB]); + + return [$i18n, $nlIndex, $globalIndex, $nlPostA]; + } + + /** + * Validate regularPages(withUntranslated: true) appends EN pages that have no NL translation. + */ + public function testRegularPagesWithUntranslatedAppendsFallbackPages(): void + { + [$i18n, $nlIndex, $globalIndex, $currentPage] = $this->buildWithUntranslatedContext(); + + $context = new SiteContext( + siteIndex: $nlIndex, + currentPage: $currentPage, + i18nConfig: $i18n, + globalIndex: $globalIndex, + ); + + $pages = $context->regularPages(withUntranslated: true); + + $slugs = array_map(static fn(ContentPage $p): string => $p->slug, $pages->all()); + + // NL translations come first + $this->assertContains('nl/post-a', $slugs); + $this->assertContains('nl/post-b', $slugs); + // EN fallbacks for pages without NL translations are appended + $this->assertContains('post-c', $slugs); + $this->assertContains('post-d', $slugs); + // EN about also has no NL translation, so it appears too + $this->assertContains('about', $slugs); + $this->assertCount(5, $pages); + } + + /** + * Validate regularPages(withUntranslated: true) does NOT include EN pages whose + * translationKey is already covered by a NL page. + */ + public function testRegularPagesWithUntranslatedDoesNotDuplicateTranslatedPages(): void + { + [$i18n, $nlIndex, $globalIndex, $currentPage] = $this->buildWithUntranslatedContext(); + + $context = new SiteContext( + siteIndex: $nlIndex, + currentPage: $currentPage, + i18nConfig: $i18n, + globalIndex: $globalIndex, + ); + + $pages = $context->regularPages(withUntranslated: true); + + $slugs = array_map(static fn(ContentPage $p): string => $p->slug, $pages->all()); + + // EN post-a and post-b must NOT appear — they have NL translations + $this->assertNotContains('post-a', $slugs); + $this->assertNotContains('post-b', $slugs); + // EN about is not a post but also has no NL translation — it will appear + $this->assertContains('about', $slugs); + } + + /** + * Validate regularPages(withUntranslated: false) (the default) is unchanged. + */ + public function testRegularPagesWithUntranslatedFalseIsUnchanged(): void + { + [$i18n, $nlIndex, $globalIndex, $currentPage] = $this->buildWithUntranslatedContext(); + + $context = new SiteContext( + siteIndex: $nlIndex, + currentPage: $currentPage, + i18nConfig: $i18n, + globalIndex: $globalIndex, + ); + + $this->assertCount(2, $context->regularPages()); + $this->assertCount(2, $context->regularPages(withUntranslated: false)); + } + + /** + * Validate regularPages(withUntranslated: ['en']) matches the explicit single-language list. + */ + public function testRegularPagesWithUntranslatedExplicitLanguageList(): void + { + [$i18n, $nlIndex, $globalIndex, $currentPage] = $this->buildWithUntranslatedContext(); + + $context = new SiteContext( + siteIndex: $nlIndex, + currentPage: $currentPage, + i18nConfig: $i18n, + globalIndex: $globalIndex, + ); + + $withTrue = $context->regularPages(withUntranslated: true); + $withArray = $context->regularPages(withUntranslated: ['en']); + + // Both should produce identical results when defaultLanguage is 'en' + $this->assertCount(count($withTrue), $withArray); + $this->assertSame( + array_map(static fn(ContentPage $p): string => $p->slug, $withTrue->all()), + array_map(static fn(ContentPage $p): string => $p->slug, $withArray->all()), + ); + } + + /** + * Validate type(withUntranslated: true) applies the type filter after merging fallbacks, + * so fallback pages must also match the requested type to appear. + */ + public function testTypeWithUntranslatedFiltersAfterFallbackMerge(): void + { + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + $enAbout = new ContentPage( + sourcePath: '/tmp/about.dj', + relativePath: 'about.dj', + slug: 'about', + urlPath: '/about/', + outputRelativePath: 'about/index.html', + title: 'About', + source: '# About', + draft: false, + meta: ['type' => 'page'], + language: 'en', + translationKey: 'about.dj', + ); + $enPost = new ContentPage( + sourcePath: '/tmp/post-1.dj', + relativePath: 'post-1.dj', + slug: 'post-1', + urlPath: '/post-1/', + outputRelativePath: 'post-1/index.html', + title: 'Post 1', + source: '# Post 1', + draft: false, + meta: ['type' => 'post'], + language: 'en', + translationKey: 'post-1.dj', + ); + $nlPost = new ContentPage( + sourcePath: '/tmp/nl-post-1.dj', + relativePath: 'post-1.dj', + slug: 'nl/post-1', + urlPath: '/nl/post-1/', + outputRelativePath: 'nl/post-1/index.html', + title: 'Post 1 NL', + source: '# Post 1', + draft: false, + meta: ['type' => 'post'], + language: 'nl', + translationKey: 'post-1.dj', + ); + + $nlIndex = new SiteIndex([$nlPost]); + $globalIndex = new SiteIndex([$enAbout, $enPost, $nlPost]); + + $context = new SiteContext( + siteIndex: $nlIndex, + currentPage: $nlPost, + i18nConfig: $i18n, + globalIndex: $globalIndex, + ); + + // type 'post': NL post covers the translationKey, so EN post is NOT added as fallback + $posts = $context->type('post', withUntranslated: true); + $this->assertCount(1, $posts); + $this->assertSame('nl/post-1', $posts->first()?->slug); + + // type 'page': EN about has no NL translation, so it appears as a fallback + $pagesCollection = $context->type('page', withUntranslated: true); + $this->assertCount(1, $pagesCollection); + $this->assertSame('about', $pagesCollection->first()?->slug); + } + + /** + * Validate regularPages(withUntranslated: true) does not include unlisted pages (e.g. _index.dj) + * when they appear in the default-language fallback set. + * + * An unlisted EN _index.dj should never surface as a fallback for an NL site index that + * only contains regular translated posts. + */ + public function testRegularPagesWithUntranslatedSkipsUnlistedFallbacks(): void + { + $i18n = new I18nConfig('en', [ + 'en' => new LanguageConfig('en', urlPrefix: ''), + 'nl' => new LanguageConfig('nl', urlPrefix: 'nl', contentDir: 'content/nl'), + ]); + + // EN _index section page is unlisted — must not appear as a fallback + $enIndex = new ContentPage( + sourcePath: '/tmp/_index.dj', + relativePath: '_index.dj', + slug: '_index', + urlPath: '/', + outputRelativePath: 'index.html', + title: 'Home', + source: '# Home', + draft: false, + meta: [], + unlisted: true, + language: 'en', + translationKey: '_index.dj', + ); + $enPost = $this->makeLocalizedPage('post-1', '/post-1/', 'post-1.dj', 'en', 'post-1.dj'); + $nlPost = $this->makeLocalizedPage('nl/post-1', '/nl/post-1/', 'post-1.dj', 'nl', 'post-1.dj'); + + // NL index only has the translated post; EN has the unlisted _index + translated post + $nlSiteIndex = new SiteIndex([$nlPost]); + $globalIndex = new SiteIndex([$enIndex, $enPost, $nlPost]); + + $context = new SiteContext( + siteIndex: $nlSiteIndex, + currentPage: $nlPost, + i18nConfig: $i18n, + globalIndex: $globalIndex, + ); + + // nl/post-1 already covers post-1.dj, so no fallback needed for it. + // _index.dj is unlisted in EN and must NOT appear; result should be exactly the NL post. + $pages = $context->regularPages(withUntranslated: true); + $this->assertCount(1, $pages); + + $slugs = array_map(static fn(ContentPage $p): string => $p->slug, $pages->all()); + $this->assertNotContains('_index', $slugs); + $this->assertContains('nl/post-1', $slugs); + } + // ------------------------------------------------------------------------- // Dual-index: language-scoped navigation + cross-language translation lookups // -------------------------------------------------------------------------