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/dist/core/site-config.js
CHANGED
|
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
4
4
|
import { tsImport } from 'tsx/esm/api';
|
|
5
5
|
import { getDirectoryIndexCandidates } from './directory-index.js';
|
|
6
6
|
import { parseMarkdownDocument } from './markdown.js';
|
|
7
|
+
import { DEFAULT_SITE_LOCALE, validateSiteMessages, } from '../i18n/messages.js';
|
|
7
8
|
export async function loadSiteConfig(options = {}) {
|
|
8
9
|
return (await loadUserSiteConfig(options)).siteConfig;
|
|
9
10
|
}
|
|
@@ -15,6 +16,8 @@ export async function loadUserSiteConfig(options = {}) {
|
|
|
15
16
|
: await resolveDefaultConfigPath(cwd, rootDir);
|
|
16
17
|
const parsedConfig = await loadConfigSource(configFilePath);
|
|
17
18
|
const legacyConfig = parsedConfig;
|
|
19
|
+
const messageOverrides = resolveMessageOverrides(parsedConfig.messages, configFilePath);
|
|
20
|
+
const locales = resolveLocalesConfig(parsedConfig, configFilePath);
|
|
18
21
|
const stylesheetPath = parsedConfig.stylesheet
|
|
19
22
|
? path.resolve(path.dirname(configFilePath), parsedConfig.stylesheet)
|
|
20
23
|
: null;
|
|
@@ -29,6 +32,10 @@ export async function loadUserSiteConfig(options = {}) {
|
|
|
29
32
|
parsedConfig.siteDescription !== ''
|
|
30
33
|
? parsedConfig.siteDescription
|
|
31
34
|
: undefined,
|
|
35
|
+
locale: locales?.find((locale) => locale.isDefault)?.code ??
|
|
36
|
+
normalizeSiteLocale(parsedConfig.locale),
|
|
37
|
+
messages: messageOverrides,
|
|
38
|
+
locales,
|
|
32
39
|
siteUrl: normalizeSiteUrl(parsedConfig.siteUrl),
|
|
33
40
|
favicon: normalizeSiteHref(parsedConfig.favicon),
|
|
34
41
|
socialImage: normalizeSiteHref(parsedConfig.socialImage),
|
|
@@ -88,6 +95,95 @@ export async function applySiteConfigFrontmatterDefaults(store, siteConfig) {
|
|
|
88
95
|
}
|
|
89
96
|
return siteConfig;
|
|
90
97
|
}
|
|
98
|
+
function resolveMessageOverrides(value, configFilePath) {
|
|
99
|
+
if (value === undefined) {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
const { messages, unknownKeys } = validateSiteMessages(value);
|
|
103
|
+
if (unknownKeys.length > 0) {
|
|
104
|
+
throw new Error(`[mdorigin] ${configFilePath}: "messages" contains unknown keys: ${unknownKeys.join(', ')}`);
|
|
105
|
+
}
|
|
106
|
+
return messages;
|
|
107
|
+
}
|
|
108
|
+
function normalizeSiteLocale(value) {
|
|
109
|
+
if (typeof value !== 'string') {
|
|
110
|
+
return DEFAULT_SITE_LOCALE;
|
|
111
|
+
}
|
|
112
|
+
const trimmed = value.trim();
|
|
113
|
+
return trimmed === '' ? DEFAULT_SITE_LOCALE : trimmed;
|
|
114
|
+
}
|
|
115
|
+
const LOCALE_CODE_PATTERN = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/;
|
|
116
|
+
function resolveLocalesConfig(parsedConfig, configFilePath) {
|
|
117
|
+
const input = parsedConfig.locales;
|
|
118
|
+
if (input === undefined) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
122
|
+
throw new Error(`[mdorigin] ${configFilePath}: "locales" must be a non-empty array when configured`);
|
|
123
|
+
}
|
|
124
|
+
const seenCodes = new Set();
|
|
125
|
+
const parsed = [];
|
|
126
|
+
for (const entry of input) {
|
|
127
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
128
|
+
throw new Error(`[mdorigin] ${configFilePath}: every "locales" entry must be an object`);
|
|
129
|
+
}
|
|
130
|
+
const locale = entry;
|
|
131
|
+
const code = typeof locale.code === 'string' ? locale.code.trim() : '';
|
|
132
|
+
if (code === '' || !LOCALE_CODE_PATTERN.test(code)) {
|
|
133
|
+
throw new Error(`[mdorigin] ${configFilePath}: "locales[].code" must be a BCP 47 style code like "en" or "zh-CN"`);
|
|
134
|
+
}
|
|
135
|
+
const normalizedCodeKey = code.toLowerCase();
|
|
136
|
+
if (seenCodes.has(normalizedCodeKey)) {
|
|
137
|
+
throw new Error(`[mdorigin] ${configFilePath}: duplicate "locales" code: ${code}`);
|
|
138
|
+
}
|
|
139
|
+
seenCodes.add(normalizedCodeKey);
|
|
140
|
+
const isDefault = locale.default === true;
|
|
141
|
+
const pathPrefix = resolveLocalePathPrefix(locale, isDefault, configFilePath);
|
|
142
|
+
const { messages, unknownKeys } = validateSiteMessages(locale.messages);
|
|
143
|
+
if (unknownKeys.length > 0) {
|
|
144
|
+
throw new Error(`[mdorigin] ${configFilePath}: "locales[${code}].messages" contains unknown keys: ${unknownKeys.join(', ')}`);
|
|
145
|
+
}
|
|
146
|
+
parsed.push({
|
|
147
|
+
code,
|
|
148
|
+
label: typeof locale.label === 'string' && locale.label.trim() !== ''
|
|
149
|
+
? locale.label.trim()
|
|
150
|
+
: code,
|
|
151
|
+
pathPrefix,
|
|
152
|
+
isDefault,
|
|
153
|
+
messages,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const defaultCount = parsed.filter((locale) => locale.isDefault).length;
|
|
157
|
+
if (defaultCount !== 1) {
|
|
158
|
+
throw new Error(`[mdorigin] ${configFilePath}: "locales" must mark exactly one locale with "default": true (found ${defaultCount})`);
|
|
159
|
+
}
|
|
160
|
+
const defaultLocale = parsed.find((locale) => locale.isDefault);
|
|
161
|
+
const configuredLocale = typeof parsedConfig.locale === 'string' ? parsedConfig.locale.trim() : '';
|
|
162
|
+
if (configuredLocale !== '' &&
|
|
163
|
+
configuredLocale.toLowerCase() !== defaultLocale?.code.toLowerCase()) {
|
|
164
|
+
throw new Error(`[mdorigin] ${configFilePath}: "locale" (${configuredLocale}) conflicts with the default "locales" entry (${defaultLocale?.code}); remove "locale" or align it with the default locale`);
|
|
165
|
+
}
|
|
166
|
+
return parsed.map((locale) => ({
|
|
167
|
+
...locale,
|
|
168
|
+
contentBase: locale.pathPrefix === '' ? '' : locale.code,
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
function resolveLocalePathPrefix(locale, isDefault, configFilePath) {
|
|
172
|
+
if (locale.pathPrefix === undefined) {
|
|
173
|
+
return isDefault ? '' : `/${locale.code}`;
|
|
174
|
+
}
|
|
175
|
+
const prefix = locale.pathPrefix;
|
|
176
|
+
if (prefix === '') {
|
|
177
|
+
if (!isDefault) {
|
|
178
|
+
throw new Error(`[mdorigin] ${configFilePath}: "locales[${locale.code}].pathPrefix" can only be empty for the default locale`);
|
|
179
|
+
}
|
|
180
|
+
return '';
|
|
181
|
+
}
|
|
182
|
+
if (prefix !== `/${locale.code}`) {
|
|
183
|
+
throw new Error(`[mdorigin] ${configFilePath}: "locales[${locale.code}].pathPrefix" must be "/${locale.code}" or "" (default locales only); got "${prefix}"`);
|
|
184
|
+
}
|
|
185
|
+
return prefix;
|
|
186
|
+
}
|
|
91
187
|
async function resolveDefaultConfigPath(cwd, rootDir) {
|
|
92
188
|
const rootConfigPath = rootDir ? await findConfigPath(rootDir) : null;
|
|
93
189
|
if (rootConfigPath) {
|
package/dist/html/template.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { SiteLogo, SiteNavItem, SiteSocialLink } from '../core/site-config.js';
|
|
2
|
+
import type { PageLanguage } from '../core/extensions.js';
|
|
2
3
|
import type { ManagedIndexEntry } from '../core/markdown.js';
|
|
4
|
+
import { type SiteMessages } from '../i18n/messages.js';
|
|
3
5
|
export interface RenderDocumentOptions {
|
|
4
6
|
siteTitle: string;
|
|
5
7
|
siteDescription?: string;
|
|
8
|
+
locale?: string;
|
|
9
|
+
messages?: SiteMessages;
|
|
6
10
|
siteUrl?: string;
|
|
7
11
|
favicon?: string;
|
|
8
12
|
socialImage?: string;
|
|
@@ -27,6 +31,18 @@ export interface RenderDocumentOptions {
|
|
|
27
31
|
listingInitialPostCount?: number;
|
|
28
32
|
listingLoadMoreStep?: number;
|
|
29
33
|
searchEnabled?: boolean;
|
|
34
|
+
/** Language switcher entries; rendered in the header when more than one is present. */
|
|
35
|
+
languages?: PageLanguage[];
|
|
36
|
+
/** hreflang alternates with absolute URLs, including x-default when applicable. */
|
|
37
|
+
hreflangAlternates?: Array<{
|
|
38
|
+
hreflang: string;
|
|
39
|
+
href: string;
|
|
40
|
+
}>;
|
|
41
|
+
/** Locale content filter handed to the search client script. */
|
|
42
|
+
searchLocaleFilter?: {
|
|
43
|
+
include: string;
|
|
44
|
+
exclude: string[];
|
|
45
|
+
};
|
|
30
46
|
headerHtml?: string;
|
|
31
47
|
footerHtml?: string;
|
|
32
48
|
}
|
package/dist/html/template.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { DEFAULT_SITE_LOCALE, resolveSiteMessages, serializeMessagesForScript, } from '../i18n/messages.js';
|
|
1
2
|
import { getDefaultThemeStyles } from './theme.js';
|
|
2
3
|
export function renderDocument(options) {
|
|
4
|
+
const locale = options.locale ?? DEFAULT_SITE_LOCALE;
|
|
5
|
+
const messages = options.messages ?? resolveSiteMessages(locale);
|
|
3
6
|
const title = escapeHtml(options.title);
|
|
4
7
|
const siteTitle = escapeHtml(options.siteTitle);
|
|
5
8
|
const siteDescription = options.siteDescription
|
|
@@ -28,6 +31,11 @@ export function renderDocument(options) {
|
|
|
28
31
|
const rssMeta = options.rssFeedUrl
|
|
29
32
|
? `<link rel="alternate" type="application/rss+xml" title="${siteTitle}" href="${escapeHtml(options.rssFeedUrl)}">`
|
|
30
33
|
: '';
|
|
34
|
+
const hreflangMeta = options.hreflangAlternates && options.hreflangAlternates.length > 0
|
|
35
|
+
? options.hreflangAlternates
|
|
36
|
+
.map((alternate) => `<link rel="alternate" hreflang="${escapeHtml(alternate.hreflang)}" href="${escapeHtml(alternate.href)}">`)
|
|
37
|
+
.join('')
|
|
38
|
+
: '';
|
|
31
39
|
const stylesheetBlock = `<style>${getDefaultThemeStyles()}${options.stylesheetContent ? `\n${options.stylesheetContent}` : ''}</style>`;
|
|
32
40
|
const navBlock = options.topNav && options.topNav.length > 0
|
|
33
41
|
? `<nav class="site-nav"><ul>${options.topNav
|
|
@@ -37,21 +45,26 @@ export function renderDocument(options) {
|
|
|
37
45
|
const searchToggleBlock = options.searchEnabled
|
|
38
46
|
? [
|
|
39
47
|
'<div class="site-search" data-site-search>',
|
|
40
|
-
|
|
48
|
+
`<button type="button" class="site-search__toggle" aria-expanded="false" aria-controls="site-search-panel">${escapeHtml(messages['search.toggle'])}</button>`,
|
|
41
49
|
'<div id="site-search-panel" class="site-search__panel" hidden>',
|
|
42
50
|
'<form class="site-search__form" role="search" action="/api/search" method="get">',
|
|
43
|
-
|
|
51
|
+
`<label class="site-search__label" for="site-search-input">${escapeHtml(messages['search.label'])}</label>`,
|
|
44
52
|
'<div class="site-search__controls">',
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
`<input id="site-search-input" class="site-search__input" type="search" name="q" placeholder="${escapeHtml(messages['search.placeholder'])}" autocomplete="off">`,
|
|
54
|
+
`<button type="submit" class="site-search__submit">${escapeHtml(messages['search.go'])}</button>`,
|
|
47
55
|
'</div>',
|
|
48
|
-
|
|
56
|
+
`<p class="site-search__hint">${messages['search.hint']}</p>`,
|
|
49
57
|
'</form>',
|
|
50
58
|
'<div class="site-search__results" data-site-search-results></div>',
|
|
51
59
|
'</div>',
|
|
52
60
|
'</div>',
|
|
53
61
|
].join('')
|
|
54
62
|
: '';
|
|
63
|
+
const languagesBlock = options.languages && options.languages.length > 1
|
|
64
|
+
? `<nav class="site-languages" aria-label="${escapeHtml(messages['languages.ariaLabel'])}"><ul>${options.languages
|
|
65
|
+
.map((language) => `<li><a href="${escapeHtml(language.href)}" hreflang="${escapeHtml(language.code)}"${language.current ? ' aria-current="page"' : ''}>${escapeHtml(language.label)}</a></li>`)
|
|
66
|
+
.join('')}</ul></nav>`
|
|
67
|
+
: '';
|
|
55
68
|
const footerNavBlock = options.footerNav && options.footerNav.length > 0
|
|
56
69
|
? `<nav class="site-footer__nav"><ul>${options.footerNav
|
|
57
70
|
.map((item) => `<li><a href="${escapeHtml(item.href)}">${escapeHtml(item.label)}</a></li>`)
|
|
@@ -70,10 +83,10 @@ export function renderDocument(options) {
|
|
|
70
83
|
: '';
|
|
71
84
|
const brandHref = escapeHtml(options.logo?.href ?? '/');
|
|
72
85
|
const editLinkBlock = options.editLinkHref
|
|
73
|
-
? `<a class="site-footer__edit-link" href="${escapeHtml(options.editLinkHref)}"
|
|
86
|
+
? `<a class="site-footer__edit-link" href="${escapeHtml(options.editLinkHref)}">${escapeHtml(messages['footer.editPage'])}</a>`
|
|
74
87
|
: '';
|
|
75
88
|
const markdownViewBlock = options.alternateMarkdownPath
|
|
76
|
-
? `<a class="site-footer__markdown-link" href="${escapeHtml(options.alternateMarkdownPath)}" aria-label="
|
|
89
|
+
? `<a class="site-footer__markdown-link" href="${escapeHtml(options.alternateMarkdownPath)}" aria-label="${escapeHtml(messages['footer.markdownViewAria'])}">${escapeHtml(messages['footer.markdownView'])}</a>`
|
|
77
90
|
: '';
|
|
78
91
|
const footerActionsBlock = markdownViewBlock || editLinkBlock
|
|
79
92
|
? `<div class="site-footer__actions">${markdownViewBlock}${editLinkBlock}</div>`
|
|
@@ -92,15 +105,17 @@ export function renderDocument(options) {
|
|
|
92
105
|
requestPath: options.listingRequestPath ?? '/',
|
|
93
106
|
initialPostCount: options.listingInitialPostCount ?? 10,
|
|
94
107
|
loadMoreStep: options.listingLoadMoreStep ?? 10,
|
|
95
|
-
})
|
|
108
|
+
}, messages)
|
|
96
109
|
: options.body;
|
|
97
|
-
const searchScript = options.searchEnabled
|
|
110
|
+
const searchScript = options.searchEnabled
|
|
111
|
+
? renderSearchScript(messages, options.searchLocaleFilter)
|
|
112
|
+
: '';
|
|
98
113
|
const headerBlock = options.headerHtml ??
|
|
99
|
-
`<header class="site-header"><div class="site-header__inner"><div class="site-header__brand"><p class="site-header__title"><a href="${brandHref}">${logoBlock}<span>${siteTitle}</span></a></p>${siteDescriptionBlock}</div><div class="site-header__actions">${navBlock}${searchToggleBlock}</div></div></header>`;
|
|
114
|
+
`<header class="site-header"><div class="site-header__inner"><div class="site-header__brand"><p class="site-header__title"><a href="${brandHref}">${logoBlock}<span>${siteTitle}</span></a></p>${siteDescriptionBlock}</div><div class="site-header__actions">${navBlock}${languagesBlock}${searchToggleBlock}</div></div></header>`;
|
|
100
115
|
const renderedFooterBlock = options.footerHtml ?? footerBlock;
|
|
101
116
|
return [
|
|
102
117
|
'<!doctype html>',
|
|
103
|
-
|
|
118
|
+
`<html lang="${escapeHtml(locale)}">`,
|
|
104
119
|
'<head>',
|
|
105
120
|
'<meta charset="utf-8">',
|
|
106
121
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
@@ -111,6 +126,7 @@ export function renderDocument(options) {
|
|
|
111
126
|
socialImageMeta,
|
|
112
127
|
alternateMarkdownMeta,
|
|
113
128
|
rssMeta,
|
|
129
|
+
hreflangMeta,
|
|
114
130
|
stylesheetBlock,
|
|
115
131
|
'</head>',
|
|
116
132
|
'<body>',
|
|
@@ -163,7 +179,7 @@ function renderSocialIcon(icon) {
|
|
|
163
179
|
function iconSvg(pathData) {
|
|
164
180
|
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${pathData}"></path></svg>`;
|
|
165
181
|
}
|
|
166
|
-
function renderListingArticle(body, entries, options) {
|
|
182
|
+
function renderListingArticle(body, entries, options, messages) {
|
|
167
183
|
if (entries.length === 0) {
|
|
168
184
|
return body;
|
|
169
185
|
}
|
|
@@ -174,26 +190,26 @@ function renderListingArticle(body, entries, options) {
|
|
|
174
190
|
const shouldLoadMore = articles.length > visibleArticles.length;
|
|
175
191
|
return [
|
|
176
192
|
`<div class="catalog-page__body">${body}</div>`,
|
|
177
|
-
|
|
178
|
-
directories.length > 0 ? renderListingDirectories(directories) : '',
|
|
193
|
+
`<section class="catalog-page" aria-label="${escapeHtml(messages['listing.ariaLabel'])}">`,
|
|
194
|
+
directories.length > 0 ? renderListingDirectories(directories, messages) : '',
|
|
179
195
|
articles.length > 0
|
|
180
196
|
? renderListingArticles(visibleArticles, {
|
|
181
197
|
requestPath: options.requestPath,
|
|
182
198
|
nextOffset: visibleArticles.length,
|
|
183
199
|
loadMoreStep: options.loadMoreStep,
|
|
184
200
|
hasMore: shouldLoadMore,
|
|
185
|
-
})
|
|
201
|
+
}, messages)
|
|
186
202
|
: '',
|
|
187
203
|
'</section>',
|
|
188
|
-
shouldLoadMore ? renderListingLoadMoreScript() : '',
|
|
204
|
+
shouldLoadMore ? renderListingLoadMoreScript(messages) : '',
|
|
189
205
|
].join('');
|
|
190
206
|
}
|
|
191
|
-
function renderListingDirectories(entries) {
|
|
207
|
+
function renderListingDirectories(entries, messages) {
|
|
192
208
|
return [
|
|
193
209
|
'<div class="catalog-list catalog-list--directories">',
|
|
194
210
|
...entries.map((entry) => `<a class="catalog-item catalog-item--directory" href="${escapeHtml(entry.href)}"><strong class="catalog-item__title">${escapeHtml(entry.title)}</strong>${entry.detail
|
|
195
211
|
? `<span class="catalog-item__detail">${escapeHtml(entry.detail)}</span>`
|
|
196
|
-
:
|
|
212
|
+
: `<span class="catalog-item__detail">${escapeHtml(messages['listing.browseSection'])}</span>`}</a>`),
|
|
197
213
|
'</div>',
|
|
198
214
|
].join('');
|
|
199
215
|
}
|
|
@@ -204,17 +220,17 @@ export function renderListingArticleItems(entries) {
|
|
|
204
220
|
: ''}</a>`)
|
|
205
221
|
.join('');
|
|
206
222
|
}
|
|
207
|
-
function renderListingArticles(entries, options) {
|
|
223
|
+
function renderListingArticles(entries, options, messages) {
|
|
208
224
|
return [
|
|
209
225
|
'<div class="catalog-list" data-listing-articles>',
|
|
210
226
|
renderListingArticleItems(entries),
|
|
211
227
|
'</div>',
|
|
212
228
|
options.hasMore
|
|
213
|
-
? `<div class="catalog-load-more"><button type="button" class="catalog-load-more__button" data-listing-load-more data-request-path="${escapeHtml(options.requestPath)}" data-next-offset="${escapeHtml(String(options.nextOffset))}" data-load-more-step="${escapeHtml(String(options.loadMoreStep))}"
|
|
229
|
+
? `<div class="catalog-load-more"><button type="button" class="catalog-load-more__button" data-listing-load-more data-request-path="${escapeHtml(options.requestPath)}" data-next-offset="${escapeHtml(String(options.nextOffset))}" data-load-more-step="${escapeHtml(String(options.loadMoreStep))}">${escapeHtml(messages['listing.loadMore'])}</button></div>`
|
|
214
230
|
: '',
|
|
215
231
|
].join('');
|
|
216
232
|
}
|
|
217
|
-
function renderListingLoadMoreScript() {
|
|
233
|
+
function renderListingLoadMoreScript(messages) {
|
|
218
234
|
return `<script>
|
|
219
235
|
(() => {
|
|
220
236
|
const button = document.querySelector('[data-listing-load-more]');
|
|
@@ -223,6 +239,11 @@ function renderListingLoadMoreScript() {
|
|
|
223
239
|
return;
|
|
224
240
|
}
|
|
225
241
|
|
|
242
|
+
const M = ${serializeMessagesForScript({
|
|
243
|
+
loadMore: messages['listing.loadMore'],
|
|
244
|
+
loading: messages['listing.loading'],
|
|
245
|
+
})};
|
|
246
|
+
|
|
226
247
|
const loadMore = async () => {
|
|
227
248
|
const requestPath = button.dataset.requestPath;
|
|
228
249
|
const nextOffset = button.dataset.nextOffset;
|
|
@@ -233,7 +254,7 @@ function renderListingLoadMoreScript() {
|
|
|
233
254
|
|
|
234
255
|
button.disabled = true;
|
|
235
256
|
const previousLabel = button.textContent;
|
|
236
|
-
button.textContent =
|
|
257
|
+
button.textContent = M.loading;
|
|
237
258
|
|
|
238
259
|
try {
|
|
239
260
|
const url = new URL(requestPath, window.location.origin);
|
|
@@ -256,14 +277,14 @@ function renderListingLoadMoreScript() {
|
|
|
256
277
|
if (payload.hasMore === true && typeof payload.nextOffset === 'number') {
|
|
257
278
|
button.dataset.nextOffset = String(payload.nextOffset);
|
|
258
279
|
button.disabled = false;
|
|
259
|
-
button.textContent = previousLabel ??
|
|
280
|
+
button.textContent = previousLabel ?? M.loadMore;
|
|
260
281
|
return;
|
|
261
282
|
}
|
|
262
283
|
|
|
263
284
|
button.remove();
|
|
264
285
|
} catch {
|
|
265
286
|
button.disabled = false;
|
|
266
|
-
button.textContent = previousLabel ??
|
|
287
|
+
button.textContent = previousLabel ?? M.loadMore;
|
|
267
288
|
}
|
|
268
289
|
};
|
|
269
290
|
|
|
@@ -273,7 +294,7 @@ function renderListingLoadMoreScript() {
|
|
|
273
294
|
})();
|
|
274
295
|
</script>`;
|
|
275
296
|
}
|
|
276
|
-
function renderSearchScript() {
|
|
297
|
+
function renderSearchScript(messages, localeFilter) {
|
|
277
298
|
return [
|
|
278
299
|
'<script>',
|
|
279
300
|
'(function () {',
|
|
@@ -285,41 +306,59 @@ function renderSearchScript() {
|
|
|
285
306
|
' const input = root.querySelector(".site-search__input");',
|
|
286
307
|
' const results = root.querySelector("[data-site-search-results]");',
|
|
287
308
|
' if (!toggle || !panel || !form || !input || !results) return;',
|
|
309
|
+
` const M = ${serializeMessagesForScript({
|
|
310
|
+
resultsEmpty: messages['search.resultsEmpty'],
|
|
311
|
+
searching: messages['search.searching'],
|
|
312
|
+
failed: messages['search.failed'],
|
|
313
|
+
failedWithStatus: messages['search.failedWithStatus'],
|
|
314
|
+
enterQuery: messages['search.enterQuery'],
|
|
315
|
+
initialHint: messages['search.initialHint'],
|
|
316
|
+
untitled: messages['search.untitled'],
|
|
317
|
+
})};`,
|
|
318
|
+
` const F = ${localeFilter === undefined ? 'null' : serializeMessagesForScript(localeFilter)};`,
|
|
288
319
|
' let controller = null;',
|
|
289
320
|
' function renderMessage(message) {',
|
|
290
321
|
' results.innerHTML = `<p class="site-search__message">${escapeHtmlForScript(message)}</p>`;',
|
|
291
322
|
' }',
|
|
292
323
|
' function renderHits(hits) {',
|
|
293
324
|
' if (!Array.isArray(hits) || hits.length === 0) {',
|
|
294
|
-
' renderMessage(
|
|
325
|
+
' renderMessage(M.resultsEmpty);',
|
|
295
326
|
' return;',
|
|
296
327
|
' }',
|
|
297
328
|
' results.innerHTML = hits.map((hit) => {',
|
|
298
329
|
' const href = escapeHtmlForScript(hit.canonicalUrl || hit.docId || "#");',
|
|
299
|
-
' const title = escapeHtmlForScript(hit.title || hit.relativePath ||
|
|
330
|
+
' const title = escapeHtmlForScript(hit.title || hit.relativePath || M.untitled);',
|
|
300
331
|
' const summary = typeof hit.summary === "string" ? `<span class="site-search__item-summary">${escapeHtmlForScript(hit.summary)}</span>` : "";',
|
|
301
332
|
' const excerpt = hit.bestMatch && typeof hit.bestMatch.excerpt === "string" ? `<span class="site-search__item-excerpt">${escapeHtmlForScript(hit.bestMatch.excerpt)}</span>` : "";',
|
|
302
333
|
' return `<a class="site-search__item" href="${href}"><strong class="site-search__item-title">${title}</strong>${summary}${excerpt}</a>`;',
|
|
303
334
|
' }).join("");',
|
|
304
335
|
' }',
|
|
336
|
+
' function filterHits(hits) {',
|
|
337
|
+
' if (!F || !Array.isArray(hits)) return hits;',
|
|
338
|
+
' return hits.filter((hit) => {',
|
|
339
|
+
' if (typeof hit.relativePath !== "string") return false;',
|
|
340
|
+
' if (F.include !== "" && !hit.relativePath.startsWith(F.include)) return false;',
|
|
341
|
+
' return !F.exclude.some((prefix) => hit.relativePath.startsWith(prefix));',
|
|
342
|
+
' });',
|
|
343
|
+
' }',
|
|
305
344
|
' async function runSearch(query) {',
|
|
306
345
|
' if (controller) controller.abort();',
|
|
307
346
|
' controller = new AbortController();',
|
|
308
|
-
' renderMessage(
|
|
347
|
+
' renderMessage(M.searching);',
|
|
309
348
|
' try {',
|
|
310
349
|
' const url = new URL("/api/search", window.location.origin);',
|
|
311
350
|
' url.searchParams.set("q", query);',
|
|
312
351
|
' url.searchParams.set("topK", "8");',
|
|
313
352
|
' const response = await fetch(url, { signal: controller.signal });',
|
|
314
353
|
' if (!response.ok) {',
|
|
315
|
-
' renderMessage(
|
|
354
|
+
' renderMessage(M.failedWithStatus.replaceAll("{status}", String(response.status)));',
|
|
316
355
|
' return;',
|
|
317
356
|
' }',
|
|
318
357
|
' const payload = await response.json();',
|
|
319
|
-
' renderHits(payload.hits);',
|
|
358
|
+
' renderHits(filterHits(payload.hits));',
|
|
320
359
|
' } catch (error) {',
|
|
321
360
|
' if (error && typeof error === "object" && "name" in error && error.name === "AbortError") return;',
|
|
322
|
-
' renderMessage(
|
|
361
|
+
' renderMessage(M.failed);',
|
|
323
362
|
' }',
|
|
324
363
|
' }',
|
|
325
364
|
' function setOpen(open) {',
|
|
@@ -327,7 +366,7 @@ function renderSearchScript() {
|
|
|
327
366
|
' panel.hidden = !open;',
|
|
328
367
|
' if (open) {',
|
|
329
368
|
' input.focus();',
|
|
330
|
-
' if (!results.innerHTML) renderMessage(
|
|
369
|
+
' if (!results.innerHTML) renderMessage(M.initialHint);',
|
|
331
370
|
' }',
|
|
332
371
|
' }',
|
|
333
372
|
' toggle.addEventListener("click", () => setOpen(panel.hidden));',
|
|
@@ -335,7 +374,7 @@ function renderSearchScript() {
|
|
|
335
374
|
' event.preventDefault();',
|
|
336
375
|
' const query = input.value.trim();',
|
|
337
376
|
' if (!query) {',
|
|
338
|
-
' renderMessage(
|
|
377
|
+
' renderMessage(M.enterQuery);',
|
|
339
378
|
' return;',
|
|
340
379
|
' }',
|
|
341
380
|
' void runSearch(query);',
|
package/dist/html/theme.js
CHANGED
|
@@ -219,6 +219,35 @@ a:hover { color: var(--link-hover); text-decoration: underline; }
|
|
|
219
219
|
white-space: nowrap;
|
|
220
220
|
}
|
|
221
221
|
.site-nav a:hover { color: var(--link-hover); }
|
|
222
|
+
.site-languages ul {
|
|
223
|
+
list-style: none;
|
|
224
|
+
display: flex;
|
|
225
|
+
flex-wrap: wrap;
|
|
226
|
+
gap: 0.6rem;
|
|
227
|
+
margin: 0;
|
|
228
|
+
padding: 0;
|
|
229
|
+
align-items: center;
|
|
230
|
+
}
|
|
231
|
+
.site-languages li {
|
|
232
|
+
display: flex;
|
|
233
|
+
align-items: center;
|
|
234
|
+
}
|
|
235
|
+
.site-languages a {
|
|
236
|
+
color: var(--muted);
|
|
237
|
+
font-size: 0.85rem;
|
|
238
|
+
display: inline-flex;
|
|
239
|
+
align-items: center;
|
|
240
|
+
min-height: 2.25rem;
|
|
241
|
+
padding: 0 0.05rem;
|
|
242
|
+
white-space: nowrap;
|
|
243
|
+
}
|
|
244
|
+
.site-languages a:hover { color: var(--link-hover); }
|
|
245
|
+
.site-languages a[aria-current='page'] {
|
|
246
|
+
color: inherit;
|
|
247
|
+
font-weight: 700;
|
|
248
|
+
text-decoration: underline;
|
|
249
|
+
text-underline-offset: 0.25em;
|
|
250
|
+
}
|
|
222
251
|
main {
|
|
223
252
|
max-width: calc(var(--max) + 4rem);
|
|
224
253
|
margin: 0 auto;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface SiteMessages {
|
|
2
|
+
'search.toggle': string;
|
|
3
|
+
'search.label': string;
|
|
4
|
+
'search.placeholder': string;
|
|
5
|
+
'search.go': string;
|
|
6
|
+
'search.hint': string;
|
|
7
|
+
'search.resultsEmpty': string;
|
|
8
|
+
'search.searching': string;
|
|
9
|
+
'search.failed': string;
|
|
10
|
+
'search.failedWithStatus': string;
|
|
11
|
+
'search.enterQuery': string;
|
|
12
|
+
'search.initialHint': string;
|
|
13
|
+
'search.untitled': string;
|
|
14
|
+
'footer.editPage': string;
|
|
15
|
+
'footer.markdownView': string;
|
|
16
|
+
'footer.markdownViewAria': string;
|
|
17
|
+
'languages.ariaLabel': string;
|
|
18
|
+
'listing.browseSection': string;
|
|
19
|
+
'listing.loadMore': string;
|
|
20
|
+
'listing.loading': string;
|
|
21
|
+
'listing.ariaLabel': string;
|
|
22
|
+
'listing.emptyDirectory': string;
|
|
23
|
+
'error.notFoundTitle': string;
|
|
24
|
+
'error.notFoundBody': string;
|
|
25
|
+
}
|
|
26
|
+
export type SiteMessageKey = keyof SiteMessages;
|
|
27
|
+
export declare const DEFAULT_SITE_LOCALE = "en";
|
|
28
|
+
export declare function isKnownSiteLocale(locale: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the effective message set for a locale: built-in English catalog,
|
|
31
|
+
* overlaid with the built-in catalog for the locale (when available, unknown
|
|
32
|
+
* locales fall back to English), overlaid with user overrides.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveSiteMessages(locale: string | undefined, overrides?: Partial<SiteMessages>): SiteMessages;
|
|
35
|
+
export declare function formatSiteMessage(message: string, params: Record<string, string>): string;
|
|
36
|
+
/**
|
|
37
|
+
* Serializes messages for embedding inside an inline `<script>` element. `<`
|
|
38
|
+
* is escaped so that a message containing `</script>` cannot terminate the
|
|
39
|
+
* script block early.
|
|
40
|
+
*/
|
|
41
|
+
export declare function serializeMessagesForScript(value: Record<string, unknown>): string;
|
|
42
|
+
export declare function validateSiteMessages(value: unknown): {
|
|
43
|
+
messages: Partial<SiteMessages>;
|
|
44
|
+
unknownKeys: string[];
|
|
45
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export const DEFAULT_SITE_LOCALE = 'en';
|
|
2
|
+
/**
|
|
3
|
+
* Built-in message catalogs. Messages are trusted template fragments: they may
|
|
4
|
+
* contain inline HTML markup such as `<code>` and `{placeholder}` parameters.
|
|
5
|
+
*/
|
|
6
|
+
const enMessages = {
|
|
7
|
+
'search.toggle': 'Search',
|
|
8
|
+
'search.label': 'Search site',
|
|
9
|
+
'search.placeholder': 'Search docs and skills',
|
|
10
|
+
'search.go': 'Go',
|
|
11
|
+
'search.hint': 'Search is powered by <code>/api/search</code>.',
|
|
12
|
+
'search.resultsEmpty': 'No results.',
|
|
13
|
+
'search.searching': 'Searching...',
|
|
14
|
+
'search.failed': 'Search failed.',
|
|
15
|
+
'search.failedWithStatus': 'Search failed ({status}).',
|
|
16
|
+
'search.enterQuery': 'Enter a search query.',
|
|
17
|
+
'search.initialHint': 'Search docs, guides, and skills.',
|
|
18
|
+
'search.untitled': 'Untitled',
|
|
19
|
+
'footer.editPage': 'Edit this page',
|
|
20
|
+
'footer.markdownView': 'MD View',
|
|
21
|
+
'footer.markdownViewAria': 'View Markdown source',
|
|
22
|
+
'languages.ariaLabel': 'Languages',
|
|
23
|
+
'listing.browseSection': 'Browse this section.',
|
|
24
|
+
'listing.loadMore': 'Load more',
|
|
25
|
+
'listing.loading': 'Loading...',
|
|
26
|
+
'listing.ariaLabel': 'Content listing',
|
|
27
|
+
'listing.emptyDirectory': 'This directory is empty.',
|
|
28
|
+
'error.notFoundTitle': 'Not Found',
|
|
29
|
+
'error.notFoundBody': 'No page was published at <code>{path}</code>.',
|
|
30
|
+
};
|
|
31
|
+
const zhCnMessages = {
|
|
32
|
+
'search.toggle': '搜索',
|
|
33
|
+
'search.label': '搜索站点',
|
|
34
|
+
'search.placeholder': '搜索文档与技能',
|
|
35
|
+
'search.go': '搜索',
|
|
36
|
+
'search.hint': '搜索由 <code>/api/search</code> 提供支持。',
|
|
37
|
+
'search.resultsEmpty': '没有找到结果。',
|
|
38
|
+
'search.searching': '搜索中...',
|
|
39
|
+
'search.failed': '搜索失败。',
|
|
40
|
+
'search.failedWithStatus': '搜索失败({status})。',
|
|
41
|
+
'search.enterQuery': '请输入搜索关键词。',
|
|
42
|
+
'search.initialHint': '搜索文档、指南与技能。',
|
|
43
|
+
'search.untitled': '无标题',
|
|
44
|
+
'footer.editPage': '编辑此页',
|
|
45
|
+
'footer.markdownView': 'MD 视图',
|
|
46
|
+
'footer.markdownViewAria': '查看 Markdown 源文件',
|
|
47
|
+
'languages.ariaLabel': '语言',
|
|
48
|
+
'listing.browseSection': '浏览此章节。',
|
|
49
|
+
'listing.loadMore': '加载更多',
|
|
50
|
+
'listing.loading': '加载中...',
|
|
51
|
+
'listing.ariaLabel': '内容列表',
|
|
52
|
+
'listing.emptyDirectory': '此目录为空。',
|
|
53
|
+
'error.notFoundTitle': '未找到',
|
|
54
|
+
'error.notFoundBody': '没有页面发布在 <code>{path}</code>。',
|
|
55
|
+
};
|
|
56
|
+
const messageCatalogs = {
|
|
57
|
+
en: enMessages,
|
|
58
|
+
'zh-CN': zhCnMessages,
|
|
59
|
+
};
|
|
60
|
+
const catalogKeys = new Map(Object.keys(messageCatalogs).map((key) => [key.toLowerCase(), key]));
|
|
61
|
+
export function isKnownSiteLocale(locale) {
|
|
62
|
+
return messageCatalogs[locale] !== undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolves the effective message set for a locale: built-in English catalog,
|
|
66
|
+
* overlaid with the built-in catalog for the locale (when available, unknown
|
|
67
|
+
* locales fall back to English), overlaid with user overrides.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveSiteMessages(locale, overrides) {
|
|
70
|
+
const catalogKey = locale === undefined ? undefined : catalogKeys.get(locale.toLowerCase());
|
|
71
|
+
const catalog = catalogKey === undefined ? undefined : messageCatalogs[catalogKey];
|
|
72
|
+
if (catalog === undefined || catalogKey === 'en') {
|
|
73
|
+
return { ...enMessages, ...(overrides ?? {}) };
|
|
74
|
+
}
|
|
75
|
+
return { ...enMessages, ...catalog, ...(overrides ?? {}) };
|
|
76
|
+
}
|
|
77
|
+
export function formatSiteMessage(message, params) {
|
|
78
|
+
return Object.entries(params).reduce((text, [key, value]) => text.replaceAll(`{${key}}`, value), message);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Serializes messages for embedding inside an inline `<script>` element. `<`
|
|
82
|
+
* is escaped so that a message containing `</script>` cannot terminate the
|
|
83
|
+
* script block early.
|
|
84
|
+
*/
|
|
85
|
+
export function serializeMessagesForScript(value) {
|
|
86
|
+
return JSON.stringify(value).replaceAll('<', '\\u003c');
|
|
87
|
+
}
|
|
88
|
+
export function validateSiteMessages(value) {
|
|
89
|
+
const messages = {};
|
|
90
|
+
const unknownKeys = [];
|
|
91
|
+
if (typeof value !== 'object' || value === null) {
|
|
92
|
+
return { messages, unknownKeys };
|
|
93
|
+
}
|
|
94
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
95
|
+
if (!(key in enMessages)) {
|
|
96
|
+
unknownKeys.push(key);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (typeof entry === 'string' && entry !== '') {
|
|
100
|
+
messages[key] = entry;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { messages, unknownKeys };
|
|
104
|
+
}
|
package/dist/index-builder.d.ts
CHANGED
|
@@ -3,13 +3,15 @@ export interface BuildIndexOptions {
|
|
|
3
3
|
rootDir?: string;
|
|
4
4
|
dir?: string;
|
|
5
5
|
plugins?: MdoPlugin[];
|
|
6
|
+
/** Top-level directory names (locale content bases) kept out of managed indexes. */
|
|
7
|
+
excludedDirectories?: string[];
|
|
6
8
|
}
|
|
7
9
|
export interface BuildIndexResult {
|
|
8
10
|
updatedFiles: string[];
|
|
9
11
|
skippedDirectories: string[];
|
|
10
12
|
}
|
|
11
13
|
export declare function buildDirectoryIndexes(options: BuildIndexOptions): Promise<BuildIndexResult>;
|
|
12
|
-
export declare function buildManagedIndexBlock(directoryPath: string, plugins?: MdoPlugin[]): Promise<string>;
|
|
14
|
+
export declare function buildManagedIndexBlock(directoryPath: string, plugins?: MdoPlugin[], excludedDirectories?: string[]): Promise<string>;
|
|
13
15
|
export declare function upsertManagedIndexBlock(source: string, block: string, options?: {
|
|
14
16
|
directoryPath?: string;
|
|
15
17
|
}): string;
|