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.
package/README.md CHANGED
@@ -23,7 +23,7 @@ That means `mdorigin` should own content semantics, while page rendering remains
23
23
  - extensionless routes render human-friendly HTML from the same files
24
24
  - `README.md`, `index.md`, and `SKILL.md` all fit into one routing model
25
25
  - the same core works in local preview and Cloudflare Workers
26
- - optional search is powered by [`indexbind`](https://github.com/jolestar/indexbind)
26
+ - optional search is powered by [`indexbind`](https://github.com/holon-run/indexbind)
27
27
 
28
28
  ## Install
29
29
 
@@ -87,7 +87,7 @@ The intended boundary is:
87
87
 
88
88
  ## Optional Search
89
89
 
90
- `mdorigin` can build a local retrieval bundle through the optional [`indexbind`](https://github.com/jolestar/indexbind) package. For the retrieval engine itself, see the `indexbind` docs: <https://indexbind.jolestar.workers.dev>.
90
+ `mdorigin` can build a local retrieval bundle through the optional [`indexbind`](https://github.com/holon-run/indexbind) package. For the retrieval engine itself, see the `indexbind` docs: <https://indexbind.jolestar.workers.dev>.
91
91
 
92
92
  ```bash
93
93
  npm install indexbind
@@ -127,7 +127,7 @@ Runtime endpoints:
127
127
 
128
128
  ## Docs
129
129
 
130
- - Docs site: <https://mdorigin.jolestar.workers.dev>
130
+ - Docs site: <https://mdorigin.holon.run>
131
131
  - Getting started: [`docs/site/guides/getting-started.md`](docs/site/guides/getting-started.md)
132
132
  - Routing model: [`docs/site/concepts/routing.md`](docs/site/concepts/routing.md)
133
133
  - Directory indexes: [`docs/site/concepts/directory-indexes.md`](docs/site/concepts/directory-indexes.md)
@@ -136,4 +136,4 @@ Runtime endpoints:
136
136
  - Search setup: [`docs/site/guides/getting-started.md`](docs/site/guides/getting-started.md#quick-start)
137
137
  - Cloudflare deployment: [`docs/site/guides/cloudflare.md`](docs/site/guides/cloudflare.md)
138
138
 
139
- The docs site at <https://mdorigin.jolestar.workers.dev> is deployed automatically from `main` with GitHub Actions.
139
+ The docs site at <https://mdorigin.holon.run> is deployed automatically from `main` with GitHub Actions.
@@ -48,6 +48,8 @@ export function createCloudflareWorker(manifest, options = {}) {
48
48
  siteConfig: manifest.siteConfig ?? {
49
49
  siteTitle: 'mdorigin',
50
50
  siteUrl: undefined,
51
+ locale: 'en',
52
+ messages: {},
51
53
  favicon: undefined,
52
54
  logo: undefined,
53
55
  showDate: true,
@@ -23,6 +23,9 @@ export async function runBuildIndexCommand(argv) {
23
23
  rootDir,
24
24
  dir,
25
25
  plugins: loadedConfig.plugins,
26
+ excludedDirectories: (loadedConfig.siteConfig.locales ?? [])
27
+ .filter((locale) => locale.contentBase !== '')
28
+ .map((locale) => locale.contentBase),
26
29
  });
27
30
  console.log(`updated ${result.updatedFiles.length} index file(s)`);
28
31
  if (result.skippedDirectories.length > 0) {
@@ -8,10 +8,23 @@ export interface IndexTransformContext {
8
8
  sourcePath?: string;
9
9
  siteConfig?: ResolvedSiteConfig;
10
10
  }
11
+ export interface PageLanguage {
12
+ code: string;
13
+ label: string;
14
+ /** Root-relative href for this language: the translated page when available, otherwise the language home. */
15
+ href: string;
16
+ current: boolean;
17
+ /** Whether a page-level translation exists for this language. */
18
+ translated: boolean;
19
+ }
11
20
  export interface PageRenderModel {
12
21
  kind: 'page' | 'listing';
13
22
  requestPath: string;
14
23
  sourcePath: string;
24
+ /** Effective UI locale for this request (frontmatter overrides not applied). */
25
+ locale: string;
26
+ /** Language switcher entries; empty for single-language sites. */
27
+ languages: PageLanguage[];
15
28
  siteTitle: string;
16
29
  siteDescription?: string;
17
30
  siteUrl?: string;
@@ -6,11 +6,17 @@ import { extractManagedIndexEntries, getDocumentSummary, getDocumentTitle as get
6
6
  import { ensureTrailingSlash, trimLeadingSlash } from './site-url.js';
7
7
  import { applyIndexTransforms, renderFooterOverride, renderHeaderOverride, renderPageWithPlugins, transformHtmlWithPlugins, } from './extensions.js';
8
8
  import { handleApiRoute } from './api.js';
9
- import { normalizeRequestPath, resolveRequest } from './router.js';
9
+ import { matchRequestLocale, normalizeRequestPath, resolveRequest } from './router.js';
10
10
  import { escapeHtml, renderListingArticleItems, renderDocument, } from '../html/template.js';
11
+ import { formatSiteMessage, resolveSiteMessages, } from '../i18n/messages.js';
11
12
  export async function handleSiteRequest(store, pathname, options) {
12
13
  const plugins = options.plugins ?? [];
13
14
  const searchEnabled = options.searchApi !== undefined;
15
+ const requestLocale = matchRequestLocale(pathname, options.siteConfig.locales);
16
+ const defaultLocale = options.siteConfig.locales?.find((locale) => locale.isDefault);
17
+ if (defaultLocale !== undefined && defaultLocale.pathPrefix !== '' && pathname === '/') {
18
+ return redirect(`${defaultLocale.pathPrefix}/`);
19
+ }
14
20
  const apiRoute = await handleApiRoute(pathname, options.searchParams, {
15
21
  searchApi: options.searchApi,
16
22
  siteConfig: options.siteConfig,
@@ -23,7 +29,14 @@ export async function handleSiteRequest(store, pathname, options) {
23
29
  return renderSitemap(store, options);
24
30
  }
25
31
  if (pathname === '/feed.xml') {
26
- return renderRssFeed(store, options);
32
+ return renderRssFeed(store, options, defaultLocale ?? null);
33
+ }
34
+ if (options.siteConfig.locales !== undefined && requestLocale !== null) {
35
+ for (const locale of options.siteConfig.locales) {
36
+ if (locale.pathPrefix !== '' && pathname === `${locale.pathPrefix}/feed.xml`) {
37
+ return renderRssFeed(store, options, locale);
38
+ }
39
+ }
27
40
  }
28
41
  const resolved = resolveRequest(pathname);
29
42
  const listingFragmentRequest = getListingFragmentRequest(options.searchParams);
@@ -58,20 +71,20 @@ export async function handleSiteRequest(store, pathname, options) {
58
71
  if (directoryIndexResponse !== null) {
59
72
  return directoryIndexResponse;
60
73
  }
61
- return renderDirectoryListing(store, resolved.requestPath, options.siteConfig, searchEnabled);
74
+ return renderDirectoryListing(store, resolved.requestPath, options.siteConfig, searchEnabled, requestLocale);
62
75
  }
63
- return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown);
76
+ return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown, requestLocale);
64
77
  }
65
78
  if (resolved.kind === 'asset') {
66
79
  return serveAsset(entry);
67
80
  }
68
81
  if (entry.kind !== 'text' || entry.text === undefined) {
69
- return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown);
82
+ return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown, requestLocale);
70
83
  }
71
84
  if (resolved.kind === 'markdown' || negotiatedMarkdown) {
72
85
  const parsed = await parseMarkdownDocument(resolved.sourcePath, entry.text);
73
86
  if (parsed.meta.draft === true && options.draftMode === 'exclude') {
74
- return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown);
87
+ return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown, requestLocale);
75
88
  }
76
89
  return {
77
90
  status: 200,
@@ -83,7 +96,7 @@ export async function handleSiteRequest(store, pathname, options) {
83
96
  }
84
97
  const parsed = await parseMarkdownDocument(resolved.sourcePath, entry.text);
85
98
  if (parsed.meta.draft === true && options.draftMode === 'exclude') {
86
- return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown);
99
+ return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown, requestLocale);
87
100
  }
88
101
  const navigation = await resolveTopNav(store, options.siteConfig);
89
102
  const renderedBody = isRootHomeRequest(resolved.requestPath) && !options.siteConfig.showHomeIndex
@@ -114,6 +127,9 @@ export async function handleSiteRequest(store, pathname, options) {
114
127
  listingEntries,
115
128
  searchEnabled,
116
129
  plugins,
130
+ store,
131
+ requestLocale,
132
+ draftMode: options.draftMode,
117
133
  varyOnAccept: shouldVaryOnAccept(resolved),
118
134
  });
119
135
  }
@@ -148,6 +164,8 @@ function buildPageRenderModel(options) {
148
164
  kind: options.listingEntries.length > 0 ? 'listing' : 'page',
149
165
  requestPath: options.resolvedRequestPath,
150
166
  sourcePath: options.sourcePath,
167
+ locale: options.locale,
168
+ languages: options.languages,
151
169
  siteTitle: options.siteConfig.siteTitle,
152
170
  siteDescription: options.siteConfig.siteDescription,
153
171
  siteUrl: options.siteConfig.siteUrl,
@@ -180,9 +198,18 @@ function buildPageRenderModel(options) {
180
198
  };
181
199
  }
182
200
  async function renderStructuredPage(options) {
201
+ const localeConfig = options.requestLocale;
202
+ const localeCode = localeConfig?.code ?? options.siteConfig.locale;
203
+ const messages = getEffectiveLocaleMessages(options.siteConfig, localeConfig);
204
+ const htmlLang = getFrontmatterLocale(options.parsed.meta) ?? localeCode;
205
+ const languages = await buildLanguageOptions(options.store, { sourcePath: options.sourcePath }, localeConfig, options.siteConfig, options.draftMode);
206
+ const hreflangAlternates = buildHreflangAlternates(languages, options.siteConfig);
207
+ const searchLocaleFilter = buildSearchLocaleFilter(options.siteConfig, localeConfig);
183
208
  const page = buildPageRenderModel({
184
209
  resolvedRequestPath: options.requestPath,
185
210
  sourcePath: options.sourcePath,
211
+ locale: localeCode,
212
+ languages,
186
213
  renderedBodyHtml: options.renderedParsed.html,
187
214
  parsed: options.parsed,
188
215
  siteConfig: options.siteConfig,
@@ -204,6 +231,8 @@ async function renderStructuredPage(options) {
204
231
  return renderDocument({
205
232
  siteTitle: currentPage.siteTitle,
206
233
  siteDescription: currentPage.siteDescription,
234
+ locale: htmlLang,
235
+ messages,
207
236
  siteUrl: currentPage.siteUrl,
208
237
  favicon: currentPage.favicon,
209
238
  socialImage: currentPage.socialImage,
@@ -222,12 +251,15 @@ async function renderStructuredPage(options) {
222
251
  stylesheetContent: currentPage.stylesheetContent,
223
252
  canonicalPath: currentPage.canonicalPath,
224
253
  alternateMarkdownPath: currentPage.alternateMarkdownPath,
225
- rssFeedUrl: getRssFeedUrl(currentPage.siteUrl, options.siteConfig),
254
+ rssFeedUrl: getRssFeedUrl(currentPage.siteUrl, options.siteConfig, localeConfig),
226
255
  listingEntries: currentPage.listingEntries,
227
256
  listingRequestPath: currentPage.listingRequestPath,
228
257
  listingInitialPostCount: currentPage.listingInitialPostCount,
229
258
  listingLoadMoreStep: currentPage.listingLoadMoreStep,
230
259
  searchEnabled: currentPage.searchEnabled,
260
+ languages: currentPage.languages,
261
+ hreflangAlternates,
262
+ searchLocaleFilter,
231
263
  headerHtml,
232
264
  footerHtml,
233
265
  });
@@ -292,20 +324,26 @@ function notFound() {
292
324
  }
293
325
  async function renderNotFoundForRequest(store, pathname, options) {
294
326
  const resolved = resolveRequest(pathname);
295
- return renderNotFoundForResolvedRequest(store, resolved, options, false);
327
+ return renderNotFoundForResolvedRequest(store, resolved, options, false, matchRequestLocale(pathname, options.siteConfig.locales));
296
328
  }
297
- async function renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown) {
329
+ async function renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown, requestLocale) {
298
330
  const varyOnAccept = shouldVaryOnAccept(resolved);
299
331
  if (resolved.kind !== 'html' || negotiatedMarkdown) {
300
332
  return withNotFoundVary(varyOnAccept);
301
333
  }
302
- return renderHtmlNotFound(store, resolved.requestPath, options, varyOnAccept);
334
+ return renderHtmlNotFound(store, resolved.requestPath, options, varyOnAccept, requestLocale);
303
335
  }
304
- async function renderHtmlNotFound(store, requestPath, options, varyOnAccept) {
336
+ async function renderHtmlNotFound(store, requestPath, options, varyOnAccept, requestLocale) {
305
337
  const navigation = await resolveTopNav(store, options.siteConfig);
338
+ const localeConfig = requestLocale;
339
+ const locale = localeConfig?.code ?? options.siteConfig.locale;
340
+ const messages = getEffectiveLocaleMessages(options.siteConfig, localeConfig);
341
+ const languages = await buildLanguageOptions(store, {}, localeConfig, options.siteConfig, options.draftMode);
306
342
  const body = [
307
- '<h1>Not Found</h1>',
308
- `<p>No page was published at <code>${escapeHtml(requestPath)}</code>.</p>`,
343
+ `<h1>${escapeHtml(messages['error.notFoundTitle'])}</h1>`,
344
+ `<p>${formatSiteMessage(messages['error.notFoundBody'], {
345
+ path: escapeHtml(requestPath),
346
+ })}</p>`,
309
347
  ].join('');
310
348
  return {
311
349
  status: 404,
@@ -319,8 +357,11 @@ async function renderHtmlNotFound(store, requestPath, options, varyOnAccept) {
319
357
  favicon: options.siteConfig.favicon,
320
358
  socialImage: options.siteConfig.socialImage,
321
359
  logo: options.siteConfig.logo,
322
- title: 'Not Found',
360
+ title: messages['error.notFoundTitle'],
323
361
  body,
362
+ locale,
363
+ messages,
364
+ languages,
324
365
  showSummary: false,
325
366
  showDate: false,
326
367
  topNav: navigation.items,
@@ -328,7 +369,7 @@ async function renderHtmlNotFound(store, requestPath, options, varyOnAccept) {
328
369
  footerText: options.siteConfig.footerText,
329
370
  socialLinks: options.siteConfig.socialLinks,
330
371
  stylesheetContent: options.siteConfig.stylesheetContent,
331
- rssFeedUrl: getRssFeedUrl(options.siteConfig.siteUrl, options.siteConfig),
372
+ rssFeedUrl: getRssFeedUrl(options.siteConfig.siteUrl, options.siteConfig, localeConfig),
332
373
  searchEnabled: options.searchApi !== undefined,
333
374
  }),
334
375
  };
@@ -352,6 +393,127 @@ function redirect(location) {
352
393
  },
353
394
  };
354
395
  }
396
+ function getEffectiveLocaleMessages(siteConfig, localeConfig) {
397
+ if (localeConfig === null) {
398
+ return resolveSiteMessages(siteConfig.locale, siteConfig.messages);
399
+ }
400
+ return resolveSiteMessages(localeConfig.code, {
401
+ ...siteConfig.messages,
402
+ ...localeConfig.messages,
403
+ });
404
+ }
405
+ function getFrontmatterLocale(meta) {
406
+ if (typeof meta.lang !== 'string') {
407
+ return null;
408
+ }
409
+ const trimmed = meta.lang.trim();
410
+ return trimmed === '' ? null : trimmed;
411
+ }
412
+ async function buildLanguageOptions(store, target, currentLocale, siteConfig, draftMode) {
413
+ const locales = siteConfig.locales;
414
+ if (locales === undefined || locales.length === 0) {
415
+ return [];
416
+ }
417
+ const languages = [];
418
+ for (const locale of locales) {
419
+ const translated = await localeHasContent(store, target, currentLocale, locale, draftMode);
420
+ languages.push({
421
+ code: locale.code,
422
+ label: locale.label,
423
+ href: translated === null
424
+ ? getLocaleHomePath(locale)
425
+ : getCanonicalHtmlPathForContentPath(translated),
426
+ current: locale === currentLocale,
427
+ translated: translated !== null,
428
+ });
429
+ }
430
+ return languages;
431
+ }
432
+ async function localeHasContent(store, target, currentLocale, locale, draftMode) {
433
+ if (locale === currentLocale) {
434
+ return target.sourcePath
435
+ ? target.sourcePath
436
+ : target.directoryPath === ''
437
+ ? 'index.md'
438
+ : `${target.directoryPath}/index.md`;
439
+ }
440
+ const candidates = [];
441
+ if (target.sourcePath !== undefined) {
442
+ candidates.push(mapContentPathForLocale(target.sourcePath, currentLocale, locale));
443
+ }
444
+ else {
445
+ const directoryPath = target.directoryPath ?? '';
446
+ for (const candidate of getDirectoryIndexCandidates(mapContentPathForLocale(directoryPath, currentLocale, locale))) {
447
+ candidates.push(candidate);
448
+ }
449
+ }
450
+ for (const candidate of candidates) {
451
+ if (await hasPublishedContent(store, candidate, draftMode)) {
452
+ return candidate;
453
+ }
454
+ }
455
+ return null;
456
+ }
457
+ function mapContentPathForLocale(contentPath, from, to) {
458
+ if (from === null || from.contentBase === '') {
459
+ return to.contentBase === '' ? contentPath : `${to.contentBase}/${contentPath}`;
460
+ }
461
+ if (!contentPath.startsWith(`${from.contentBase}/`)) {
462
+ return contentPath;
463
+ }
464
+ const rest = contentPath.slice(from.contentBase.length + 1);
465
+ return to.contentBase === '' ? rest : `${to.contentBase}/${rest}`;
466
+ }
467
+ function getLocaleHomePath(locale) {
468
+ return locale.pathPrefix === '' ? '/' : `${locale.pathPrefix}/`;
469
+ }
470
+ async function hasPublishedContent(store, contentPath, draftMode) {
471
+ const entry = await store.get(contentPath);
472
+ if (entry === null || entry.kind !== 'text' || entry.text === undefined) {
473
+ return false;
474
+ }
475
+ if (draftMode === 'exclude') {
476
+ const parsed = await parseMarkdownDocument(contentPath, entry.text);
477
+ if (parsed.meta.draft === true) {
478
+ return false;
479
+ }
480
+ }
481
+ return true;
482
+ }
483
+ function buildHreflangAlternates(languages, siteConfig) {
484
+ if (languages.length === 0 || siteConfig.siteUrl === undefined) {
485
+ return [];
486
+ }
487
+ const base = ensureTrailingSlash(siteConfig.siteUrl);
488
+ const alternates = languages
489
+ .filter((language) => language.translated)
490
+ .map((language) => ({
491
+ hreflang: language.code,
492
+ href: new URL(trimLeadingSlash(language.href), base).toString(),
493
+ }));
494
+ const defaultLocale = siteConfig.locales?.find((locale) => locale.isDefault);
495
+ const defaultLanguage = defaultLocale
496
+ ? languages.find((language) => language.code === defaultLocale.code)
497
+ : undefined;
498
+ if (defaultLanguage?.translated) {
499
+ alternates.push({
500
+ hreflang: 'x-default',
501
+ href: new URL(trimLeadingSlash(defaultLanguage.href), base).toString(),
502
+ });
503
+ }
504
+ return alternates;
505
+ }
506
+ function buildSearchLocaleFilter(siteConfig, localeConfig) {
507
+ if (siteConfig.locales === undefined || localeConfig === null) {
508
+ return undefined;
509
+ }
510
+ return {
511
+ include: localeConfig.contentBase === '' ? '' : `${localeConfig.contentBase}/`,
512
+ exclude: siteConfig.locales
513
+ .filter((locale) => locale !== localeConfig && locale.contentBase !== '')
514
+ .map((locale) => `${locale.contentBase}/`),
515
+ };
516
+ }
355
517
  async function renderSitemap(store, options) {
356
518
  if (!options.siteConfig.siteUrl) {
357
519
  return {
@@ -380,13 +542,16 @@ async function renderSitemap(store, options) {
380
542
  body,
381
543
  };
382
544
  }
383
- async function renderRssFeed(store, options) {
545
+ async function renderRssFeed(store, options, locale) {
384
546
  if (!isRssEnabled(options.siteConfig) || !options.siteConfig.siteUrl) {
385
547
  return notFound();
386
548
  }
387
- const items = await collectRssFeedItems(store, '', options);
549
+ const excludedLocaleDirectories = new Set((options.siteConfig.locales ?? [])
550
+ .filter((entry) => entry.contentBase !== '' && entry !== locale)
551
+ .map((entry) => entry.contentBase));
552
+ const items = await collectRssFeedItems(store, locale?.contentBase ?? '', options, excludedLocaleDirectories);
388
553
  const limitedItems = items.slice(0, options.siteConfig.rss?.maxItems ?? 20);
389
- const rssFeedUrl = getRssFeedUrl(options.siteConfig.siteUrl, options.siteConfig);
554
+ const rssFeedUrl = getRssFeedUrl(options.siteConfig.siteUrl, options.siteConfig, locale);
390
555
  const title = options.siteConfig.rss?.title ?? options.siteConfig.siteTitle;
391
556
  const description = options.siteConfig.rss?.description ?? options.siteConfig.siteDescription;
392
557
  const lastBuildDate = limitedItems[0]?.pubDate.toUTCString();
@@ -503,7 +668,7 @@ function dedupeSitemapEntries(entries) {
503
668
  }
504
669
  return Array.from(deduped.values());
505
670
  }
506
- async function collectRssFeedItems(store, directoryPath, options) {
671
+ async function collectRssFeedItems(store, directoryPath, options, excludedDirectories) {
507
672
  const entries = await store.listDirectory(directoryPath);
508
673
  if (entries === null) {
509
674
  return [];
@@ -512,7 +677,10 @@ async function collectRssFeedItems(store, directoryPath, options) {
512
677
  const directoryShape = inspectDirectoryShapeEntries(entries);
513
678
  for (const entry of entries) {
514
679
  if (entry.kind === 'directory') {
515
- feedItems.push(...(await collectRssFeedItems(store, entry.path, options)));
680
+ if (excludedDirectories.has(entry.path)) {
681
+ continue;
682
+ }
683
+ feedItems.push(...(await collectRssFeedItems(store, entry.path, options, excludedDirectories)));
516
684
  continue;
517
685
  }
518
686
  if (!isMarkdownEntry(entry)) {
@@ -571,7 +739,7 @@ function parseFeedDate(value) {
571
739
  const parsed = new Date(value);
572
740
  return Number.isNaN(parsed.getTime()) ? null : parsed;
573
741
  }
574
- async function renderDirectoryListing(store, requestPath, siteConfig, searchEnabled) {
742
+ async function renderDirectoryListing(store, requestPath, siteConfig, searchEnabled, requestLocale) {
575
743
  const directoryPath = requestPath === '/' ? '' : requestPath.slice(1).replace(/\/$/, '');
576
744
  const entries = await store.listDirectory(directoryPath);
577
745
  if (entries === null) {
@@ -579,12 +747,17 @@ async function renderDirectoryListing(store, requestPath, siteConfig, searchEnab
579
747
  }
580
748
  const visibleEntries = entries.filter(isVisibleDirectoryEntry);
581
749
  const navigation = await resolveTopNav(store, siteConfig);
750
+ const locale = requestLocale?.code ?? siteConfig.locale;
751
+ const messages = getEffectiveLocaleMessages(siteConfig, requestLocale);
752
+ const languages = await buildLanguageOptions(store, { directoryPath }, requestLocale, siteConfig, 'exclude');
582
753
  const listItems = visibleEntries
583
754
  .map((entry) => `<li><a href="${getDirectoryEntryHref(requestPath, entry)}">${escapeHtml(getDirectoryEntryLabel(entry))}</a></li>`)
584
755
  .join('');
585
756
  const body = [
586
757
  `<h1>${escapeHtml(getDirectoryTitle(requestPath))}</h1>`,
587
- visibleEntries.length > 0 ? `<ul>${listItems}</ul>` : '<p>This directory is empty.</p>',
758
+ visibleEntries.length > 0
759
+ ? `<ul>${listItems}</ul>`
760
+ : `<p>${escapeHtml(messages['listing.emptyDirectory'])}</p>`,
588
761
  ].join('');
589
762
  return {
590
763
  status: 200,
@@ -594,6 +767,9 @@ async function renderDirectoryListing(store, requestPath, siteConfig, searchEnab
594
767
  body: renderDocument({
595
768
  siteTitle: siteConfig.siteTitle,
596
769
  siteDescription: siteConfig.siteDescription,
770
+ locale,
771
+ messages,
772
+ languages,
597
773
  siteUrl: siteConfig.siteUrl,
598
774
  favicon: siteConfig.favicon,
599
775
  logo: siteConfig.logo,
@@ -608,7 +784,7 @@ async function renderDirectoryListing(store, requestPath, siteConfig, searchEnab
608
784
  stylesheetContent: siteConfig.stylesheetContent,
609
785
  canonicalPath: requestPath,
610
786
  alternateMarkdownPath: getMarkdownRequestPathForContentPath(getDirectoryIndexContentPathForRequestPath(requestPath)),
611
- rssFeedUrl: getRssFeedUrl(siteConfig.siteUrl, siteConfig),
787
+ rssFeedUrl: getRssFeedUrl(siteConfig.siteUrl, siteConfig, requestLocale),
612
788
  searchEnabled,
613
789
  }),
614
790
  };
@@ -684,6 +860,9 @@ async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
684
860
  listingEntries,
685
861
  searchEnabled: options.searchApi !== undefined,
686
862
  plugins,
863
+ store,
864
+ requestLocale: matchRequestLocale(requestPath, options.siteConfig.locales),
865
+ draftMode: options.draftMode,
687
866
  });
688
867
  }
689
868
  return null;
@@ -859,11 +1038,14 @@ function getCanonicalHtmlPathForContentPath(contentPath) {
859
1038
  function isRssEnabled(siteConfig) {
860
1039
  return Boolean(siteConfig.siteUrl) && (siteConfig.rss?.enabled ?? true);
861
1040
  }
862
- function getRssFeedUrl(siteUrl, siteConfig) {
1041
+ function getRssFeedUrl(siteUrl, siteConfig, locale) {
863
1042
  if (!siteUrl || !isRssEnabled(siteConfig)) {
864
1043
  return undefined;
865
1044
  }
866
- return new URL('feed.xml', ensureTrailingSlash(siteUrl)).toString();
1045
+ const feedPath = locale !== null && locale.pathPrefix !== ''
1046
+ ? `${locale.pathPrefix.slice(1)}/feed.xml`
1047
+ : 'feed.xml';
1048
+ return new URL(feedPath, ensureTrailingSlash(siteUrl)).toString();
867
1049
  }
868
1050
  function getEditLinkHref(siteConfig, sourcePath) {
869
1051
  if (!siteConfig.editLink || !sourcePath) {
@@ -886,9 +1068,15 @@ async function resolveTopNav(store, siteConfig) {
886
1068
  };
887
1069
  }
888
1070
  const directories = rootEntries.filter((entry) => entry.kind === 'directory');
1071
+ const localeDirectoryNames = new Set((siteConfig.locales ?? [])
1072
+ .filter((locale) => locale.contentBase !== '')
1073
+ .map((locale) => locale.contentBase));
889
1074
  const navItems = [];
890
1075
  const orderedNavItems = [];
891
1076
  for (const entry of directories) {
1077
+ if (localeDirectoryNames.has(entry.name)) {
1078
+ continue;
1079
+ }
892
1080
  const resolved = await resolveDirectoryNav(store, entry);
893
1081
  if (resolved.type !== 'page') {
894
1082
  continue;
@@ -1,3 +1,4 @@
1
+ import type { ResolvedLocaleConfig } from './site-config.js';
1
2
  export type ResolvedRequestKind = 'markdown' | 'html' | 'asset' | 'not-found';
2
3
  export interface ResolvedRequest {
3
4
  kind: ResolvedRequestKind;
@@ -6,3 +7,11 @@ export interface ResolvedRequest {
6
7
  }
7
8
  export declare function resolveRequest(pathname: string): ResolvedRequest;
8
9
  export declare function normalizeRequestPath(pathname: string): string | null;
10
+ /**
11
+ * Attributes a request path to a configured content locale. Locale URL
12
+ * prefixes map 1:1 to top-level `{code}` content directories, so attribution
13
+ * never rewrites content resolution: a path that matches a locale prefix keeps
14
+ * resolving to that locale's directory, and any other path belongs to the
15
+ * default locale (whose content lives at the content root when unprefixed).
16
+ */
17
+ export declare function matchRequestLocale(pathname: string, locales: readonly ResolvedLocaleConfig[] | undefined): ResolvedLocaleConfig | null;
@@ -80,3 +80,29 @@ export function normalizeRequestPath(pathname) {
80
80
  return null;
81
81
  }
82
82
  }
83
+ /**
84
+ * Attributes a request path to a configured content locale. Locale URL
85
+ * prefixes map 1:1 to top-level `{code}` content directories, so attribution
86
+ * never rewrites content resolution: a path that matches a locale prefix keeps
87
+ * resolving to that locale's directory, and any other path belongs to the
88
+ * default locale (whose content lives at the content root when unprefixed).
89
+ */
90
+ export function matchRequestLocale(pathname, locales) {
91
+ if (locales === undefined || locales.length === 0) {
92
+ return null;
93
+ }
94
+ const normalized = normalizeRequestPath(pathname);
95
+ if (normalized === null) {
96
+ return null;
97
+ }
98
+ const prefixed = locales
99
+ .filter((locale) => locale.pathPrefix !== '')
100
+ .sort((left, right) => right.pathPrefix.length - left.pathPrefix.length);
101
+ for (const locale of prefixed) {
102
+ if (normalized === locale.pathPrefix ||
103
+ normalized.startsWith(`${locale.pathPrefix}/`)) {
104
+ return locale;
105
+ }
106
+ }
107
+ return locales.find((locale) => locale.isDefault) ?? null;
108
+ }
@@ -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;
@@ -64,6 +65,16 @@ export interface SiteSearchConfig {
64
65
  export interface SiteConfig {
65
66
  siteTitle?: string;
66
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[];
67
78
  siteUrl?: string;
68
79
  favicon?: string;
69
80
  socialImage?: string;
@@ -88,6 +99,9 @@ export interface UserSiteConfig extends SiteConfig {
88
99
  export interface ResolvedSiteConfig {
89
100
  siteTitle: string;
90
101
  siteDescription?: string;
102
+ locale: string;
103
+ messages: Partial<SiteMessages>;
104
+ locales?: ResolvedLocaleConfig[];
91
105
  siteUrl?: string;
92
106
  favicon?: string;
93
107
  socialImage?: string;
@@ -119,6 +133,30 @@ export interface LoadedSiteConfig {
119
133
  configFilePath: string;
120
134
  configModulePath?: string;
121
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
+ }
122
160
  export declare function loadSiteConfig(options?: LoadSiteConfigOptions): Promise<ResolvedSiteConfig>;
123
161
  export declare function loadUserSiteConfig(options?: LoadSiteConfigOptions): Promise<LoadedSiteConfig>;
124
162
  export declare function applySiteConfigFrontmatterDefaults(store: ContentStore, siteConfig: ResolvedSiteConfig): Promise<ResolvedSiteConfig>;