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 +4 -4
- package/dist/adapters/cloudflare.js +2 -0
- package/dist/cli/build-index.js +3 -0
- package/dist/core/extensions.d.ts +13 -0
- package/dist/core/request-handler.js +214 -26
- package/dist/core/router.d.ts +9 -0
- package/dist/core/router.js +26 -0
- package/dist/core/site-config.d.ts +38 -0
- package/dist/core/site-config.js +96 -0
- package/dist/html/template.d.ts +16 -0
- package/dist/html/template.js +72 -33
- package/dist/html/theme.js +29 -0
- package/dist/i18n/messages.d.ts +45 -0
- package/dist/i18n/messages.js +104 -0
- package/dist/index-builder.d.ts +3 -1
- package/dist/index-builder.js +8 -2
- package/package.json +4 -4
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/
|
|
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/
|
|
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.
|
|
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.
|
|
139
|
+
The docs site at <https://mdorigin.holon.run> is deployed automatically from `main` with GitHub Actions.
|
package/dist/cli/build-index.js
CHANGED
|
@@ -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
|
-
'
|
|
308
|
-
`<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: '
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/core/router.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/router.js
CHANGED
|
@@ -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>;
|