mdorigin 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import type { ContentStore } from './content-store.js';
2
2
  import type { MdoPlugin } from './extensions.js';
3
+ import { type SiteMessages } from '../i18n/messages.js';
3
4
  export interface SiteNavItem {
4
5
  label: string;
5
6
  href: string;
@@ -17,6 +18,19 @@ export interface SiteSocialLink {
17
18
  export interface EditLinkConfig {
18
19
  baseUrl: string;
19
20
  }
21
+ export interface SiteRssConfigInput {
22
+ title?: string;
23
+ description?: string;
24
+ author?: string;
25
+ maxItems?: number;
26
+ }
27
+ export interface SiteRssConfig {
28
+ enabled: boolean;
29
+ title?: string;
30
+ description?: string;
31
+ author?: string;
32
+ maxItems: number;
33
+ }
20
34
  export interface SiteSearchRerankerConfig {
21
35
  kind?: 'embedding-v1' | 'heuristic-v1';
22
36
  candidatePoolSize?: number;
@@ -51,6 +65,16 @@ export interface SiteSearchConfig {
51
65
  export interface SiteConfig {
52
66
  siteTitle?: string;
53
67
  siteDescription?: string;
68
+ /** Site UI locale (BCP 47) used for built-in chrome messages. Defaults to 'en'. */
69
+ locale?: string;
70
+ /** Flat overrides for built-in UI messages; keys are typed via SiteMessages. */
71
+ messages?: Partial<SiteMessages>;
72
+ /**
73
+ * Content locales for a multilingual site. Each non-default locale keeps its
74
+ * content under a top-level `{code}/` directory that maps 1:1 to its URL
75
+ * path prefix. Omit for single-language sites.
76
+ */
77
+ locales?: LocaleConfigInput[];
54
78
  siteUrl?: string;
55
79
  favicon?: string;
56
80
  socialImage?: string;
@@ -63,6 +87,7 @@ export interface SiteConfig {
63
87
  footerText?: string;
64
88
  socialLinks?: SiteSocialLink[];
65
89
  editLink?: EditLinkConfig;
90
+ rss?: false | SiteRssConfigInput;
66
91
  showHomeIndex?: boolean;
67
92
  listingInitialPostCount?: number;
68
93
  listingLoadMoreStep?: number;
@@ -74,6 +99,9 @@ export interface UserSiteConfig extends SiteConfig {
74
99
  export interface ResolvedSiteConfig {
75
100
  siteTitle: string;
76
101
  siteDescription?: string;
102
+ locale: string;
103
+ messages: Partial<SiteMessages>;
104
+ locales?: ResolvedLocaleConfig[];
77
105
  siteUrl?: string;
78
106
  favicon?: string;
79
107
  socialImage?: string;
@@ -85,6 +113,7 @@ export interface ResolvedSiteConfig {
85
113
  footerText?: string;
86
114
  socialLinks: SiteSocialLink[];
87
115
  editLink?: EditLinkConfig;
116
+ rss?: SiteRssConfig;
88
117
  showHomeIndex: boolean;
89
118
  listingInitialPostCount: number;
90
119
  listingLoadMoreStep: number;
@@ -104,6 +133,30 @@ export interface LoadedSiteConfig {
104
133
  configFilePath: string;
105
134
  configModulePath?: string;
106
135
  }
136
+ export interface LocaleConfigInput {
137
+ /** BCP 47 locale code, e.g. 'en' or 'zh-CN'. Also the content directory name. */
138
+ code: string;
139
+ /** Switcher display label. Defaults to the code. */
140
+ label?: string;
141
+ /**
142
+ * URL path prefix. Must be '' (only allowed for the default locale) or
143
+ * '/{code}'. Defaults to '' for the default locale and '/{code}' otherwise.
144
+ */
145
+ pathPrefix?: string;
146
+ /** Mark exactly one locale as default. */
147
+ default?: boolean;
148
+ /** UI message overrides for this locale; win over global `messages`. */
149
+ messages?: Partial<SiteMessages>;
150
+ }
151
+ export interface ResolvedLocaleConfig {
152
+ code: string;
153
+ label: string;
154
+ pathPrefix: string;
155
+ isDefault: boolean;
156
+ /** Content base directory: '' (content root) for an unprefixed default locale, otherwise `{code}`. */
157
+ contentBase: string;
158
+ messages: Partial<SiteMessages>;
159
+ }
107
160
  export declare function loadSiteConfig(options?: LoadSiteConfigOptions): Promise<ResolvedSiteConfig>;
108
161
  export declare function loadUserSiteConfig(options?: LoadSiteConfigOptions): Promise<LoadedSiteConfig>;
109
162
  export declare function applySiteConfigFrontmatterDefaults(store: ContentStore, siteConfig: ResolvedSiteConfig): Promise<ResolvedSiteConfig>;
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
4
4
  import { tsImport } from 'tsx/esm/api';
5
5
  import { getDirectoryIndexCandidates } from './directory-index.js';
6
6
  import { parseMarkdownDocument } from './markdown.js';
7
+ import { DEFAULT_SITE_LOCALE, validateSiteMessages, } from '../i18n/messages.js';
7
8
  export async function loadSiteConfig(options = {}) {
8
9
  return (await loadUserSiteConfig(options)).siteConfig;
9
10
  }
@@ -15,6 +16,8 @@ export async function loadUserSiteConfig(options = {}) {
15
16
  : await resolveDefaultConfigPath(cwd, rootDir);
16
17
  const parsedConfig = await loadConfigSource(configFilePath);
17
18
  const legacyConfig = parsedConfig;
19
+ const messageOverrides = resolveMessageOverrides(parsedConfig.messages, configFilePath);
20
+ const locales = resolveLocalesConfig(parsedConfig, configFilePath);
18
21
  const stylesheetPath = parsedConfig.stylesheet
19
22
  ? path.resolve(path.dirname(configFilePath), parsedConfig.stylesheet)
20
23
  : null;
@@ -29,6 +32,10 @@ export async function loadUserSiteConfig(options = {}) {
29
32
  parsedConfig.siteDescription !== ''
30
33
  ? parsedConfig.siteDescription
31
34
  : undefined,
35
+ locale: locales?.find((locale) => locale.isDefault)?.code ??
36
+ normalizeSiteLocale(parsedConfig.locale),
37
+ messages: messageOverrides,
38
+ locales,
32
39
  siteUrl: normalizeSiteUrl(parsedConfig.siteUrl),
33
40
  favicon: normalizeSiteHref(parsedConfig.favicon),
34
41
  socialImage: normalizeSiteHref(parsedConfig.socialImage),
@@ -42,6 +49,7 @@ export async function loadUserSiteConfig(options = {}) {
42
49
  : undefined,
43
50
  socialLinks: normalizeSocialLinks(parsedConfig.socialLinks),
44
51
  editLink: normalizeEditLink(parsedConfig.editLink),
52
+ rss: normalizeRssConfig(parsedConfig.rss),
45
53
  showHomeIndex: typeof parsedConfig.showHomeIndex === 'boolean'
46
54
  ? parsedConfig.showHomeIndex
47
55
  : normalizeTopNav(parsedConfig.topNav).length === 0,
@@ -87,6 +95,95 @@ export async function applySiteConfigFrontmatterDefaults(store, siteConfig) {
87
95
  }
88
96
  return siteConfig;
89
97
  }
98
+ function resolveMessageOverrides(value, configFilePath) {
99
+ if (value === undefined) {
100
+ return {};
101
+ }
102
+ const { messages, unknownKeys } = validateSiteMessages(value);
103
+ if (unknownKeys.length > 0) {
104
+ throw new Error(`[mdorigin] ${configFilePath}: "messages" contains unknown keys: ${unknownKeys.join(', ')}`);
105
+ }
106
+ return messages;
107
+ }
108
+ function normalizeSiteLocale(value) {
109
+ if (typeof value !== 'string') {
110
+ return DEFAULT_SITE_LOCALE;
111
+ }
112
+ const trimmed = value.trim();
113
+ return trimmed === '' ? DEFAULT_SITE_LOCALE : trimmed;
114
+ }
115
+ const LOCALE_CODE_PATTERN = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/;
116
+ function resolveLocalesConfig(parsedConfig, configFilePath) {
117
+ const input = parsedConfig.locales;
118
+ if (input === undefined) {
119
+ return undefined;
120
+ }
121
+ if (!Array.isArray(input) || input.length === 0) {
122
+ throw new Error(`[mdorigin] ${configFilePath}: "locales" must be a non-empty array when configured`);
123
+ }
124
+ const seenCodes = new Set();
125
+ const parsed = [];
126
+ for (const entry of input) {
127
+ if (typeof entry !== 'object' || entry === null) {
128
+ throw new Error(`[mdorigin] ${configFilePath}: every "locales" entry must be an object`);
129
+ }
130
+ const locale = entry;
131
+ const code = typeof locale.code === 'string' ? locale.code.trim() : '';
132
+ if (code === '' || !LOCALE_CODE_PATTERN.test(code)) {
133
+ throw new Error(`[mdorigin] ${configFilePath}: "locales[].code" must be a BCP 47 style code like "en" or "zh-CN"`);
134
+ }
135
+ const normalizedCodeKey = code.toLowerCase();
136
+ if (seenCodes.has(normalizedCodeKey)) {
137
+ throw new Error(`[mdorigin] ${configFilePath}: duplicate "locales" code: ${code}`);
138
+ }
139
+ seenCodes.add(normalizedCodeKey);
140
+ const isDefault = locale.default === true;
141
+ const pathPrefix = resolveLocalePathPrefix(locale, isDefault, configFilePath);
142
+ const { messages, unknownKeys } = validateSiteMessages(locale.messages);
143
+ if (unknownKeys.length > 0) {
144
+ throw new Error(`[mdorigin] ${configFilePath}: "locales[${code}].messages" contains unknown keys: ${unknownKeys.join(', ')}`);
145
+ }
146
+ parsed.push({
147
+ code,
148
+ label: typeof locale.label === 'string' && locale.label.trim() !== ''
149
+ ? locale.label.trim()
150
+ : code,
151
+ pathPrefix,
152
+ isDefault,
153
+ messages,
154
+ });
155
+ }
156
+ const defaultCount = parsed.filter((locale) => locale.isDefault).length;
157
+ if (defaultCount !== 1) {
158
+ throw new Error(`[mdorigin] ${configFilePath}: "locales" must mark exactly one locale with "default": true (found ${defaultCount})`);
159
+ }
160
+ const defaultLocale = parsed.find((locale) => locale.isDefault);
161
+ const configuredLocale = typeof parsedConfig.locale === 'string' ? parsedConfig.locale.trim() : '';
162
+ if (configuredLocale !== '' &&
163
+ configuredLocale.toLowerCase() !== defaultLocale?.code.toLowerCase()) {
164
+ throw new Error(`[mdorigin] ${configFilePath}: "locale" (${configuredLocale}) conflicts with the default "locales" entry (${defaultLocale?.code}); remove "locale" or align it with the default locale`);
165
+ }
166
+ return parsed.map((locale) => ({
167
+ ...locale,
168
+ contentBase: locale.pathPrefix === '' ? '' : locale.code,
169
+ }));
170
+ }
171
+ function resolveLocalePathPrefix(locale, isDefault, configFilePath) {
172
+ if (locale.pathPrefix === undefined) {
173
+ return isDefault ? '' : `/${locale.code}`;
174
+ }
175
+ const prefix = locale.pathPrefix;
176
+ if (prefix === '') {
177
+ if (!isDefault) {
178
+ throw new Error(`[mdorigin] ${configFilePath}: "locales[${locale.code}].pathPrefix" can only be empty for the default locale`);
179
+ }
180
+ return '';
181
+ }
182
+ if (prefix !== `/${locale.code}`) {
183
+ throw new Error(`[mdorigin] ${configFilePath}: "locales[${locale.code}].pathPrefix" must be "/${locale.code}" or "" (default locales only); got "${prefix}"`);
184
+ }
185
+ return prefix;
186
+ }
90
187
  async function resolveDefaultConfigPath(cwd, rootDir) {
91
188
  const rootConfigPath = rootDir ? await findConfigPath(rootDir) : null;
92
189
  if (rootConfigPath) {
@@ -222,6 +319,36 @@ function normalizeOptionalNumber(value) {
222
319
  }
223
320
  return undefined;
224
321
  }
322
+ function normalizeRssConfig(value) {
323
+ if (value === false) {
324
+ return {
325
+ enabled: false,
326
+ title: undefined,
327
+ description: undefined,
328
+ author: undefined,
329
+ maxItems: 20,
330
+ };
331
+ }
332
+ if (typeof value !== 'object' || value === null) {
333
+ return {
334
+ enabled: true,
335
+ title: undefined,
336
+ description: undefined,
337
+ author: undefined,
338
+ maxItems: 20,
339
+ };
340
+ }
341
+ const rss = value;
342
+ return {
343
+ enabled: true,
344
+ title: typeof rss.title === 'string' && rss.title !== '' ? rss.title : undefined,
345
+ description: typeof rss.description === 'string' && rss.description !== ''
346
+ ? rss.description
347
+ : undefined,
348
+ author: typeof rss.author === 'string' && rss.author !== '' ? rss.author : undefined,
349
+ maxItems: normalizePositiveInteger(rss.maxItems, 20),
350
+ };
351
+ }
225
352
  function normalizeSearchConfig(value, configFilePath) {
226
353
  if (typeof value !== 'object' || value === null) {
227
354
  return undefined;
@@ -0,0 +1,2 @@
1
+ export declare function trimLeadingSlash(value: string): string;
2
+ export declare function ensureTrailingSlash(value: string): string;
@@ -0,0 +1,6 @@
1
+ export function trimLeadingSlash(value) {
2
+ return value.startsWith('/') ? value.slice(1) : value;
3
+ }
4
+ export function ensureTrailingSlash(value) {
5
+ return value.endsWith('/') ? value : `${value}/`;
6
+ }
@@ -1,8 +1,12 @@
1
1
  import type { SiteLogo, SiteNavItem, SiteSocialLink } from '../core/site-config.js';
2
+ import type { PageLanguage } from '../core/extensions.js';
2
3
  import type { ManagedIndexEntry } from '../core/markdown.js';
4
+ import { type SiteMessages } from '../i18n/messages.js';
3
5
  export interface RenderDocumentOptions {
4
6
  siteTitle: string;
5
7
  siteDescription?: string;
8
+ locale?: string;
9
+ messages?: SiteMessages;
6
10
  siteUrl?: string;
7
11
  favicon?: string;
8
12
  socialImage?: string;
@@ -21,11 +25,24 @@ export interface RenderDocumentOptions {
21
25
  stylesheetContent?: string;
22
26
  canonicalPath?: string;
23
27
  alternateMarkdownPath?: string;
28
+ rssFeedUrl?: string;
24
29
  listingEntries?: ManagedIndexEntry[];
25
30
  listingRequestPath?: string;
26
31
  listingInitialPostCount?: number;
27
32
  listingLoadMoreStep?: number;
28
33
  searchEnabled?: boolean;
34
+ /** Language switcher entries; rendered in the header when more than one is present. */
35
+ languages?: PageLanguage[];
36
+ /** hreflang alternates with absolute URLs, including x-default when applicable. */
37
+ hreflangAlternates?: Array<{
38
+ hreflang: string;
39
+ href: string;
40
+ }>;
41
+ /** Locale content filter handed to the search client script. */
42
+ searchLocaleFilter?: {
43
+ include: string;
44
+ exclude: string[];
45
+ };
29
46
  headerHtml?: string;
30
47
  footerHtml?: string;
31
48
  }
@@ -1,5 +1,8 @@
1
+ import { DEFAULT_SITE_LOCALE, resolveSiteMessages, serializeMessagesForScript, } from '../i18n/messages.js';
1
2
  import { getDefaultThemeStyles } from './theme.js';
2
3
  export function renderDocument(options) {
4
+ const locale = options.locale ?? DEFAULT_SITE_LOCALE;
5
+ const messages = options.messages ?? resolveSiteMessages(locale);
3
6
  const title = escapeHtml(options.title);
4
7
  const siteTitle = escapeHtml(options.siteTitle);
5
8
  const siteDescription = options.siteDescription
@@ -25,6 +28,14 @@ export function renderDocument(options) {
25
28
  const alternateMarkdownMeta = options.alternateMarkdownPath
26
29
  ? `<link rel="alternate" type="text/markdown" href="${escapeHtml(options.alternateMarkdownPath)}">`
27
30
  : '';
31
+ const rssMeta = options.rssFeedUrl
32
+ ? `<link rel="alternate" type="application/rss+xml" title="${siteTitle}" href="${escapeHtml(options.rssFeedUrl)}">`
33
+ : '';
34
+ const hreflangMeta = options.hreflangAlternates && options.hreflangAlternates.length > 0
35
+ ? options.hreflangAlternates
36
+ .map((alternate) => `<link rel="alternate" hreflang="${escapeHtml(alternate.hreflang)}" href="${escapeHtml(alternate.href)}">`)
37
+ .join('')
38
+ : '';
28
39
  const stylesheetBlock = `<style>${getDefaultThemeStyles()}${options.stylesheetContent ? `\n${options.stylesheetContent}` : ''}</style>`;
29
40
  const navBlock = options.topNav && options.topNav.length > 0
30
41
  ? `<nav class="site-nav"><ul>${options.topNav
@@ -34,21 +45,26 @@ export function renderDocument(options) {
34
45
  const searchToggleBlock = options.searchEnabled
35
46
  ? [
36
47
  '<div class="site-search" data-site-search>',
37
- '<button type="button" class="site-search__toggle" aria-expanded="false" aria-controls="site-search-panel">Search</button>',
48
+ `<button type="button" class="site-search__toggle" aria-expanded="false" aria-controls="site-search-panel">${escapeHtml(messages['search.toggle'])}</button>`,
38
49
  '<div id="site-search-panel" class="site-search__panel" hidden>',
39
50
  '<form class="site-search__form" role="search" action="/api/search" method="get">',
40
- '<label class="site-search__label" for="site-search-input">Search site</label>',
51
+ `<label class="site-search__label" for="site-search-input">${escapeHtml(messages['search.label'])}</label>`,
41
52
  '<div class="site-search__controls">',
42
- '<input id="site-search-input" class="site-search__input" type="search" name="q" placeholder="Search docs and skills" autocomplete="off">',
43
- '<button type="submit" class="site-search__submit">Go</button>',
53
+ `<input id="site-search-input" class="site-search__input" type="search" name="q" placeholder="${escapeHtml(messages['search.placeholder'])}" autocomplete="off">`,
54
+ `<button type="submit" class="site-search__submit">${escapeHtml(messages['search.go'])}</button>`,
44
55
  '</div>',
45
- '<p class="site-search__hint">Search is powered by <code>/api/search</code>.</p>',
56
+ `<p class="site-search__hint">${messages['search.hint']}</p>`,
46
57
  '</form>',
47
58
  '<div class="site-search__results" data-site-search-results></div>',
48
59
  '</div>',
49
60
  '</div>',
50
61
  ].join('')
51
62
  : '';
63
+ const languagesBlock = options.languages && options.languages.length > 1
64
+ ? `<nav class="site-languages" aria-label="${escapeHtml(messages['languages.ariaLabel'])}"><ul>${options.languages
65
+ .map((language) => `<li><a href="${escapeHtml(language.href)}" hreflang="${escapeHtml(language.code)}"${language.current ? ' aria-current="page"' : ''}>${escapeHtml(language.label)}</a></li>`)
66
+ .join('')}</ul></nav>`
67
+ : '';
52
68
  const footerNavBlock = options.footerNav && options.footerNav.length > 0
53
69
  ? `<nav class="site-footer__nav"><ul>${options.footerNav
54
70
  .map((item) => `<li><a href="${escapeHtml(item.href)}">${escapeHtml(item.label)}</a></li>`)
@@ -67,10 +83,10 @@ export function renderDocument(options) {
67
83
  : '';
68
84
  const brandHref = escapeHtml(options.logo?.href ?? '/');
69
85
  const editLinkBlock = options.editLinkHref
70
- ? `<a class="site-footer__edit-link" href="${escapeHtml(options.editLinkHref)}">Edit this page</a>`
86
+ ? `<a class="site-footer__edit-link" href="${escapeHtml(options.editLinkHref)}">${escapeHtml(messages['footer.editPage'])}</a>`
71
87
  : '';
72
88
  const markdownViewBlock = options.alternateMarkdownPath
73
- ? `<a class="site-footer__markdown-link" href="${escapeHtml(options.alternateMarkdownPath)}" aria-label="View Markdown source">MD View</a>`
89
+ ? `<a class="site-footer__markdown-link" href="${escapeHtml(options.alternateMarkdownPath)}" aria-label="${escapeHtml(messages['footer.markdownViewAria'])}">${escapeHtml(messages['footer.markdownView'])}</a>`
74
90
  : '';
75
91
  const footerActionsBlock = markdownViewBlock || editLinkBlock
76
92
  ? `<div class="site-footer__actions">${markdownViewBlock}${editLinkBlock}</div>`
@@ -89,15 +105,17 @@ export function renderDocument(options) {
89
105
  requestPath: options.listingRequestPath ?? '/',
90
106
  initialPostCount: options.listingInitialPostCount ?? 10,
91
107
  loadMoreStep: options.listingLoadMoreStep ?? 10,
92
- })
108
+ }, messages)
93
109
  : options.body;
94
- const searchScript = options.searchEnabled ? renderSearchScript() : '';
110
+ const searchScript = options.searchEnabled
111
+ ? renderSearchScript(messages, options.searchLocaleFilter)
112
+ : '';
95
113
  const headerBlock = options.headerHtml ??
96
- `<header class="site-header"><div class="site-header__inner"><div class="site-header__brand"><p class="site-header__title"><a href="${brandHref}">${logoBlock}<span>${siteTitle}</span></a></p>${siteDescriptionBlock}</div><div class="site-header__actions">${navBlock}${searchToggleBlock}</div></div></header>`;
114
+ `<header class="site-header"><div class="site-header__inner"><div class="site-header__brand"><p class="site-header__title"><a href="${brandHref}">${logoBlock}<span>${siteTitle}</span></a></p>${siteDescriptionBlock}</div><div class="site-header__actions">${navBlock}${languagesBlock}${searchToggleBlock}</div></div></header>`;
97
115
  const renderedFooterBlock = options.footerHtml ?? footerBlock;
98
116
  return [
99
117
  '<!doctype html>',
100
- '<html lang="en">',
118
+ `<html lang="${escapeHtml(locale)}">`,
101
119
  '<head>',
102
120
  '<meta charset="utf-8">',
103
121
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
@@ -107,6 +125,8 @@ export function renderDocument(options) {
107
125
  faviconMeta,
108
126
  socialImageMeta,
109
127
  alternateMarkdownMeta,
128
+ rssMeta,
129
+ hreflangMeta,
110
130
  stylesheetBlock,
111
131
  '</head>',
112
132
  '<body>',
@@ -159,7 +179,7 @@ function renderSocialIcon(icon) {
159
179
  function iconSvg(pathData) {
160
180
  return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${pathData}"></path></svg>`;
161
181
  }
162
- function renderListingArticle(body, entries, options) {
182
+ function renderListingArticle(body, entries, options, messages) {
163
183
  if (entries.length === 0) {
164
184
  return body;
165
185
  }
@@ -170,26 +190,26 @@ function renderListingArticle(body, entries, options) {
170
190
  const shouldLoadMore = articles.length > visibleArticles.length;
171
191
  return [
172
192
  `<div class="catalog-page__body">${body}</div>`,
173
- '<section class="catalog-page" aria-label="Content listing">',
174
- directories.length > 0 ? renderListingDirectories(directories) : '',
193
+ `<section class="catalog-page" aria-label="${escapeHtml(messages['listing.ariaLabel'])}">`,
194
+ directories.length > 0 ? renderListingDirectories(directories, messages) : '',
175
195
  articles.length > 0
176
196
  ? renderListingArticles(visibleArticles, {
177
197
  requestPath: options.requestPath,
178
198
  nextOffset: visibleArticles.length,
179
199
  loadMoreStep: options.loadMoreStep,
180
200
  hasMore: shouldLoadMore,
181
- })
201
+ }, messages)
182
202
  : '',
183
203
  '</section>',
184
- shouldLoadMore ? renderListingLoadMoreScript() : '',
204
+ shouldLoadMore ? renderListingLoadMoreScript(messages) : '',
185
205
  ].join('');
186
206
  }
187
- function renderListingDirectories(entries) {
207
+ function renderListingDirectories(entries, messages) {
188
208
  return [
189
209
  '<div class="catalog-list catalog-list--directories">',
190
210
  ...entries.map((entry) => `<a class="catalog-item catalog-item--directory" href="${escapeHtml(entry.href)}"><strong class="catalog-item__title">${escapeHtml(entry.title)}</strong>${entry.detail
191
211
  ? `<span class="catalog-item__detail">${escapeHtml(entry.detail)}</span>`
192
- : '<span class="catalog-item__detail">Browse this section.</span>'}</a>`),
212
+ : `<span class="catalog-item__detail">${escapeHtml(messages['listing.browseSection'])}</span>`}</a>`),
193
213
  '</div>',
194
214
  ].join('');
195
215
  }
@@ -200,17 +220,17 @@ export function renderListingArticleItems(entries) {
200
220
  : ''}</a>`)
201
221
  .join('');
202
222
  }
203
- function renderListingArticles(entries, options) {
223
+ function renderListingArticles(entries, options, messages) {
204
224
  return [
205
225
  '<div class="catalog-list" data-listing-articles>',
206
226
  renderListingArticleItems(entries),
207
227
  '</div>',
208
228
  options.hasMore
209
- ? `<div class="catalog-load-more"><button type="button" class="catalog-load-more__button" data-listing-load-more data-request-path="${escapeHtml(options.requestPath)}" data-next-offset="${escapeHtml(String(options.nextOffset))}" data-load-more-step="${escapeHtml(String(options.loadMoreStep))}">Load more</button></div>`
229
+ ? `<div class="catalog-load-more"><button type="button" class="catalog-load-more__button" data-listing-load-more data-request-path="${escapeHtml(options.requestPath)}" data-next-offset="${escapeHtml(String(options.nextOffset))}" data-load-more-step="${escapeHtml(String(options.loadMoreStep))}">${escapeHtml(messages['listing.loadMore'])}</button></div>`
210
230
  : '',
211
231
  ].join('');
212
232
  }
213
- function renderListingLoadMoreScript() {
233
+ function renderListingLoadMoreScript(messages) {
214
234
  return `<script>
215
235
  (() => {
216
236
  const button = document.querySelector('[data-listing-load-more]');
@@ -219,6 +239,11 @@ function renderListingLoadMoreScript() {
219
239
  return;
220
240
  }
221
241
 
242
+ const M = ${serializeMessagesForScript({
243
+ loadMore: messages['listing.loadMore'],
244
+ loading: messages['listing.loading'],
245
+ })};
246
+
222
247
  const loadMore = async () => {
223
248
  const requestPath = button.dataset.requestPath;
224
249
  const nextOffset = button.dataset.nextOffset;
@@ -229,7 +254,7 @@ function renderListingLoadMoreScript() {
229
254
 
230
255
  button.disabled = true;
231
256
  const previousLabel = button.textContent;
232
- button.textContent = 'Loading...';
257
+ button.textContent = M.loading;
233
258
 
234
259
  try {
235
260
  const url = new URL(requestPath, window.location.origin);
@@ -252,14 +277,14 @@ function renderListingLoadMoreScript() {
252
277
  if (payload.hasMore === true && typeof payload.nextOffset === 'number') {
253
278
  button.dataset.nextOffset = String(payload.nextOffset);
254
279
  button.disabled = false;
255
- button.textContent = previousLabel ?? 'Load more';
280
+ button.textContent = previousLabel ?? M.loadMore;
256
281
  return;
257
282
  }
258
283
 
259
284
  button.remove();
260
285
  } catch {
261
286
  button.disabled = false;
262
- button.textContent = previousLabel ?? 'Load more';
287
+ button.textContent = previousLabel ?? M.loadMore;
263
288
  }
264
289
  };
265
290
 
@@ -269,7 +294,7 @@ function renderListingLoadMoreScript() {
269
294
  })();
270
295
  </script>`;
271
296
  }
272
- function renderSearchScript() {
297
+ function renderSearchScript(messages, localeFilter) {
273
298
  return [
274
299
  '<script>',
275
300
  '(function () {',
@@ -281,41 +306,59 @@ function renderSearchScript() {
281
306
  ' const input = root.querySelector(".site-search__input");',
282
307
  ' const results = root.querySelector("[data-site-search-results]");',
283
308
  ' if (!toggle || !panel || !form || !input || !results) return;',
309
+ ` const M = ${serializeMessagesForScript({
310
+ resultsEmpty: messages['search.resultsEmpty'],
311
+ searching: messages['search.searching'],
312
+ failed: messages['search.failed'],
313
+ failedWithStatus: messages['search.failedWithStatus'],
314
+ enterQuery: messages['search.enterQuery'],
315
+ initialHint: messages['search.initialHint'],
316
+ untitled: messages['search.untitled'],
317
+ })};`,
318
+ ` const F = ${localeFilter === undefined ? 'null' : serializeMessagesForScript(localeFilter)};`,
284
319
  ' let controller = null;',
285
320
  ' function renderMessage(message) {',
286
321
  ' results.innerHTML = `<p class="site-search__message">${escapeHtmlForScript(message)}</p>`;',
287
322
  ' }',
288
323
  ' function renderHits(hits) {',
289
324
  ' if (!Array.isArray(hits) || hits.length === 0) {',
290
- ' renderMessage("No results.");',
325
+ ' renderMessage(M.resultsEmpty);',
291
326
  ' return;',
292
327
  ' }',
293
328
  ' results.innerHTML = hits.map((hit) => {',
294
329
  ' const href = escapeHtmlForScript(hit.canonicalUrl || hit.docId || "#");',
295
- ' const title = escapeHtmlForScript(hit.title || hit.relativePath || "Untitled");',
330
+ ' const title = escapeHtmlForScript(hit.title || hit.relativePath || M.untitled);',
296
331
  ' const summary = typeof hit.summary === "string" ? `<span class="site-search__item-summary">${escapeHtmlForScript(hit.summary)}</span>` : "";',
297
332
  ' const excerpt = hit.bestMatch && typeof hit.bestMatch.excerpt === "string" ? `<span class="site-search__item-excerpt">${escapeHtmlForScript(hit.bestMatch.excerpt)}</span>` : "";',
298
333
  ' return `<a class="site-search__item" href="${href}"><strong class="site-search__item-title">${title}</strong>${summary}${excerpt}</a>`;',
299
334
  ' }).join("");',
300
335
  ' }',
336
+ ' function filterHits(hits) {',
337
+ ' if (!F || !Array.isArray(hits)) return hits;',
338
+ ' return hits.filter((hit) => {',
339
+ ' if (typeof hit.relativePath !== "string") return false;',
340
+ ' if (F.include !== "" && !hit.relativePath.startsWith(F.include)) return false;',
341
+ ' return !F.exclude.some((prefix) => hit.relativePath.startsWith(prefix));',
342
+ ' });',
343
+ ' }',
301
344
  ' async function runSearch(query) {',
302
345
  ' if (controller) controller.abort();',
303
346
  ' controller = new AbortController();',
304
- ' renderMessage("Searching...");',
347
+ ' renderMessage(M.searching);',
305
348
  ' try {',
306
349
  ' const url = new URL("/api/search", window.location.origin);',
307
350
  ' url.searchParams.set("q", query);',
308
351
  ' url.searchParams.set("topK", "8");',
309
352
  ' const response = await fetch(url, { signal: controller.signal });',
310
353
  ' if (!response.ok) {',
311
- ' renderMessage(`Search failed (${response.status}).`);',
354
+ ' renderMessage(M.failedWithStatus.replaceAll("{status}", String(response.status)));',
312
355
  ' return;',
313
356
  ' }',
314
357
  ' const payload = await response.json();',
315
- ' renderHits(payload.hits);',
358
+ ' renderHits(filterHits(payload.hits));',
316
359
  ' } catch (error) {',
317
360
  ' if (error && typeof error === "object" && "name" in error && error.name === "AbortError") return;',
318
- ' renderMessage("Search failed.");',
361
+ ' renderMessage(M.failed);',
319
362
  ' }',
320
363
  ' }',
321
364
  ' function setOpen(open) {',
@@ -323,7 +366,7 @@ function renderSearchScript() {
323
366
  ' panel.hidden = !open;',
324
367
  ' if (open) {',
325
368
  ' input.focus();',
326
- ' if (!results.innerHTML) renderMessage("Search docs, guides, and skills.");',
369
+ ' if (!results.innerHTML) renderMessage(M.initialHint);',
327
370
  ' }',
328
371
  ' }',
329
372
  ' toggle.addEventListener("click", () => setOpen(panel.hidden));',
@@ -331,7 +374,7 @@ function renderSearchScript() {
331
374
  ' event.preventDefault();',
332
375
  ' const query = input.value.trim();',
333
376
  ' if (!query) {',
334
- ' renderMessage("Enter a search query.");',
377
+ ' renderMessage(M.enterQuery);',
335
378
  ' return;',
336
379
  ' }',
337
380
  ' void runSearch(query);',
@@ -219,6 +219,35 @@ a:hover { color: var(--link-hover); text-decoration: underline; }
219
219
  white-space: nowrap;
220
220
  }
221
221
  .site-nav a:hover { color: var(--link-hover); }
222
+ .site-languages ul {
223
+ list-style: none;
224
+ display: flex;
225
+ flex-wrap: wrap;
226
+ gap: 0.6rem;
227
+ margin: 0;
228
+ padding: 0;
229
+ align-items: center;
230
+ }
231
+ .site-languages li {
232
+ display: flex;
233
+ align-items: center;
234
+ }
235
+ .site-languages a {
236
+ color: var(--muted);
237
+ font-size: 0.85rem;
238
+ display: inline-flex;
239
+ align-items: center;
240
+ min-height: 2.25rem;
241
+ padding: 0 0.05rem;
242
+ white-space: nowrap;
243
+ }
244
+ .site-languages a:hover { color: var(--link-hover); }
245
+ .site-languages a[aria-current='page'] {
246
+ color: inherit;
247
+ font-weight: 700;
248
+ text-decoration: underline;
249
+ text-underline-offset: 0.25em;
250
+ }
222
251
  main {
223
252
  max-width: calc(var(--max) + 4rem);
224
253
  margin: 0 auto;