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
62 changes: 51 additions & 11 deletions docs/features/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ i18n:
| `enabled` | `boolean` | `false` | Enable i18n UI |
| `defaultLocale` | `string` | `"en"` | The canonical locale — builds from `content.dir` and is what untranslated pages fall back to |
| `locales` | `array` | `[]` | All available locales |
| `strings` | `object` | `{}` | UI string overrides (see [UI Strings](#ui-strings)) |
| `strings` | `object` | `{}` | Shared UI string fallbacks for all locales (see [UI Strings](#ui-strings)) |

### Locale Entry

Expand All @@ -73,6 +73,7 @@ Each item in `locales` has:
| `dir` | `string` | No | Text direction — `"ltr"` (default) or `"rtl"` |
| `source` | `string` | No | Directory holding this locale's translations. Defaults to `<content.dir>/<code>` |
| `sidebar` | `array` | No | Sidebar for this locale, replacing the shared one — use it to translate group titles and page labels |
| `strings` | `object` | No | UI string overrides for this locale; takes precedence over `i18n.strings` |

## With versioning

Expand Down Expand Up @@ -126,20 +127,41 @@ This tells search engines which page variants exist for each language.

## UI Strings

All UI chrome text (buttons, labels, navigation) can be overridden per locale via the `strings` map. Use dot-notation keys:
UI chrome text (buttons, labels, navigation, and status messages) uses three
levels of fallback:

1. A locale's `strings` map, when that locale overrides the key.
2. The top-level `i18n.strings` map, shared by every locale.
3. Stardust's built-in English default.

Use dot-notation keys. This example supplies shared English text and overrides
the UI for the Arabic locale:

```yaml
i18n:
enabled: true
defaultLocale: ar
defaultLocale: en
strings:
nav.previous: "→ السابق"
nav.next: "التالي ←"
footer.poweredBy: "مدعوم بواسطة"
theme.toggle: "تبديل الوضع الداكن"
menu.toggle: "القائمة"
menu.close: "إغلاق"
locale.select: "اختر اللغة"
footer.poweredBy: "Powered by"
search.placeholder: "Search docs..."
locales:
- code: en
label: English
path: /
- code: ar
label: العربية
dir: rtl
path: /ar/
strings:
nav.previous: "→ السابق"
nav.next: "التالي ←"
toc.title: "في هذه الصفحة"
footer.poweredBy: "مدعوم بواسطة"
search.placeholder: "ابحث في التوثيق..."
theme.toggle: "تبديل الوضع الداكن"
menu.toggle: "القائمة"
menu.close: "إغلاق"
locale.select: "اختر اللغة"
```

### Available String Keys
Expand All @@ -148,23 +170,41 @@ i18n:
|-----|---------|---------|
| `nav.previous` | `"← Previous"` | Previous page link |
| `nav.next` | `"Next →"` | Next page link |
| `toc.title` | `"On this page"` | Table of contents heading |
| `footer.poweredBy` | `"Powered by"` | Footer |
| `theme.toggle` | `"Toggle dark mode"` | Theme button aria-label |
| `menu.toggle` | `"Toggle menu"` | Mobile menu button aria-label |
| `menu.close` | `"Close menu"` | Sidebar close button aria-label |
| `announcement.dismiss` | `"Dismiss announcement"` | Announcement dismiss aria-label |
| `version.select` | `"Select version"` | Version dropdown aria-label |
| `version.dismiss` | `"Dismiss banner"` | Version banner dismiss aria-label |
| `search.placeholder` | `"Search docs..."` | Search trigger, dialog, and input |
| `search.noResults` | `"No results found for \"%s\""` | Search — no results (`%s` = query) |
| `search.oneResult` | `"1 result"` | Search — single result |
| `search.manyResults` | `"%s results"` | Search — multiple results (`%s` = count) |
| `search.searching` | `"Searching..."` | Search — loading state |
| `search.clear` | `"Clear search"` | Clear-search button aria-label |
| `search.more` | `"Load more results"` | Load-more-results button |
| `search.unavailable` | `"Search is unavailable"` | Search-index loading failure |
| `code.copy` | `"Copy"` | Code copy button text |
| `code.copyLabel` | `"Copy code"` | Code copy button aria-label |
| `code.copied` | `"Copied!"` | Copy success feedback |
| `code.copyFailed` | `"Copy failed"` | Copy failure feedback |
| `locale.select` | `"Select language"` | Locale dropdown aria-label |
| `locale.untranslated` | `"This page has not been translated yet."` | Notice on pages that fell back to the default locale |
| `page.copyMarkdown` | `"Copy page as Markdown"` | Copy-page button |
| `page.readingTime` | `"%s min read"` | Reading-time label (`%s` = minutes) |
| `page.lastUpdated` | `"Last updated %s"` | Last-updated label (`%s` = date) |
| `dartdoc.api` | `"API"` | Badge on generated dartdoc pages |
| `dartdoc.backToDocs` | `"← Back to docs"` | Link from dartdoc pages back to the documentation |

Locale-specific values can be partial. Any omitted key falls back to
`i18n.strings`, then to the English default shown above.

When no overrides are provided, English defaults are used.
`search.placeholder` and `toc.title` preserve the corresponding top-level
`search.placeholder` and `toc.title` settings as an additional fallback. This
keeps existing single-language configuration working while allowing each
locale to override those labels.

## Workflow

Expand Down
23 changes: 22 additions & 1 deletion lib/src/config/config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,28 @@ class StardustConfig {
this.untranslatedPaths,
});

I18nStrings get i18nStrings => i18n?.strings ?? const I18nStrings();
/// UI strings for the current locale. Locale-specific values win over the
/// shared i18n strings; before a locale build is selected, the configured
/// default locale is used.
I18nStrings get i18nStrings {
final i18nConfig = i18n;
if (i18nConfig == null) return const I18nStrings();

final selectedCode = activeLocale?.code ?? i18nConfig.defaultLocale;
final selectedStrings = activeLocale?.strings ??
i18nConfig.locales
.where((locale) => locale.code == selectedCode)
.map((locale) => locale.strings)
.whereType<I18nStrings>()
.firstOrNull;
return selectedStrings ?? i18nConfig.strings;
}

/// Locale override, then the existing search configuration.
String get searchPlaceholder => i18nStrings.searchPlaceholder ?? search.placeholder;

/// Locale override, then the existing table-of-contents configuration.
String get tocTitle => i18nStrings.tocTitle ?? toc.title;

String get lang => activeLocale?.code ?? i18n?.defaultLocale ?? 'en';

Expand Down
121 changes: 89 additions & 32 deletions lib/src/config/i18n_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,20 @@ class I18nConfig {
this.strings = const I18nStrings(),
});

factory I18nConfig.fromYaml(Map? yaml) => switch (yaml) {
final Map yaml => I18nConfig(
enabled: yaml['enabled'] as bool? ?? false,
defaultLocale: yaml['defaultLocale'] as String? ?? 'en',
locales: (yaml['locales'] as List?)?.map((e) => LocaleConfig.fromYaml(e as Map)).toList() ?? [],
strings: I18nStrings.fromYaml(yaml['strings'] as Map?),
),
_ => const I18nConfig(),
};
factory I18nConfig.fromYaml(Map? yaml) {
if (yaml == null) return const I18nConfig();

final strings = I18nStrings.fromYaml(yaml['strings'] as Map?);
return I18nConfig(
enabled: yaml['enabled'] as bool? ?? false,
defaultLocale: yaml['defaultLocale'] as String? ?? 'en',
locales: (yaml['locales'] as List?)
?.map((entry) => LocaleConfig.fromYaml(entry as Map, stringFallback: strings))
.toList() ??
[],
strings: strings,
);
}
}

class LocaleConfig {
Expand All @@ -96,28 +101,43 @@ class LocaleConfig {
/// titles and page labels. Defaults to the shared sidebar.
final List<SidebarGroup>? sidebar;

/// UI string overrides for this locale. Values omitted from YAML inherit the
/// shared [I18nConfig.strings] values.
final I18nStrings? strings;

const LocaleConfig({
required this.code,
required this.label,
this.dir = 'ltr',
required this.path,
this.source,
this.sidebar,
this.strings,
});

factory LocaleConfig.fromYaml(Map yaml) => LocaleConfig(
factory LocaleConfig.fromYaml(
Map yaml, {
I18nStrings stringFallback = const I18nStrings(),
}) =>
LocaleConfig(
code: yaml['code'] as String,
label: yaml['label'] as String,
dir: yaml['dir'] as String? ?? 'ltr',
path: yaml['path'] as String,
source: yaml['source'] as String?,
sidebar: (yaml['sidebar'] as List?)?.map((e) => SidebarGroup.fromYaml(e as Map)).toList(),
strings: switch (yaml['strings']) {
final Map strings => I18nStrings.fromYaml(strings, fallback: stringFallback),
_ => null,
},
);
}

class I18nStrings {
final String? searchPlaceholder;
final String navPrevious;
final String navNext;
final String? tocTitle;
final String footerPoweredBy;
final String themeToggle;
final String menuToggle;
Expand All @@ -132,14 +152,23 @@ class I18nStrings {
final String searchClear;
final String searchMore;
final String searchUnavailable;
final String codeCopy;
final String codeCopyLabel;
final String codeCopied;
final String codeCopyFailed;
final String localeSelect;
final String localeUntranslated;
final String pageCopyMarkdown;
final String readingTime;
final String lastUpdated;
final String dartdocApi;
final String dartdocBackToDocs;

const I18nStrings({
this.searchPlaceholder,
this.navPrevious = '← Previous',
this.navNext = 'Next →',
this.tocTitle,
this.footerPoweredBy = 'Powered by',
this.themeToggle = 'Toggle dark mode',
this.menuToggle = 'Toggle menu',
Expand All @@ -154,15 +183,24 @@ class I18nStrings {
this.searchClear = 'Clear search',
this.searchMore = 'Load more results',
this.searchUnavailable = 'Search is unavailable',
this.codeCopy = 'Copy',
this.codeCopyLabel = 'Copy code',
this.codeCopied = 'Copied!',
this.codeCopyFailed = 'Copy failed',
this.localeSelect = 'Select language',
this.localeUntranslated = 'This page has not been translated yet.',
this.pageCopyMarkdown = 'Copy page as Markdown',
this.readingTime = '%s min read',
this.lastUpdated = 'Last updated %s',
this.dartdocApi = 'API',
this.dartdocBackToDocs = '← Back to docs',
});

static const _keyMap = {
'search.placeholder': 'searchPlaceholder',
'nav.previous': 'navPrevious',
'nav.next': 'navNext',
'toc.title': 'tocTitle',
'footer.poweredBy': 'footerPoweredBy',
'theme.toggle': 'themeToggle',
'menu.toggle': 'menuToggle',
Expand All @@ -177,14 +215,24 @@ class I18nStrings {
'search.clear': 'searchClear',
'search.more': 'searchMore',
'search.unavailable': 'searchUnavailable',
'code.copy': 'codeCopy',
'code.copyLabel': 'codeCopyLabel',
'code.copied': 'codeCopied',
'code.copyFailed': 'codeCopyFailed',
'locale.select': 'localeSelect',
'locale.untranslated': 'localeUntranslated',
'page.copyMarkdown': 'pageCopyMarkdown',
'page.readingTime': 'readingTime',
'page.lastUpdated': 'lastUpdated',
'dartdoc.api': 'dartdocApi',
'dartdoc.backToDocs': 'dartdocBackToDocs',
};

factory I18nStrings.fromYaml(Map? yaml) {
if (yaml == null || yaml.isEmpty) return const I18nStrings();
factory I18nStrings.fromYaml(
Map? yaml, {
I18nStrings fallback = const I18nStrings(),
}) {
if (yaml == null || yaml.isEmpty) return fallback;

final overrides = <String, String>{};
for (final entry in yaml.entries) {
Expand All @@ -195,26 +243,35 @@ class I18nStrings {
}

return I18nStrings(
navPrevious: overrides['navPrevious'] ?? '← Previous',
navNext: overrides['navNext'] ?? 'Next →',
footerPoweredBy: overrides['footerPoweredBy'] ?? 'Powered by',
themeToggle: overrides['themeToggle'] ?? 'Toggle dark mode',
menuToggle: overrides['menuToggle'] ?? 'Toggle menu',
menuClose: overrides['menuClose'] ?? 'Close menu',
announcementDismiss: overrides['announcementDismiss'] ?? 'Dismiss announcement',
versionSelect: overrides['versionSelect'] ?? 'Select version',
versionDismiss: overrides['versionDismiss'] ?? 'Dismiss banner',
searchNoResults: overrides['searchNoResults'] ?? 'No results found for "%s"',
searchOneResult: overrides['searchOneResult'] ?? '1 result',
searchManyResults: overrides['searchManyResults'] ?? '%s results',
searchSearching: overrides['searchSearching'] ?? 'Searching...',
searchClear: overrides['searchClear'] ?? 'Clear search',
searchMore: overrides['searchMore'] ?? 'Load more results',
searchUnavailable: overrides['searchUnavailable'] ?? 'Search is unavailable',
localeSelect: overrides['localeSelect'] ?? 'Select language',
localeUntranslated: overrides['localeUntranslated'] ?? 'This page has not been translated yet.',
readingTime: overrides['readingTime'] ?? '%s min read',
lastUpdated: overrides['lastUpdated'] ?? 'Last updated %s',
searchPlaceholder: overrides['searchPlaceholder'] ?? fallback.searchPlaceholder,
navPrevious: overrides['navPrevious'] ?? fallback.navPrevious,
navNext: overrides['navNext'] ?? fallback.navNext,
tocTitle: overrides['tocTitle'] ?? fallback.tocTitle,
footerPoweredBy: overrides['footerPoweredBy'] ?? fallback.footerPoweredBy,
themeToggle: overrides['themeToggle'] ?? fallback.themeToggle,
menuToggle: overrides['menuToggle'] ?? fallback.menuToggle,
menuClose: overrides['menuClose'] ?? fallback.menuClose,
announcementDismiss: overrides['announcementDismiss'] ?? fallback.announcementDismiss,
versionSelect: overrides['versionSelect'] ?? fallback.versionSelect,
versionDismiss: overrides['versionDismiss'] ?? fallback.versionDismiss,
searchNoResults: overrides['searchNoResults'] ?? fallback.searchNoResults,
searchOneResult: overrides['searchOneResult'] ?? fallback.searchOneResult,
searchManyResults: overrides['searchManyResults'] ?? fallback.searchManyResults,
searchSearching: overrides['searchSearching'] ?? fallback.searchSearching,
searchClear: overrides['searchClear'] ?? fallback.searchClear,
searchMore: overrides['searchMore'] ?? fallback.searchMore,
searchUnavailable: overrides['searchUnavailable'] ?? fallback.searchUnavailable,
codeCopy: overrides['codeCopy'] ?? fallback.codeCopy,
codeCopyLabel: overrides['codeCopyLabel'] ?? fallback.codeCopyLabel,
codeCopied: overrides['codeCopied'] ?? fallback.codeCopied,
codeCopyFailed: overrides['codeCopyFailed'] ?? fallback.codeCopyFailed,
localeSelect: overrides['localeSelect'] ?? fallback.localeSelect,
localeUntranslated: overrides['localeUntranslated'] ?? fallback.localeUntranslated,
pageCopyMarkdown: overrides['pageCopyMarkdown'] ?? fallback.pageCopyMarkdown,
readingTime: overrides['readingTime'] ?? fallback.readingTime,
lastUpdated: overrides['lastUpdated'] ?? fallback.lastUpdated,
dartdocApi: overrides['dartdocApi'] ?? fallback.dartdocApi,
dartdocBackToDocs: overrides['dartdocBackToDocs'] ?? fallback.dartdocBackToDocs,
);
}
}
Loading