Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 123 additions & 2 deletions docs/content/content/i18n.dj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/`:
Expand Down Expand Up @@ -249,7 +309,7 @@ code. Useful for generating `<link rel="alternate" hreflang="...">` tags.
<link s:foreach="$this->translations() as $lang => $translated"
rel="alternate"
hreflang="<?= $lang ?>"
href="<?= $this->url($translated->urlPath) ?>">
href="<?= $this->url($translated) ?>">
```

### Single translation
Expand All @@ -268,7 +328,7 @@ On single-language sites this is equivalent to `$this->regularPages()`.

```sugar
<a s:foreach="$this->localizedPages() as $p"
href="<?= $this->url($p->urlPath) ?>"><?= $p->title ?></a>
href="<?= $this->url($p) ?>"><?= $p->title ?></a>
```

### Translate a string
Expand All @@ -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
<a href="<?= $this->url($post) ?>"><?= $post->title ?></a>

// problematic: always applies the current page's language prefix
<a href="<?= $this->url($post->urlPath) ?>"><?= $post->title ?></a>
```

For static files that must never receive a language prefix (favicons, JS/CSS bundles, JSON feeds,
images in `static/`) use `rawUrl()`:

```php
<link rel="icon" href="<?= $this->rawUrl('/favicon.svg') ?>" />
<script src="<?= $this->rawUrl('/assets/site.js') ?>"></script>
```

`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**
Expand Down
7 changes: 7 additions & 0 deletions docs/content/getting-started/configuration.dj
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ i18n:
label: Nederlands
urlPrefix: /nl
contentDir: content/nl
site:
title: "Nederlandse site"
description: "Een Nederlandstalige omschrijving."
```

## `pageTemplate`
Expand Down Expand Up @@ -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.
Expand Down
38 changes: 35 additions & 3 deletions docs/content/templating/index.dj
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
```

Expand All @@ -162,10 +171,33 @@ Use `$this->url()` for all internal `href` and `src` attributes. Pass `true` as
```php
<link rel="canonical" href="<?= $this->canonicalUrl() ?>" />
<meta property="og:url" content="<?= $this->canonicalUrl() ?>" />
<meta property="og:image" content="<?= $this->url('/og-image.png', true) ?>" />
<meta property="og:image" content="<?= $this->rawUrl('/og-image.png', true) ?>" />
<a href="<?= $this->url('/quick-start/') ?>">Get started</a>
```

#### 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
<a href="<?= $this->url($post) ?>"><?= $post->title ?></a>

// problematic on i18n sites: always applies the current page's language prefix
<a href="<?= $this->url($post->urlPath) ?>"><?= $post->title ?></a>
```

#### `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
<link rel="icon" href="<?= $this->rawUrl('/favicon.svg') ?>" />
<script src="<?= $this->rawUrl('/assets/site.js') ?>"></script>
```

`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:
Expand Down
17 changes: 17 additions & 0 deletions docs/content/templating/page-collections.dj
Original file line number Diff line number Diff line change
Expand Up @@ -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
<a s:foreach="$this->regularPages(withUntranslated: true) as $p"
href="<?= $this->url($p) ?>"><?= $p->title ?></a>
```

## `ContentPage` properties

Every item in a collection is a `ContentPage` object:
Expand Down
17 changes: 17 additions & 0 deletions src/Config/LanguageConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
* label: Nederlands
* urlPrefix: nl
* contentDir: content/nl
* site:
* title: "Mijn site"
* hero:
* title: "Hallo!"
* ```
*/
final class LanguageConfig
Expand All @@ -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<string, mixed> $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 = [],
) {
}

Expand All @@ -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,
);
}

Expand Down
63 changes: 63 additions & 0 deletions src/Config/SiteConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,43 @@
);
}

/**
* 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<string, mixed> $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<string, mixed> $mergedMeta */
$mergedMeta = self::deepMerge($this->meta, $overrideConfig->meta);

Comment on lines +106 to +110
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

array_replace_recursive() will merge numeric arrays by index and keep any base elements that are not present in the override. For meta keys that are lists (e.g. nav), providing an override with fewer items than the base will unintentionally retain trailing base items instead of fully replacing the list, which conflicts with the documented/expected “override” semantics. Consider implementing a merge that deep-merges associative arrays but replaces numeric arrays wholesale when the override provides an array for that key.

Copilot uses AI. Check for mistakes.
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.
*
Expand Down Expand Up @@ -193,4 +230,30 @@

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<string, mixed> $base Base metadata.
* @param array<string, mixed> $override Override metadata.
* @return array<string, mixed>
*/
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);

Check failure on line 253 in src/Config/SiteConfig.php

View workflow job for this annotation

GitHub Actions / static-analysis

Parameter #2 $override of static method Glaze\Config\SiteConfig::deepMerge() expects array<string, mixed>, array<mixed, mixed> given.

Check failure on line 253 in src/Config/SiteConfig.php

View workflow job for this annotation

GitHub Actions / static-analysis

Parameter #1 $base of static method Glaze\Config\SiteConfig::deepMerge() expects array<string, mixed>, array<mixed, mixed> given.
}
}

return $base;
}
}
Loading
Loading