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="= $this->url($translated) ?>">
```
### Single translation
@@ -268,7 +328,7 @@ On single-language sites this is equivalent to `$this->regularPages()`.
```sugar
= $p->title ?>
+ href="= $this->url($p) ?>">= $p->title ?>
```
### Translate a string
@@ -283,6 +343,67 @@ substitution via `$params`. Falls back to the default language, then returns `$f
= $this->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
+= $post->title ?>
+
+// problematic: always applies the current page's language prefix
+= $post->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
+= $post->title ?>
+
+// problematic on i18n sites: always applies the current page's language prefix
+= $post->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
+= $p->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',
+ '= $site->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',
+ '= $site->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
// -------------------------------------------------------------------------