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,15 +1,22 @@
1
1
  import path from 'node:path';
2
2
  import { isIgnoredContentName } from './content-store.js';
3
- import { inferDirectoryContentType } from './content-type.js';
3
+ import { inferDirectoryContentType, resolveContentType, } from './content-type.js';
4
4
  import { getDirectoryIndexCandidates } from './directory-index.js';
5
- import { extractManagedIndexEntries, getDocumentSummary, getDocumentTitle as getParsedDocumentTitle, parseMarkdownDocument, stripManagedIndexBlock, stripManagedIndexLinks, } from './markdown.js';
5
+ import { extractManagedIndexEntries, getDocumentSummary, getDocumentTitle as getParsedDocumentTitle, parseMarkdownDocument, stripManagedIndexBlock, stripManagedIndexLinks, stripMachineOnlyMarkdownComments, } from './markdown.js';
6
+ import { ensureTrailingSlash, trimLeadingSlash } from './site-url.js';
6
7
  import { applyIndexTransforms, renderFooterOverride, renderHeaderOverride, renderPageWithPlugins, transformHtmlWithPlugins, } from './extensions.js';
7
8
  import { handleApiRoute } from './api.js';
8
- import { normalizeRequestPath, resolveRequest } from './router.js';
9
+ import { matchRequestLocale, normalizeRequestPath, resolveRequest } from './router.js';
9
10
  import { escapeHtml, renderListingArticleItems, renderDocument, } from '../html/template.js';
11
+ import { formatSiteMessage, resolveSiteMessages, } from '../i18n/messages.js';
10
12
  export async function handleSiteRequest(store, pathname, options) {
11
13
  const plugins = options.plugins ?? [];
12
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
+ }
13
20
  const apiRoute = await handleApiRoute(pathname, options.searchParams, {
14
21
  searchApi: options.searchApi,
15
22
  siteConfig: options.siteConfig,
@@ -21,6 +28,16 @@ export async function handleSiteRequest(store, pathname, options) {
21
28
  if (pathname === '/sitemap.xml') {
22
29
  return renderSitemap(store, options);
23
30
  }
31
+ if (pathname === '/feed.xml') {
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
+ }
40
+ }
24
41
  const resolved = resolveRequest(pathname);
25
42
  const listingFragmentRequest = getListingFragmentRequest(options.searchParams);
26
43
  const negotiatedMarkdown = shouldServeMarkdownForRequest(resolved, options.acceptHeader);
@@ -29,7 +46,7 @@ export async function handleSiteRequest(store, pathname, options) {
29
46
  if (aliasRedirect !== null) {
30
47
  return aliasRedirect;
31
48
  }
32
- return notFound();
49
+ return renderNotFoundForRequest(store, pathname, options);
33
50
  }
34
51
  const entry = await store.get(resolved.sourcePath);
35
52
  if (entry === null) {
@@ -45,25 +62,29 @@ export async function handleSiteRequest(store, pathname, options) {
45
62
  if (alternateMarkdownRedirect !== null) {
46
63
  return alternateMarkdownRedirect;
47
64
  }
65
+ const canonicalDirectoryRedirect = await tryRedirectCanonicalDirectoryPath(store, resolved);
66
+ if (canonicalDirectoryRedirect !== null) {
67
+ return canonicalDirectoryRedirect;
68
+ }
48
69
  if (resolved.kind === 'html' && resolved.requestPath.endsWith('/')) {
49
70
  const directoryIndexResponse = await tryRenderAlternateDirectoryIndex(store, resolved.requestPath, options);
50
71
  if (directoryIndexResponse !== null) {
51
72
  return directoryIndexResponse;
52
73
  }
53
- return renderDirectoryListing(store, resolved.requestPath, options.siteConfig, searchEnabled);
74
+ return renderDirectoryListing(store, resolved.requestPath, options.siteConfig, searchEnabled, requestLocale);
54
75
  }
55
- return notFound();
76
+ return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown, requestLocale);
56
77
  }
57
78
  if (resolved.kind === 'asset') {
58
79
  return serveAsset(entry);
59
80
  }
60
81
  if (entry.kind !== 'text' || entry.text === undefined) {
61
- return notFound();
82
+ return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown, requestLocale);
62
83
  }
63
84
  if (resolved.kind === 'markdown' || negotiatedMarkdown) {
64
85
  const parsed = await parseMarkdownDocument(resolved.sourcePath, entry.text);
65
86
  if (parsed.meta.draft === true && options.draftMode === 'exclude') {
66
- return notFound();
87
+ return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown, requestLocale);
67
88
  }
68
89
  return {
69
90
  status: 200,
@@ -75,7 +96,7 @@ export async function handleSiteRequest(store, pathname, options) {
75
96
  }
76
97
  const parsed = await parseMarkdownDocument(resolved.sourcePath, entry.text);
77
98
  if (parsed.meta.draft === true && options.draftMode === 'exclude') {
78
- return notFound();
99
+ return renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown, requestLocale);
79
100
  }
80
101
  const navigation = await resolveTopNav(store, options.siteConfig);
81
102
  const renderedBody = isRootHomeRequest(resolved.requestPath) && !options.siteConfig.showHomeIndex
@@ -106,6 +127,9 @@ export async function handleSiteRequest(store, pathname, options) {
106
127
  listingEntries,
107
128
  searchEnabled,
108
129
  plugins,
130
+ store,
131
+ requestLocale,
132
+ draftMode: options.draftMode,
109
133
  varyOnAccept: shouldVaryOnAccept(resolved),
110
134
  });
111
135
  }
@@ -140,6 +164,8 @@ function buildPageRenderModel(options) {
140
164
  kind: options.listingEntries.length > 0 ? 'listing' : 'page',
141
165
  requestPath: options.resolvedRequestPath,
142
166
  sourcePath: options.sourcePath,
167
+ locale: options.locale,
168
+ languages: options.languages,
143
169
  siteTitle: options.siteConfig.siteTitle,
144
170
  siteDescription: options.siteConfig.siteDescription,
145
171
  siteUrl: options.siteConfig.siteUrl,
@@ -172,9 +198,18 @@ function buildPageRenderModel(options) {
172
198
  };
173
199
  }
174
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);
175
208
  const page = buildPageRenderModel({
176
209
  resolvedRequestPath: options.requestPath,
177
210
  sourcePath: options.sourcePath,
211
+ locale: localeCode,
212
+ languages,
178
213
  renderedBodyHtml: options.renderedParsed.html,
179
214
  parsed: options.parsed,
180
215
  siteConfig: options.siteConfig,
@@ -196,6 +231,8 @@ async function renderStructuredPage(options) {
196
231
  return renderDocument({
197
232
  siteTitle: currentPage.siteTitle,
198
233
  siteDescription: currentPage.siteDescription,
234
+ locale: htmlLang,
235
+ messages,
199
236
  siteUrl: currentPage.siteUrl,
200
237
  favicon: currentPage.favicon,
201
238
  socialImage: currentPage.socialImage,
@@ -214,11 +251,15 @@ async function renderStructuredPage(options) {
214
251
  stylesheetContent: currentPage.stylesheetContent,
215
252
  canonicalPath: currentPage.canonicalPath,
216
253
  alternateMarkdownPath: currentPage.alternateMarkdownPath,
254
+ rssFeedUrl: getRssFeedUrl(currentPage.siteUrl, options.siteConfig, localeConfig),
217
255
  listingEntries: currentPage.listingEntries,
218
256
  listingRequestPath: currentPage.listingRequestPath,
219
257
  listingInitialPostCount: currentPage.listingInitialPostCount,
220
258
  listingLoadMoreStep: currentPage.listingLoadMoreStep,
221
259
  searchEnabled: currentPage.searchEnabled,
260
+ languages: currentPage.languages,
261
+ hreflangAlternates,
262
+ searchLocaleFilter,
222
263
  headerHtml,
223
264
  footerHtml,
224
265
  });
@@ -281,6 +322,69 @@ function notFound() {
281
322
  body: 'Not Found',
282
323
  };
283
324
  }
325
+ async function renderNotFoundForRequest(store, pathname, options) {
326
+ const resolved = resolveRequest(pathname);
327
+ return renderNotFoundForResolvedRequest(store, resolved, options, false, matchRequestLocale(pathname, options.siteConfig.locales));
328
+ }
329
+ async function renderNotFoundForResolvedRequest(store, resolved, options, negotiatedMarkdown, requestLocale) {
330
+ const varyOnAccept = shouldVaryOnAccept(resolved);
331
+ if (resolved.kind !== 'html' || negotiatedMarkdown) {
332
+ return withNotFoundVary(varyOnAccept);
333
+ }
334
+ return renderHtmlNotFound(store, resolved.requestPath, options, varyOnAccept, requestLocale);
335
+ }
336
+ async function renderHtmlNotFound(store, requestPath, options, varyOnAccept, requestLocale) {
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);
342
+ const body = [
343
+ `<h1>${escapeHtml(messages['error.notFoundTitle'])}</h1>`,
344
+ `<p>${formatSiteMessage(messages['error.notFoundBody'], {
345
+ path: escapeHtml(requestPath),
346
+ })}</p>`,
347
+ ].join('');
348
+ return {
349
+ status: 404,
350
+ headers: withVaryAcceptIfNeeded({
351
+ 'content-type': 'text/html; charset=utf-8',
352
+ }, varyOnAccept),
353
+ body: renderDocument({
354
+ siteTitle: options.siteConfig.siteTitle,
355
+ siteDescription: options.siteConfig.siteDescription,
356
+ siteUrl: options.siteConfig.siteUrl,
357
+ favicon: options.siteConfig.favicon,
358
+ socialImage: options.siteConfig.socialImage,
359
+ logo: options.siteConfig.logo,
360
+ title: messages['error.notFoundTitle'],
361
+ body,
362
+ locale,
363
+ messages,
364
+ languages,
365
+ showSummary: false,
366
+ showDate: false,
367
+ topNav: navigation.items,
368
+ footerNav: options.siteConfig.footerNav,
369
+ footerText: options.siteConfig.footerText,
370
+ socialLinks: options.siteConfig.socialLinks,
371
+ stylesheetContent: options.siteConfig.stylesheetContent,
372
+ rssFeedUrl: getRssFeedUrl(options.siteConfig.siteUrl, options.siteConfig, localeConfig),
373
+ searchEnabled: options.searchApi !== undefined,
374
+ }),
375
+ };
376
+ }
377
+ function withNotFoundVary(varyOnAccept) {
378
+ if (!varyOnAccept) {
379
+ return notFound();
380
+ }
381
+ return {
382
+ ...notFound(),
383
+ headers: withVaryAcceptIfNeeded({
384
+ 'content-type': 'text/plain; charset=utf-8',
385
+ }, true),
386
+ };
387
+ }
284
388
  function redirect(location) {
285
389
  return {
286
390
  status: 308,
@@ -289,6 +393,127 @@ function redirect(location) {
289
393
  },
290
394
  };
291
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
+ }
292
517
  async function renderSitemap(store, options) {
293
518
  if (!options.siteConfig.siteUrl) {
294
519
  return {
@@ -317,6 +542,62 @@ async function renderSitemap(store, options) {
317
542
  body,
318
543
  };
319
544
  }
545
+ async function renderRssFeed(store, options, locale) {
546
+ if (!isRssEnabled(options.siteConfig) || !options.siteConfig.siteUrl) {
547
+ return notFound();
548
+ }
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);
553
+ const limitedItems = items.slice(0, options.siteConfig.rss?.maxItems ?? 20);
554
+ const rssFeedUrl = getRssFeedUrl(options.siteConfig.siteUrl, options.siteConfig, locale);
555
+ const title = options.siteConfig.rss?.title ?? options.siteConfig.siteTitle;
556
+ const description = options.siteConfig.rss?.description ?? options.siteConfig.siteDescription;
557
+ const lastBuildDate = limitedItems[0]?.pubDate.toUTCString();
558
+ const body = [
559
+ '<?xml version="1.0" encoding="UTF-8"?>',
560
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
561
+ '<channel>',
562
+ ` <title>${escapeHtml(title)}</title>`,
563
+ ` <link>${escapeHtml(options.siteConfig.siteUrl)}</link>`,
564
+ description
565
+ ? ` <description>${escapeHtml(description)}</description>`
566
+ : ' <description></description>',
567
+ ' <generator>mdorigin</generator>',
568
+ rssFeedUrl
569
+ ? ` <atom:link href="${escapeHtml(rssFeedUrl)}" rel="self" type="application/rss+xml" />`
570
+ : '',
571
+ options.siteConfig.rss?.author
572
+ ? ` <managingEditor>${escapeHtml(options.siteConfig.rss.author)}</managingEditor>`
573
+ : '',
574
+ lastBuildDate ? ` <lastBuildDate>${escapeHtml(lastBuildDate)}</lastBuildDate>` : '',
575
+ ...limitedItems.map((item) => [
576
+ ' <item>',
577
+ ` <title>${escapeHtml(item.title)}</title>`,
578
+ ` <link>${escapeHtml(item.absoluteUrl)}</link>`,
579
+ ` <guid isPermaLink="true">${escapeHtml(item.absoluteUrl)}</guid>`,
580
+ ` <pubDate>${escapeHtml(item.pubDate.toUTCString())}</pubDate>`,
581
+ item.summary
582
+ ? ` <description>${escapeHtml(item.summary)}</description>`
583
+ : '',
584
+ ' </item>',
585
+ ]
586
+ .filter((line) => line !== '')
587
+ .join('\n')),
588
+ '</channel>',
589
+ '</rss>',
590
+ ]
591
+ .filter((line) => line !== '')
592
+ .join('\n');
593
+ return {
594
+ status: 200,
595
+ headers: {
596
+ 'content-type': 'application/rss+xml; charset=utf-8',
597
+ },
598
+ body,
599
+ };
600
+ }
320
601
  function withVaryAcceptIfNeeded(headers, enabled) {
321
602
  if (!enabled) {
322
603
  return headers;
@@ -387,7 +668,78 @@ function dedupeSitemapEntries(entries) {
387
668
  }
388
669
  return Array.from(deduped.values());
389
670
  }
390
- async function renderDirectoryListing(store, requestPath, siteConfig, searchEnabled) {
671
+ async function collectRssFeedItems(store, directoryPath, options, excludedDirectories) {
672
+ const entries = await store.listDirectory(directoryPath);
673
+ if (entries === null) {
674
+ return [];
675
+ }
676
+ const feedItems = [];
677
+ const directoryShape = inspectDirectoryShapeEntries(entries);
678
+ for (const entry of entries) {
679
+ if (entry.kind === 'directory') {
680
+ if (excludedDirectories.has(entry.path)) {
681
+ continue;
682
+ }
683
+ feedItems.push(...(await collectRssFeedItems(store, entry.path, options, excludedDirectories)));
684
+ continue;
685
+ }
686
+ if (!isMarkdownEntry(entry)) {
687
+ continue;
688
+ }
689
+ const document = await store.get(entry.path);
690
+ if (document === null || document.kind !== 'text' || document.text === undefined) {
691
+ continue;
692
+ }
693
+ const parsed = await parseMarkdownDocument(entry.path, document.text);
694
+ if (parsed.meta.draft === true && options.draftMode === 'exclude') {
695
+ continue;
696
+ }
697
+ const pubDate = parseFeedDate(parsed.meta.date);
698
+ if (pubDate === null) {
699
+ continue;
700
+ }
701
+ const contentType = inferFeedContentType(entry.path, parsed.meta, directoryShape);
702
+ if (contentType !== 'post') {
703
+ continue;
704
+ }
705
+ const canonicalPath = getCanonicalHtmlPathForContentPath(entry.path);
706
+ feedItems.push({
707
+ title: getDocumentTitle(parsed),
708
+ canonicalPath,
709
+ absoluteUrl: new URL(trimLeadingSlash(canonicalPath), ensureTrailingSlash(options.siteConfig.siteUrl ?? '')).toString(),
710
+ summary: getFeedSummary(parsed),
711
+ pubDate,
712
+ });
713
+ }
714
+ feedItems.sort((left, right) => {
715
+ const timeDelta = right.pubDate.getTime() - left.pubDate.getTime();
716
+ return timeDelta !== 0
717
+ ? timeDelta
718
+ : left.canonicalPath.localeCompare(right.canonicalPath);
719
+ });
720
+ return feedItems;
721
+ }
722
+ function inferFeedContentType(contentPath, meta, directoryShape) {
723
+ const explicitType = resolveContentType(meta);
724
+ if (explicitType) {
725
+ return explicitType;
726
+ }
727
+ if (isDirectoryIndexContentPath(contentPath)) {
728
+ return inferDirectoryContentType(meta, directoryShape);
729
+ }
730
+ return typeof meta.date === 'string' && meta.date !== '' ? 'post' : 'page';
731
+ }
732
+ function getFeedSummary(parsed) {
733
+ return getDocumentSummary(parsed.meta, stripMachineOnlyMarkdownComments(stripManagedIndexBlock(parsed.body)));
734
+ }
735
+ function parseFeedDate(value) {
736
+ if (typeof value !== 'string' || value.trim() === '') {
737
+ return null;
738
+ }
739
+ const parsed = new Date(value);
740
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
741
+ }
742
+ async function renderDirectoryListing(store, requestPath, siteConfig, searchEnabled, requestLocale) {
391
743
  const directoryPath = requestPath === '/' ? '' : requestPath.slice(1).replace(/\/$/, '');
392
744
  const entries = await store.listDirectory(directoryPath);
393
745
  if (entries === null) {
@@ -395,12 +747,17 @@ async function renderDirectoryListing(store, requestPath, siteConfig, searchEnab
395
747
  }
396
748
  const visibleEntries = entries.filter(isVisibleDirectoryEntry);
397
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');
398
753
  const listItems = visibleEntries
399
754
  .map((entry) => `<li><a href="${getDirectoryEntryHref(requestPath, entry)}">${escapeHtml(getDirectoryEntryLabel(entry))}</a></li>`)
400
755
  .join('');
401
756
  const body = [
402
757
  `<h1>${escapeHtml(getDirectoryTitle(requestPath))}</h1>`,
403
- 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>`,
404
761
  ].join('');
405
762
  return {
406
763
  status: 200,
@@ -410,6 +767,9 @@ async function renderDirectoryListing(store, requestPath, siteConfig, searchEnab
410
767
  body: renderDocument({
411
768
  siteTitle: siteConfig.siteTitle,
412
769
  siteDescription: siteConfig.siteDescription,
770
+ locale,
771
+ messages,
772
+ languages,
413
773
  siteUrl: siteConfig.siteUrl,
414
774
  favicon: siteConfig.favicon,
415
775
  logo: siteConfig.logo,
@@ -424,6 +784,7 @@ async function renderDirectoryListing(store, requestPath, siteConfig, searchEnab
424
784
  stylesheetContent: siteConfig.stylesheetContent,
425
785
  canonicalPath: requestPath,
426
786
  alternateMarkdownPath: getMarkdownRequestPathForContentPath(getDirectoryIndexContentPathForRequestPath(requestPath)),
787
+ rssFeedUrl: getRssFeedUrl(siteConfig.siteUrl, siteConfig, requestLocale),
427
788
  searchEnabled,
428
789
  }),
429
790
  };
@@ -499,6 +860,9 @@ async function tryRenderAlternateDirectoryIndex(store, requestPath, options) {
499
860
  listingEntries,
500
861
  searchEnabled: options.searchApi !== undefined,
501
862
  plugins,
863
+ store,
864
+ requestLocale: matchRequestLocale(requestPath, options.siteConfig.locales),
865
+ draftMode: options.draftMode,
502
866
  });
503
867
  }
504
868
  return null;
@@ -558,6 +922,23 @@ async function tryRedirectAlternateDirectoryMarkdown(store, resolved, options) {
558
922
  }
559
923
  return null;
560
924
  }
925
+ async function tryRedirectCanonicalDirectoryPath(store, resolved) {
926
+ if (resolved.kind !== 'html' || resolved.requestPath.endsWith('/')) {
927
+ return null;
928
+ }
929
+ if (path.posix.extname(resolved.requestPath) !== '') {
930
+ return null;
931
+ }
932
+ const directoryPath = resolved.requestPath.slice(1);
933
+ if (directoryPath === '') {
934
+ return null;
935
+ }
936
+ const directoryEntries = await store.listDirectory(directoryPath);
937
+ if (directoryEntries === null) {
938
+ return null;
939
+ }
940
+ return redirect(`${resolved.requestPath}/`);
941
+ }
561
942
  function getMarkdownRequestPathForContentPath(contentPath) {
562
943
  return `/${contentPath}`;
563
944
  }
@@ -628,6 +1009,10 @@ async function findAliasRedirectLocation(store, directoryPath, requestPath, opti
628
1009
  function isMarkdownEntry(entry) {
629
1010
  return path.posix.extname(entry.name).toLowerCase() === '.md';
630
1011
  }
1012
+ function isDirectoryIndexContentPath(contentPath) {
1013
+ const basename = path.posix.basename(contentPath).toLowerCase();
1014
+ return basename === 'index.md' || basename === 'readme.md' || basename === 'skill.md';
1015
+ }
631
1016
  function normalizeAliases(aliases) {
632
1017
  if (!Array.isArray(aliases)) {
633
1018
  return [];
@@ -642,12 +1027,26 @@ function normalizeAliases(aliases) {
642
1027
  }
643
1028
  function getCanonicalHtmlPathForContentPath(contentPath) {
644
1029
  const basename = path.posix.basename(contentPath).toLowerCase();
645
- if (basename === 'index.md' || basename === 'readme.md') {
1030
+ if (basename === 'index.md' ||
1031
+ basename === 'readme.md' ||
1032
+ basename === 'skill.md') {
646
1033
  const directory = path.posix.dirname(contentPath);
647
1034
  return directory === '.' ? '/' : `/${directory}/`;
648
1035
  }
649
1036
  return `/${contentPath.slice(0, -'.md'.length)}`;
650
1037
  }
1038
+ function isRssEnabled(siteConfig) {
1039
+ return Boolean(siteConfig.siteUrl) && (siteConfig.rss?.enabled ?? true);
1040
+ }
1041
+ function getRssFeedUrl(siteUrl, siteConfig, locale) {
1042
+ if (!siteUrl || !isRssEnabled(siteConfig)) {
1043
+ return undefined;
1044
+ }
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();
1049
+ }
651
1050
  function getEditLinkHref(siteConfig, sourcePath) {
652
1051
  if (!siteConfig.editLink || !sourcePath) {
653
1052
  return undefined;
@@ -669,9 +1068,15 @@ async function resolveTopNav(store, siteConfig) {
669
1068
  };
670
1069
  }
671
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));
672
1074
  const navItems = [];
673
1075
  const orderedNavItems = [];
674
1076
  for (const entry of directories) {
1077
+ if (localeDirectoryNames.has(entry.name)) {
1078
+ continue;
1079
+ }
675
1080
  const resolved = await resolveDirectoryNav(store, entry);
676
1081
  if (resolved.type !== 'page') {
677
1082
  continue;
@@ -731,6 +1136,9 @@ async function inspectDirectoryShape(store, directoryPath) {
731
1136
  hasAssetFiles: false,
732
1137
  };
733
1138
  }
1139
+ return inspectDirectoryShapeEntries(entries);
1140
+ }
1141
+ function inspectDirectoryShapeEntries(entries) {
734
1142
  let hasSkillIndex = false;
735
1143
  let hasChildDirectories = false;
736
1144
  let hasExtraMarkdownFiles = false;
@@ -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
+ }