mdorigin 0.5.0 → 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.
@@ -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),
@@ -88,6 +95,95 @@ export async function applySiteConfigFrontmatterDefaults(store, siteConfig) {
88
95
  }
89
96
  return siteConfig;
90
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
+ }
91
187
  async function resolveDefaultConfigPath(cwd, rootDir) {
92
188
  const rootConfigPath = rootDir ? await findConfigPath(rootDir) : null;
93
189
  if (rootConfigPath) {
@@ -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;
@@ -27,6 +31,18 @@ export interface RenderDocumentOptions {
27
31
  listingInitialPostCount?: number;
28
32
  listingLoadMoreStep?: number;
29
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
+ };
30
46
  headerHtml?: string;
31
47
  footerHtml?: string;
32
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
@@ -28,6 +31,11 @@ export function renderDocument(options) {
28
31
  const rssMeta = options.rssFeedUrl
29
32
  ? `<link rel="alternate" type="application/rss+xml" title="${siteTitle}" href="${escapeHtml(options.rssFeedUrl)}">`
30
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
+ : '';
31
39
  const stylesheetBlock = `<style>${getDefaultThemeStyles()}${options.stylesheetContent ? `\n${options.stylesheetContent}` : ''}</style>`;
32
40
  const navBlock = options.topNav && options.topNav.length > 0
33
41
  ? `<nav class="site-nav"><ul>${options.topNav
@@ -37,21 +45,26 @@ export function renderDocument(options) {
37
45
  const searchToggleBlock = options.searchEnabled
38
46
  ? [
39
47
  '<div class="site-search" data-site-search>',
40
- '<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>`,
41
49
  '<div id="site-search-panel" class="site-search__panel" hidden>',
42
50
  '<form class="site-search__form" role="search" action="/api/search" method="get">',
43
- '<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>`,
44
52
  '<div class="site-search__controls">',
45
- '<input id="site-search-input" class="site-search__input" type="search" name="q" placeholder="Search docs and skills" autocomplete="off">',
46
- '<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>`,
47
55
  '</div>',
48
- '<p class="site-search__hint">Search is powered by <code>/api/search</code>.</p>',
56
+ `<p class="site-search__hint">${messages['search.hint']}</p>`,
49
57
  '</form>',
50
58
  '<div class="site-search__results" data-site-search-results></div>',
51
59
  '</div>',
52
60
  '</div>',
53
61
  ].join('')
54
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
+ : '';
55
68
  const footerNavBlock = options.footerNav && options.footerNav.length > 0
56
69
  ? `<nav class="site-footer__nav"><ul>${options.footerNav
57
70
  .map((item) => `<li><a href="${escapeHtml(item.href)}">${escapeHtml(item.label)}</a></li>`)
@@ -70,10 +83,10 @@ export function renderDocument(options) {
70
83
  : '';
71
84
  const brandHref = escapeHtml(options.logo?.href ?? '/');
72
85
  const editLinkBlock = options.editLinkHref
73
- ? `<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>`
74
87
  : '';
75
88
  const markdownViewBlock = options.alternateMarkdownPath
76
- ? `<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>`
77
90
  : '';
78
91
  const footerActionsBlock = markdownViewBlock || editLinkBlock
79
92
  ? `<div class="site-footer__actions">${markdownViewBlock}${editLinkBlock}</div>`
@@ -92,15 +105,17 @@ export function renderDocument(options) {
92
105
  requestPath: options.listingRequestPath ?? '/',
93
106
  initialPostCount: options.listingInitialPostCount ?? 10,
94
107
  loadMoreStep: options.listingLoadMoreStep ?? 10,
95
- })
108
+ }, messages)
96
109
  : options.body;
97
- const searchScript = options.searchEnabled ? renderSearchScript() : '';
110
+ const searchScript = options.searchEnabled
111
+ ? renderSearchScript(messages, options.searchLocaleFilter)
112
+ : '';
98
113
  const headerBlock = options.headerHtml ??
99
- `<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>`;
100
115
  const renderedFooterBlock = options.footerHtml ?? footerBlock;
101
116
  return [
102
117
  '<!doctype html>',
103
- '<html lang="en">',
118
+ `<html lang="${escapeHtml(locale)}">`,
104
119
  '<head>',
105
120
  '<meta charset="utf-8">',
106
121
  '<meta name="viewport" content="width=device-width, initial-scale=1">',
@@ -111,6 +126,7 @@ export function renderDocument(options) {
111
126
  socialImageMeta,
112
127
  alternateMarkdownMeta,
113
128
  rssMeta,
129
+ hreflangMeta,
114
130
  stylesheetBlock,
115
131
  '</head>',
116
132
  '<body>',
@@ -163,7 +179,7 @@ function renderSocialIcon(icon) {
163
179
  function iconSvg(pathData) {
164
180
  return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${pathData}"></path></svg>`;
165
181
  }
166
- function renderListingArticle(body, entries, options) {
182
+ function renderListingArticle(body, entries, options, messages) {
167
183
  if (entries.length === 0) {
168
184
  return body;
169
185
  }
@@ -174,26 +190,26 @@ function renderListingArticle(body, entries, options) {
174
190
  const shouldLoadMore = articles.length > visibleArticles.length;
175
191
  return [
176
192
  `<div class="catalog-page__body">${body}</div>`,
177
- '<section class="catalog-page" aria-label="Content listing">',
178
- directories.length > 0 ? renderListingDirectories(directories) : '',
193
+ `<section class="catalog-page" aria-label="${escapeHtml(messages['listing.ariaLabel'])}">`,
194
+ directories.length > 0 ? renderListingDirectories(directories, messages) : '',
179
195
  articles.length > 0
180
196
  ? renderListingArticles(visibleArticles, {
181
197
  requestPath: options.requestPath,
182
198
  nextOffset: visibleArticles.length,
183
199
  loadMoreStep: options.loadMoreStep,
184
200
  hasMore: shouldLoadMore,
185
- })
201
+ }, messages)
186
202
  : '',
187
203
  '</section>',
188
- shouldLoadMore ? renderListingLoadMoreScript() : '',
204
+ shouldLoadMore ? renderListingLoadMoreScript(messages) : '',
189
205
  ].join('');
190
206
  }
191
- function renderListingDirectories(entries) {
207
+ function renderListingDirectories(entries, messages) {
192
208
  return [
193
209
  '<div class="catalog-list catalog-list--directories">',
194
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
195
211
  ? `<span class="catalog-item__detail">${escapeHtml(entry.detail)}</span>`
196
- : '<span class="catalog-item__detail">Browse this section.</span>'}</a>`),
212
+ : `<span class="catalog-item__detail">${escapeHtml(messages['listing.browseSection'])}</span>`}</a>`),
197
213
  '</div>',
198
214
  ].join('');
199
215
  }
@@ -204,17 +220,17 @@ export function renderListingArticleItems(entries) {
204
220
  : ''}</a>`)
205
221
  .join('');
206
222
  }
207
- function renderListingArticles(entries, options) {
223
+ function renderListingArticles(entries, options, messages) {
208
224
  return [
209
225
  '<div class="catalog-list" data-listing-articles>',
210
226
  renderListingArticleItems(entries),
211
227
  '</div>',
212
228
  options.hasMore
213
- ? `<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>`
214
230
  : '',
215
231
  ].join('');
216
232
  }
217
- function renderListingLoadMoreScript() {
233
+ function renderListingLoadMoreScript(messages) {
218
234
  return `<script>
219
235
  (() => {
220
236
  const button = document.querySelector('[data-listing-load-more]');
@@ -223,6 +239,11 @@ function renderListingLoadMoreScript() {
223
239
  return;
224
240
  }
225
241
 
242
+ const M = ${serializeMessagesForScript({
243
+ loadMore: messages['listing.loadMore'],
244
+ loading: messages['listing.loading'],
245
+ })};
246
+
226
247
  const loadMore = async () => {
227
248
  const requestPath = button.dataset.requestPath;
228
249
  const nextOffset = button.dataset.nextOffset;
@@ -233,7 +254,7 @@ function renderListingLoadMoreScript() {
233
254
 
234
255
  button.disabled = true;
235
256
  const previousLabel = button.textContent;
236
- button.textContent = 'Loading...';
257
+ button.textContent = M.loading;
237
258
 
238
259
  try {
239
260
  const url = new URL(requestPath, window.location.origin);
@@ -256,14 +277,14 @@ function renderListingLoadMoreScript() {
256
277
  if (payload.hasMore === true && typeof payload.nextOffset === 'number') {
257
278
  button.dataset.nextOffset = String(payload.nextOffset);
258
279
  button.disabled = false;
259
- button.textContent = previousLabel ?? 'Load more';
280
+ button.textContent = previousLabel ?? M.loadMore;
260
281
  return;
261
282
  }
262
283
 
263
284
  button.remove();
264
285
  } catch {
265
286
  button.disabled = false;
266
- button.textContent = previousLabel ?? 'Load more';
287
+ button.textContent = previousLabel ?? M.loadMore;
267
288
  }
268
289
  };
269
290
 
@@ -273,7 +294,7 @@ function renderListingLoadMoreScript() {
273
294
  })();
274
295
  </script>`;
275
296
  }
276
- function renderSearchScript() {
297
+ function renderSearchScript(messages, localeFilter) {
277
298
  return [
278
299
  '<script>',
279
300
  '(function () {',
@@ -285,41 +306,59 @@ function renderSearchScript() {
285
306
  ' const input = root.querySelector(".site-search__input");',
286
307
  ' const results = root.querySelector("[data-site-search-results]");',
287
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)};`,
288
319
  ' let controller = null;',
289
320
  ' function renderMessage(message) {',
290
321
  ' results.innerHTML = `<p class="site-search__message">${escapeHtmlForScript(message)}</p>`;',
291
322
  ' }',
292
323
  ' function renderHits(hits) {',
293
324
  ' if (!Array.isArray(hits) || hits.length === 0) {',
294
- ' renderMessage("No results.");',
325
+ ' renderMessage(M.resultsEmpty);',
295
326
  ' return;',
296
327
  ' }',
297
328
  ' results.innerHTML = hits.map((hit) => {',
298
329
  ' const href = escapeHtmlForScript(hit.canonicalUrl || hit.docId || "#");',
299
- ' const title = escapeHtmlForScript(hit.title || hit.relativePath || "Untitled");',
330
+ ' const title = escapeHtmlForScript(hit.title || hit.relativePath || M.untitled);',
300
331
  ' const summary = typeof hit.summary === "string" ? `<span class="site-search__item-summary">${escapeHtmlForScript(hit.summary)}</span>` : "";',
301
332
  ' const excerpt = hit.bestMatch && typeof hit.bestMatch.excerpt === "string" ? `<span class="site-search__item-excerpt">${escapeHtmlForScript(hit.bestMatch.excerpt)}</span>` : "";',
302
333
  ' return `<a class="site-search__item" href="${href}"><strong class="site-search__item-title">${title}</strong>${summary}${excerpt}</a>`;',
303
334
  ' }).join("");',
304
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
+ ' }',
305
344
  ' async function runSearch(query) {',
306
345
  ' if (controller) controller.abort();',
307
346
  ' controller = new AbortController();',
308
- ' renderMessage("Searching...");',
347
+ ' renderMessage(M.searching);',
309
348
  ' try {',
310
349
  ' const url = new URL("/api/search", window.location.origin);',
311
350
  ' url.searchParams.set("q", query);',
312
351
  ' url.searchParams.set("topK", "8");',
313
352
  ' const response = await fetch(url, { signal: controller.signal });',
314
353
  ' if (!response.ok) {',
315
- ' renderMessage(`Search failed (${response.status}).`);',
354
+ ' renderMessage(M.failedWithStatus.replaceAll("{status}", String(response.status)));',
316
355
  ' return;',
317
356
  ' }',
318
357
  ' const payload = await response.json();',
319
- ' renderHits(payload.hits);',
358
+ ' renderHits(filterHits(payload.hits));',
320
359
  ' } catch (error) {',
321
360
  ' if (error && typeof error === "object" && "name" in error && error.name === "AbortError") return;',
322
- ' renderMessage("Search failed.");',
361
+ ' renderMessage(M.failed);',
323
362
  ' }',
324
363
  ' }',
325
364
  ' function setOpen(open) {',
@@ -327,7 +366,7 @@ function renderSearchScript() {
327
366
  ' panel.hidden = !open;',
328
367
  ' if (open) {',
329
368
  ' input.focus();',
330
- ' if (!results.innerHTML) renderMessage("Search docs, guides, and skills.");',
369
+ ' if (!results.innerHTML) renderMessage(M.initialHint);',
331
370
  ' }',
332
371
  ' }',
333
372
  ' toggle.addEventListener("click", () => setOpen(panel.hidden));',
@@ -335,7 +374,7 @@ function renderSearchScript() {
335
374
  ' event.preventDefault();',
336
375
  ' const query = input.value.trim();',
337
376
  ' if (!query) {',
338
- ' renderMessage("Enter a search query.");',
377
+ ' renderMessage(M.enterQuery);',
339
378
  ' return;',
340
379
  ' }',
341
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;
@@ -0,0 +1,45 @@
1
+ export interface SiteMessages {
2
+ 'search.toggle': string;
3
+ 'search.label': string;
4
+ 'search.placeholder': string;
5
+ 'search.go': string;
6
+ 'search.hint': string;
7
+ 'search.resultsEmpty': string;
8
+ 'search.searching': string;
9
+ 'search.failed': string;
10
+ 'search.failedWithStatus': string;
11
+ 'search.enterQuery': string;
12
+ 'search.initialHint': string;
13
+ 'search.untitled': string;
14
+ 'footer.editPage': string;
15
+ 'footer.markdownView': string;
16
+ 'footer.markdownViewAria': string;
17
+ 'languages.ariaLabel': string;
18
+ 'listing.browseSection': string;
19
+ 'listing.loadMore': string;
20
+ 'listing.loading': string;
21
+ 'listing.ariaLabel': string;
22
+ 'listing.emptyDirectory': string;
23
+ 'error.notFoundTitle': string;
24
+ 'error.notFoundBody': string;
25
+ }
26
+ export type SiteMessageKey = keyof SiteMessages;
27
+ export declare const DEFAULT_SITE_LOCALE = "en";
28
+ export declare function isKnownSiteLocale(locale: string): boolean;
29
+ /**
30
+ * Resolves the effective message set for a locale: built-in English catalog,
31
+ * overlaid with the built-in catalog for the locale (when available, unknown
32
+ * locales fall back to English), overlaid with user overrides.
33
+ */
34
+ export declare function resolveSiteMessages(locale: string | undefined, overrides?: Partial<SiteMessages>): SiteMessages;
35
+ export declare function formatSiteMessage(message: string, params: Record<string, string>): string;
36
+ /**
37
+ * Serializes messages for embedding inside an inline `<script>` element. `<`
38
+ * is escaped so that a message containing `</script>` cannot terminate the
39
+ * script block early.
40
+ */
41
+ export declare function serializeMessagesForScript(value: Record<string, unknown>): string;
42
+ export declare function validateSiteMessages(value: unknown): {
43
+ messages: Partial<SiteMessages>;
44
+ unknownKeys: string[];
45
+ };
@@ -0,0 +1,104 @@
1
+ export const DEFAULT_SITE_LOCALE = 'en';
2
+ /**
3
+ * Built-in message catalogs. Messages are trusted template fragments: they may
4
+ * contain inline HTML markup such as `<code>` and `{placeholder}` parameters.
5
+ */
6
+ const enMessages = {
7
+ 'search.toggle': 'Search',
8
+ 'search.label': 'Search site',
9
+ 'search.placeholder': 'Search docs and skills',
10
+ 'search.go': 'Go',
11
+ 'search.hint': 'Search is powered by <code>/api/search</code>.',
12
+ 'search.resultsEmpty': 'No results.',
13
+ 'search.searching': 'Searching...',
14
+ 'search.failed': 'Search failed.',
15
+ 'search.failedWithStatus': 'Search failed ({status}).',
16
+ 'search.enterQuery': 'Enter a search query.',
17
+ 'search.initialHint': 'Search docs, guides, and skills.',
18
+ 'search.untitled': 'Untitled',
19
+ 'footer.editPage': 'Edit this page',
20
+ 'footer.markdownView': 'MD View',
21
+ 'footer.markdownViewAria': 'View Markdown source',
22
+ 'languages.ariaLabel': 'Languages',
23
+ 'listing.browseSection': 'Browse this section.',
24
+ 'listing.loadMore': 'Load more',
25
+ 'listing.loading': 'Loading...',
26
+ 'listing.ariaLabel': 'Content listing',
27
+ 'listing.emptyDirectory': 'This directory is empty.',
28
+ 'error.notFoundTitle': 'Not Found',
29
+ 'error.notFoundBody': 'No page was published at <code>{path}</code>.',
30
+ };
31
+ const zhCnMessages = {
32
+ 'search.toggle': '搜索',
33
+ 'search.label': '搜索站点',
34
+ 'search.placeholder': '搜索文档与技能',
35
+ 'search.go': '搜索',
36
+ 'search.hint': '搜索由 <code>/api/search</code> 提供支持。',
37
+ 'search.resultsEmpty': '没有找到结果。',
38
+ 'search.searching': '搜索中...',
39
+ 'search.failed': '搜索失败。',
40
+ 'search.failedWithStatus': '搜索失败({status})。',
41
+ 'search.enterQuery': '请输入搜索关键词。',
42
+ 'search.initialHint': '搜索文档、指南与技能。',
43
+ 'search.untitled': '无标题',
44
+ 'footer.editPage': '编辑此页',
45
+ 'footer.markdownView': 'MD 视图',
46
+ 'footer.markdownViewAria': '查看 Markdown 源文件',
47
+ 'languages.ariaLabel': '语言',
48
+ 'listing.browseSection': '浏览此章节。',
49
+ 'listing.loadMore': '加载更多',
50
+ 'listing.loading': '加载中...',
51
+ 'listing.ariaLabel': '内容列表',
52
+ 'listing.emptyDirectory': '此目录为空。',
53
+ 'error.notFoundTitle': '未找到',
54
+ 'error.notFoundBody': '没有页面发布在 <code>{path}</code>。',
55
+ };
56
+ const messageCatalogs = {
57
+ en: enMessages,
58
+ 'zh-CN': zhCnMessages,
59
+ };
60
+ const catalogKeys = new Map(Object.keys(messageCatalogs).map((key) => [key.toLowerCase(), key]));
61
+ export function isKnownSiteLocale(locale) {
62
+ return messageCatalogs[locale] !== undefined;
63
+ }
64
+ /**
65
+ * Resolves the effective message set for a locale: built-in English catalog,
66
+ * overlaid with the built-in catalog for the locale (when available, unknown
67
+ * locales fall back to English), overlaid with user overrides.
68
+ */
69
+ export function resolveSiteMessages(locale, overrides) {
70
+ const catalogKey = locale === undefined ? undefined : catalogKeys.get(locale.toLowerCase());
71
+ const catalog = catalogKey === undefined ? undefined : messageCatalogs[catalogKey];
72
+ if (catalog === undefined || catalogKey === 'en') {
73
+ return { ...enMessages, ...(overrides ?? {}) };
74
+ }
75
+ return { ...enMessages, ...catalog, ...(overrides ?? {}) };
76
+ }
77
+ export function formatSiteMessage(message, params) {
78
+ return Object.entries(params).reduce((text, [key, value]) => text.replaceAll(`{${key}}`, value), message);
79
+ }
80
+ /**
81
+ * Serializes messages for embedding inside an inline `<script>` element. `<`
82
+ * is escaped so that a message containing `</script>` cannot terminate the
83
+ * script block early.
84
+ */
85
+ export function serializeMessagesForScript(value) {
86
+ return JSON.stringify(value).replaceAll('<', '\\u003c');
87
+ }
88
+ export function validateSiteMessages(value) {
89
+ const messages = {};
90
+ const unknownKeys = [];
91
+ if (typeof value !== 'object' || value === null) {
92
+ return { messages, unknownKeys };
93
+ }
94
+ for (const [key, entry] of Object.entries(value)) {
95
+ if (!(key in enMessages)) {
96
+ unknownKeys.push(key);
97
+ continue;
98
+ }
99
+ if (typeof entry === 'string' && entry !== '') {
100
+ messages[key] = entry;
101
+ }
102
+ }
103
+ return { messages, unknownKeys };
104
+ }
@@ -3,13 +3,15 @@ export interface BuildIndexOptions {
3
3
  rootDir?: string;
4
4
  dir?: string;
5
5
  plugins?: MdoPlugin[];
6
+ /** Top-level directory names (locale content bases) kept out of managed indexes. */
7
+ excludedDirectories?: string[];
6
8
  }
7
9
  export interface BuildIndexResult {
8
10
  updatedFiles: string[];
9
11
  skippedDirectories: string[];
10
12
  }
11
13
  export declare function buildDirectoryIndexes(options: BuildIndexOptions): Promise<BuildIndexResult>;
12
- export declare function buildManagedIndexBlock(directoryPath: string, plugins?: MdoPlugin[]): Promise<string>;
14
+ export declare function buildManagedIndexBlock(directoryPath: string, plugins?: MdoPlugin[], excludedDirectories?: string[]): Promise<string>;
13
15
  export declare function upsertManagedIndexBlock(source: string, block: string, options?: {
14
16
  directoryPath?: string;
15
17
  }): string;