From eecc42cb68e007b2270a52eabd28d3c4c2d01189 Mon Sep 17 00:00:00 2001 From: mathis6787 Date: Thu, 13 Aug 2026 17:02:10 -0400 Subject: [PATCH] feat(i18n): implement localized UI strings with shared fallbacks - Added i18nStrings schema definition to support localization. - Updated stardust_schema.dart to include i18nStrings reference. - Enhanced MarkdownParser to utilize localized copy button text and aria-label. - Modified DartdocGenerator to use localized API and back-to-docs labels. - Updated PageLayoutBuilder to reflect localized search placeholder and TOC title. - Enhanced PageScriptsBuilder to include localized runtime strings for copy feedback. - Updated PageBuilder to use localized copy-page Markdown label. - Added tests to ensure proper parsing and application of localized strings. --- docs/features/i18n.md | 62 +++++-- lib/src/config/config.dart | 23 ++- lib/src/config/i18n_config.dart | 121 +++++++++---- lib/src/config/stardust_schema.dart | 161 +++++++++++++++++- lib/src/content/markdown_parser.dart | 7 +- lib/src/dartdoc/dartdoc_generator.dart | 8 +- .../builders/page_layout_builder.dart | 4 +- .../builders/page_scripts_builder.dart | 35 ++-- lib/src/generator/page_builder.dart | 2 +- schema/stardust.json | 161 +++++++++++++++++- test/cli/commands/build_command_test.dart | 29 ++++ test/config/config_loader_test.dart | 31 ++++ test/config/config_test.dart | 138 +++++++++++++++ test/content/markdown_parser_test.dart | 25 +++ test/dartdoc/dartdoc_generator_test.dart | 30 +++- .../builders/page_layout_builder_test.dart | 34 ++++ .../builders/page_scripts_builder_test.dart | 43 +++++ test/generator/page_builder_test.dart | 26 +++ 18 files changed, 867 insertions(+), 73 deletions(-) diff --git a/docs/features/i18n.md b/docs/features/i18n.md index 592a7dd..2aee850 100644 --- a/docs/features/i18n.md +++ b/docs/features/i18n.md @@ -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 @@ -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 `/` | | `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 @@ -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 @@ -148,6 +170,7 @@ 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 | @@ -155,16 +178,33 @@ i18n: | `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 diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart index e7621d7..195a10c 100644 --- a/lib/src/config/config.dart +++ b/lib/src/config/config.dart @@ -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() + .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'; diff --git a/lib/src/config/i18n_config.dart b/lib/src/config/i18n_config.dart index 3b0dd2c..ad25867 100644 --- a/lib/src/config/i18n_config.dart +++ b/lib/src/config/i18n_config.dart @@ -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 { @@ -96,6 +101,10 @@ class LocaleConfig { /// titles and page labels. Defaults to the shared sidebar. final List? 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, @@ -103,21 +112,32 @@ class LocaleConfig { 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; @@ -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', @@ -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', @@ -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 = {}; for (final entry in yaml.entries) { @@ -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, ); } } diff --git a/lib/src/config/stardust_schema.dart b/lib/src/config/stardust_schema.dart index f2a4c53..0ff4c32 100644 --- a/lib/src/config/stardust_schema.dart +++ b/lib/src/config/stardust_schema.dart @@ -687,6 +687,9 @@ const stardustSchemaJson = r''' "items": { "$ref": "#/$defs/sidebarGroup" } + }, + "strings": { + "$ref": "#/$defs/i18nStrings" } }, "required": [ @@ -697,11 +700,7 @@ const stardustSchemaJson = r''' } }, "strings": { - "type": "object", - "description": "Override UI strings for localization", - "additionalProperties": { - "type": "string" - } + "$ref": "#/$defs/i18nStrings" } } }, @@ -1165,6 +1164,158 @@ const stardustSchemaJson = r''' "type": "string" } } + }, + "i18nStrings": { + "type": "object", + "description": "UI string overrides. At i18n.strings these are shared fallbacks; strings on a locale override them for that locale.", + "additionalProperties": false, + "properties": { + "nav.previous": { + "type": "string", + "default": "← Previous", + "description": "Label above the previous page title" + }, + "nav.next": { + "type": "string", + "default": "Next →", + "description": "Label above the next page title" + }, + "toc.title": { + "type": "string", + "default": "On this page", + "description": "Locale override for the table of contents heading; falls back to toc.title" + }, + "footer.poweredBy": { + "type": "string", + "default": "Powered by", + "description": "Powered-by label in the footer" + }, + "theme.toggle": { + "type": "string", + "default": "Toggle dark mode", + "description": "Accessible label for the theme toggle" + }, + "menu.toggle": { + "type": "string", + "default": "Toggle menu", + "description": "Accessible label for the mobile menu toggle" + }, + "menu.close": { + "type": "string", + "default": "Close menu", + "description": "Accessible label for the sidebar close button" + }, + "announcement.dismiss": { + "type": "string", + "default": "Dismiss announcement", + "description": "Accessible label for the announcement dismiss button" + }, + "version.select": { + "type": "string", + "default": "Select version", + "description": "Accessible label for the version selector" + }, + "version.dismiss": { + "type": "string", + "default": "Dismiss banner", + "description": "Accessible label for the version banner dismiss button" + }, + "search.placeholder": { + "type": "string", + "default": "Search docs...", + "description": "Locale override for the search trigger, dialog, and input label; falls back to search.placeholder" + }, + "search.noResults": { + "type": "string", + "default": "No results found for \"%s\"", + "description": "No-results message; %s is replaced by the query" + }, + "search.oneResult": { + "type": "string", + "default": "1 result", + "description": "Single-result count" + }, + "search.manyResults": { + "type": "string", + "default": "%s results", + "description": "Multiple-result count; %s is replaced by the count" + }, + "search.searching": { + "type": "string", + "default": "Searching...", + "description": "Search loading status" + }, + "search.clear": { + "type": "string", + "default": "Clear search", + "description": "Accessible label for the clear-search button" + }, + "search.more": { + "type": "string", + "default": "Load more results", + "description": "Label for the load-more-results button" + }, + "search.unavailable": { + "type": "string", + "default": "Search is unavailable", + "description": "Status shown when the search index cannot be loaded" + }, + "code.copy": { + "type": "string", + "default": "Copy", + "description": "Visible label for a code block's copy button" + }, + "code.copyLabel": { + "type": "string", + "default": "Copy code", + "description": "Accessible label for a code block's copy button" + }, + "code.copied": { + "type": "string", + "default": "Copied!", + "description": "Copy success feedback" + }, + "code.copyFailed": { + "type": "string", + "default": "Copy failed", + "description": "Copy failure feedback" + }, + "locale.select": { + "type": "string", + "default": "Select language", + "description": "Accessible label for the locale selector" + }, + "locale.untranslated": { + "type": "string", + "default": "This page has not been translated yet.", + "description": "Notice on pages that fall back to the default locale" + }, + "page.copyMarkdown": { + "type": "string", + "default": "Copy page as Markdown", + "description": "Label for the copy-page-as-Markdown button" + }, + "page.readingTime": { + "type": "string", + "default": "%s min read", + "description": "Reading-time label; %s is replaced by the number of minutes" + }, + "page.lastUpdated": { + "type": "string", + "default": "Last updated %s", + "description": "Last-updated label; %s is replaced by the date" + }, + "dartdoc.api": { + "type": "string", + "default": "API", + "description": "Badge in the Stardust bar on generated dartdoc pages" + }, + "dartdoc.backToDocs": { + "type": "string", + "default": "← Back to docs", + "description": "Link back to the documentation from generated dartdoc pages" + } + } } } } diff --git a/lib/src/content/markdown_parser.dart b/lib/src/content/markdown_parser.dart index 7216872..47d1154 100644 --- a/lib/src/content/markdown_parser.dart +++ b/lib/src/content/markdown_parser.dart @@ -244,8 +244,11 @@ class MarkdownParser implements ContentParser { final highlighted = highlight.parse(code, language: language); final highlightedHtml = _renderHighlight(highlighted.nodes ?? []); - final copyButton = - config.code.copyButton ? '' : ''; + final strings = config.i18nStrings; + final copyButton = config.code.copyButton + ? '' + : ''; final lineNumbers = config.code.lineNumbers ? '
${_generateLineNumbers(code)}
' : ''; diff --git a/lib/src/dartdoc/dartdoc_generator.dart b/lib/src/dartdoc/dartdoc_generator.dart index fe2ae9b..3037f08 100644 --- a/lib/src/dartdoc/dartdoc_generator.dart +++ b/lib/src/dartdoc/dartdoc_generator.dart @@ -52,6 +52,8 @@ class DartdocGenerator { primaryColor: config.theme.colors.primary, logoLight: _prefixAsset(config.logo?.effectiveLight, config.basePath), logoDark: _prefixAsset(config.logo?.effectiveDark, config.basePath), + apiLabel: config.i18nStrings.dartdocApi, + backToDocs: config.i18nStrings.dartdocBackToDocs, ); var pages = 0; await for (final entity in fileSystem.listDirectory(tmp.path, recursive: true)) { @@ -114,6 +116,8 @@ class DartdocGenerator { required String primaryColor, String? logoLight, String? logoDark, + String apiLabel = 'API', + String backToDocs = '← Back to docs', }) { final accent = _safeColor(primaryColor, '#6366f1'); final home = encodeHtmlAttribute(homeUrl); @@ -132,8 +136,8 @@ class DartdocGenerator { '.sd-logo-dark{display:none}.dark-theme .sd-logo-light{display:none}.dark-theme .sd-logo-dark{display:block}' '' '${_logoImages(logoLight, logoDark)}' - '${encodeHtml(name)}API' - '← Back to docs' + '${encodeHtml(name)}${encodeHtml(apiLabel)}' + '${encodeHtml(backToDocs)}' ''; } diff --git a/lib/src/generator/builders/page_layout_builder.dart b/lib/src/generator/builders/page_layout_builder.dart index c482257..2bc09b0 100644 --- a/lib/src/generator/builders/page_layout_builder.dart +++ b/lib/src/generator/builders/page_layout_builder.dart @@ -86,7 +86,7 @@ class PageLayoutBuilder { - ${encodeHtml(config.search.placeholder)} + ${encodeHtml(config.searchPlaceholder)} ${encodeHtml(config.search.hotkey)} ''' : ''} @@ -399,7 +399,7 @@ class PageLayoutBuilder { return '''