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.
- 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 +420 -12
- package/dist/core/router.d.ts +9 -0
- package/dist/core/router.js +26 -0
- package/dist/core/site-config.d.ts +53 -0
- package/dist/core/site-config.js +127 -0
- package/dist/core/site-url.d.ts +2 -0
- package/dist/core/site-url.js +6 -0
- package/dist/html/template.d.ts +17 -0
- package/dist/html/template.js +76 -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/dist/search.js +1 -6
- package/package.json +4 -4
|
@@ -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;
|
|
@@ -17,6 +18,19 @@ export interface SiteSocialLink {
|
|
|
17
18
|
export interface EditLinkConfig {
|
|
18
19
|
baseUrl: string;
|
|
19
20
|
}
|
|
21
|
+
export interface SiteRssConfigInput {
|
|
22
|
+
title?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
author?: string;
|
|
25
|
+
maxItems?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface SiteRssConfig {
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
title?: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
author?: string;
|
|
32
|
+
maxItems: number;
|
|
33
|
+
}
|
|
20
34
|
export interface SiteSearchRerankerConfig {
|
|
21
35
|
kind?: 'embedding-v1' | 'heuristic-v1';
|
|
22
36
|
candidatePoolSize?: number;
|
|
@@ -51,6 +65,16 @@ export interface SiteSearchConfig {
|
|
|
51
65
|
export interface SiteConfig {
|
|
52
66
|
siteTitle?: string;
|
|
53
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[];
|
|
54
78
|
siteUrl?: string;
|
|
55
79
|
favicon?: string;
|
|
56
80
|
socialImage?: string;
|
|
@@ -63,6 +87,7 @@ export interface SiteConfig {
|
|
|
63
87
|
footerText?: string;
|
|
64
88
|
socialLinks?: SiteSocialLink[];
|
|
65
89
|
editLink?: EditLinkConfig;
|
|
90
|
+
rss?: false | SiteRssConfigInput;
|
|
66
91
|
showHomeIndex?: boolean;
|
|
67
92
|
listingInitialPostCount?: number;
|
|
68
93
|
listingLoadMoreStep?: number;
|
|
@@ -74,6 +99,9 @@ export interface UserSiteConfig extends SiteConfig {
|
|
|
74
99
|
export interface ResolvedSiteConfig {
|
|
75
100
|
siteTitle: string;
|
|
76
101
|
siteDescription?: string;
|
|
102
|
+
locale: string;
|
|
103
|
+
messages: Partial<SiteMessages>;
|
|
104
|
+
locales?: ResolvedLocaleConfig[];
|
|
77
105
|
siteUrl?: string;
|
|
78
106
|
favicon?: string;
|
|
79
107
|
socialImage?: string;
|
|
@@ -85,6 +113,7 @@ export interface ResolvedSiteConfig {
|
|
|
85
113
|
footerText?: string;
|
|
86
114
|
socialLinks: SiteSocialLink[];
|
|
87
115
|
editLink?: EditLinkConfig;
|
|
116
|
+
rss?: SiteRssConfig;
|
|
88
117
|
showHomeIndex: boolean;
|
|
89
118
|
listingInitialPostCount: number;
|
|
90
119
|
listingLoadMoreStep: number;
|
|
@@ -104,6 +133,30 @@ export interface LoadedSiteConfig {
|
|
|
104
133
|
configFilePath: string;
|
|
105
134
|
configModulePath?: string;
|
|
106
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
|
+
}
|
|
107
160
|
export declare function loadSiteConfig(options?: LoadSiteConfigOptions): Promise<ResolvedSiteConfig>;
|
|
108
161
|
export declare function loadUserSiteConfig(options?: LoadSiteConfigOptions): Promise<LoadedSiteConfig>;
|
|
109
162
|
export declare function applySiteConfigFrontmatterDefaults(store: ContentStore, siteConfig: ResolvedSiteConfig): Promise<ResolvedSiteConfig>;
|
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),
|
|
@@ -42,6 +49,7 @@ export async function loadUserSiteConfig(options = {}) {
|
|
|
42
49
|
: undefined,
|
|
43
50
|
socialLinks: normalizeSocialLinks(parsedConfig.socialLinks),
|
|
44
51
|
editLink: normalizeEditLink(parsedConfig.editLink),
|
|
52
|
+
rss: normalizeRssConfig(parsedConfig.rss),
|
|
45
53
|
showHomeIndex: typeof parsedConfig.showHomeIndex === 'boolean'
|
|
46
54
|
? parsedConfig.showHomeIndex
|
|
47
55
|
: normalizeTopNav(parsedConfig.topNav).length === 0,
|
|
@@ -87,6 +95,95 @@ export async function applySiteConfigFrontmatterDefaults(store, siteConfig) {
|
|
|
87
95
|
}
|
|
88
96
|
return siteConfig;
|
|
89
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
|
+
}
|
|
90
187
|
async function resolveDefaultConfigPath(cwd, rootDir) {
|
|
91
188
|
const rootConfigPath = rootDir ? await findConfigPath(rootDir) : null;
|
|
92
189
|
if (rootConfigPath) {
|
|
@@ -222,6 +319,36 @@ function normalizeOptionalNumber(value) {
|
|
|
222
319
|
}
|
|
223
320
|
return undefined;
|
|
224
321
|
}
|
|
322
|
+
function normalizeRssConfig(value) {
|
|
323
|
+
if (value === false) {
|
|
324
|
+
return {
|
|
325
|
+
enabled: false,
|
|
326
|
+
title: undefined,
|
|
327
|
+
description: undefined,
|
|
328
|
+
author: undefined,
|
|
329
|
+
maxItems: 20,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (typeof value !== 'object' || value === null) {
|
|
333
|
+
return {
|
|
334
|
+
enabled: true,
|
|
335
|
+
title: undefined,
|
|
336
|
+
description: undefined,
|
|
337
|
+
author: undefined,
|
|
338
|
+
maxItems: 20,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const rss = value;
|
|
342
|
+
return {
|
|
343
|
+
enabled: true,
|
|
344
|
+
title: typeof rss.title === 'string' && rss.title !== '' ? rss.title : undefined,
|
|
345
|
+
description: typeof rss.description === 'string' && rss.description !== ''
|
|
346
|
+
? rss.description
|
|
347
|
+
: undefined,
|
|
348
|
+
author: typeof rss.author === 'string' && rss.author !== '' ? rss.author : undefined,
|
|
349
|
+
maxItems: normalizePositiveInteger(rss.maxItems, 20),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
225
352
|
function normalizeSearchConfig(value, configFilePath) {
|
|
226
353
|
if (typeof value !== 'object' || value === null) {
|
|
227
354
|
return undefined;
|
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;
|
|
@@ -21,11 +25,24 @@ export interface RenderDocumentOptions {
|
|
|
21
25
|
stylesheetContent?: string;
|
|
22
26
|
canonicalPath?: string;
|
|
23
27
|
alternateMarkdownPath?: string;
|
|
28
|
+
rssFeedUrl?: string;
|
|
24
29
|
listingEntries?: ManagedIndexEntry[];
|
|
25
30
|
listingRequestPath?: string;
|
|
26
31
|
listingInitialPostCount?: number;
|
|
27
32
|
listingLoadMoreStep?: number;
|
|
28
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
|
+
};
|
|
29
46
|
headerHtml?: string;
|
|
30
47
|
footerHtml?: string;
|
|
31
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
|
|
@@ -25,6 +28,14 @@ export function renderDocument(options) {
|
|
|
25
28
|
const alternateMarkdownMeta = options.alternateMarkdownPath
|
|
26
29
|
? `<link rel="alternate" type="text/markdown" href="${escapeHtml(options.alternateMarkdownPath)}">`
|
|
27
30
|
: '';
|
|
31
|
+
const rssMeta = options.rssFeedUrl
|
|
32
|
+
? `<link rel="alternate" type="application/rss+xml" title="${siteTitle}" href="${escapeHtml(options.rssFeedUrl)}">`
|
|
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
|
+
: '';
|
|
28
39
|
const stylesheetBlock = `<style>${getDefaultThemeStyles()}${options.stylesheetContent ? `\n${options.stylesheetContent}` : ''}</style>`;
|
|
29
40
|
const navBlock = options.topNav && options.topNav.length > 0
|
|
30
41
|
? `<nav class="site-nav"><ul>${options.topNav
|
|
@@ -34,21 +45,26 @@ export function renderDocument(options) {
|
|
|
34
45
|
const searchToggleBlock = options.searchEnabled
|
|
35
46
|
? [
|
|
36
47
|
'<div class="site-search" data-site-search>',
|
|
37
|
-
|
|
48
|
+
`<button type="button" class="site-search__toggle" aria-expanded="false" aria-controls="site-search-panel">${escapeHtml(messages['search.toggle'])}</button>`,
|
|
38
49
|
'<div id="site-search-panel" class="site-search__panel" hidden>',
|
|
39
50
|
'<form class="site-search__form" role="search" action="/api/search" method="get">',
|
|
40
|
-
|
|
51
|
+
`<label class="site-search__label" for="site-search-input">${escapeHtml(messages['search.label'])}</label>`,
|
|
41
52
|
'<div class="site-search__controls">',
|
|
42
|
-
|
|
43
|
-
|
|
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>`,
|
|
44
55
|
'</div>',
|
|
45
|
-
|
|
56
|
+
`<p class="site-search__hint">${messages['search.hint']}</p>`,
|
|
46
57
|
'</form>',
|
|
47
58
|
'<div class="site-search__results" data-site-search-results></div>',
|
|
48
59
|
'</div>',
|
|
49
60
|
'</div>',
|
|
50
61
|
].join('')
|
|
51
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
|
+
: '';
|
|
52
68
|
const footerNavBlock = options.footerNav && options.footerNav.length > 0
|
|
53
69
|
? `<nav class="site-footer__nav"><ul>${options.footerNav
|
|
54
70
|
.map((item) => `<li><a href="${escapeHtml(item.href)}">${escapeHtml(item.label)}</a></li>`)
|
|
@@ -67,10 +83,10 @@ export function renderDocument(options) {
|
|
|
67
83
|
: '';
|
|
68
84
|
const brandHref = escapeHtml(options.logo?.href ?? '/');
|
|
69
85
|
const editLinkBlock = options.editLinkHref
|
|
70
|
-
? `<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>`
|
|
71
87
|
: '';
|
|
72
88
|
const markdownViewBlock = options.alternateMarkdownPath
|
|
73
|
-
? `<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>`
|
|
74
90
|
: '';
|
|
75
91
|
const footerActionsBlock = markdownViewBlock || editLinkBlock
|
|
76
92
|
? `<div class="site-footer__actions">${markdownViewBlock}${editLinkBlock}</div>`
|
|
@@ -89,15 +105,17 @@ export function renderDocument(options) {
|
|
|
89
105
|
requestPath: options.listingRequestPath ?? '/',
|
|
90
106
|
initialPostCount: options.listingInitialPostCount ?? 10,
|
|
91
107
|
loadMoreStep: options.listingLoadMoreStep ?? 10,
|
|
92
|
-
})
|
|
108
|
+
}, messages)
|
|
93
109
|
: options.body;
|
|
94
|
-
const searchScript = options.searchEnabled
|
|
110
|
+
const searchScript = options.searchEnabled
|
|
111
|
+
? renderSearchScript(messages, options.searchLocaleFilter)
|
|
112
|
+
: '';
|
|
95
113
|
const headerBlock = options.headerHtml ??
|
|
96
|
-
`<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>`;
|
|
97
115
|
const renderedFooterBlock = options.footerHtml ?? footerBlock;
|
|
98
116
|
return [
|
|
99
117
|
'<!doctype html>',
|
|
100
|
-
|
|
118
|
+
`<html lang="${escapeHtml(locale)}">`,
|
|
101
119
|
'<head>',
|
|
102
120
|
'<meta charset="utf-8">',
|
|
103
121
|
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
@@ -107,6 +125,8 @@ export function renderDocument(options) {
|
|
|
107
125
|
faviconMeta,
|
|
108
126
|
socialImageMeta,
|
|
109
127
|
alternateMarkdownMeta,
|
|
128
|
+
rssMeta,
|
|
129
|
+
hreflangMeta,
|
|
110
130
|
stylesheetBlock,
|
|
111
131
|
'</head>',
|
|
112
132
|
'<body>',
|
|
@@ -159,7 +179,7 @@ function renderSocialIcon(icon) {
|
|
|
159
179
|
function iconSvg(pathData) {
|
|
160
180
|
return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${pathData}"></path></svg>`;
|
|
161
181
|
}
|
|
162
|
-
function renderListingArticle(body, entries, options) {
|
|
182
|
+
function renderListingArticle(body, entries, options, messages) {
|
|
163
183
|
if (entries.length === 0) {
|
|
164
184
|
return body;
|
|
165
185
|
}
|
|
@@ -170,26 +190,26 @@ function renderListingArticle(body, entries, options) {
|
|
|
170
190
|
const shouldLoadMore = articles.length > visibleArticles.length;
|
|
171
191
|
return [
|
|
172
192
|
`<div class="catalog-page__body">${body}</div>`,
|
|
173
|
-
|
|
174
|
-
directories.length > 0 ? renderListingDirectories(directories) : '',
|
|
193
|
+
`<section class="catalog-page" aria-label="${escapeHtml(messages['listing.ariaLabel'])}">`,
|
|
194
|
+
directories.length > 0 ? renderListingDirectories(directories, messages) : '',
|
|
175
195
|
articles.length > 0
|
|
176
196
|
? renderListingArticles(visibleArticles, {
|
|
177
197
|
requestPath: options.requestPath,
|
|
178
198
|
nextOffset: visibleArticles.length,
|
|
179
199
|
loadMoreStep: options.loadMoreStep,
|
|
180
200
|
hasMore: shouldLoadMore,
|
|
181
|
-
})
|
|
201
|
+
}, messages)
|
|
182
202
|
: '',
|
|
183
203
|
'</section>',
|
|
184
|
-
shouldLoadMore ? renderListingLoadMoreScript() : '',
|
|
204
|
+
shouldLoadMore ? renderListingLoadMoreScript(messages) : '',
|
|
185
205
|
].join('');
|
|
186
206
|
}
|
|
187
|
-
function renderListingDirectories(entries) {
|
|
207
|
+
function renderListingDirectories(entries, messages) {
|
|
188
208
|
return [
|
|
189
209
|
'<div class="catalog-list catalog-list--directories">',
|
|
190
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
|
|
191
211
|
? `<span class="catalog-item__detail">${escapeHtml(entry.detail)}</span>`
|
|
192
|
-
:
|
|
212
|
+
: `<span class="catalog-item__detail">${escapeHtml(messages['listing.browseSection'])}</span>`}</a>`),
|
|
193
213
|
'</div>',
|
|
194
214
|
].join('');
|
|
195
215
|
}
|
|
@@ -200,17 +220,17 @@ export function renderListingArticleItems(entries) {
|
|
|
200
220
|
: ''}</a>`)
|
|
201
221
|
.join('');
|
|
202
222
|
}
|
|
203
|
-
function renderListingArticles(entries, options) {
|
|
223
|
+
function renderListingArticles(entries, options, messages) {
|
|
204
224
|
return [
|
|
205
225
|
'<div class="catalog-list" data-listing-articles>',
|
|
206
226
|
renderListingArticleItems(entries),
|
|
207
227
|
'</div>',
|
|
208
228
|
options.hasMore
|
|
209
|
-
? `<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>`
|
|
210
230
|
: '',
|
|
211
231
|
].join('');
|
|
212
232
|
}
|
|
213
|
-
function renderListingLoadMoreScript() {
|
|
233
|
+
function renderListingLoadMoreScript(messages) {
|
|
214
234
|
return `<script>
|
|
215
235
|
(() => {
|
|
216
236
|
const button = document.querySelector('[data-listing-load-more]');
|
|
@@ -219,6 +239,11 @@ function renderListingLoadMoreScript() {
|
|
|
219
239
|
return;
|
|
220
240
|
}
|
|
221
241
|
|
|
242
|
+
const M = ${serializeMessagesForScript({
|
|
243
|
+
loadMore: messages['listing.loadMore'],
|
|
244
|
+
loading: messages['listing.loading'],
|
|
245
|
+
})};
|
|
246
|
+
|
|
222
247
|
const loadMore = async () => {
|
|
223
248
|
const requestPath = button.dataset.requestPath;
|
|
224
249
|
const nextOffset = button.dataset.nextOffset;
|
|
@@ -229,7 +254,7 @@ function renderListingLoadMoreScript() {
|
|
|
229
254
|
|
|
230
255
|
button.disabled = true;
|
|
231
256
|
const previousLabel = button.textContent;
|
|
232
|
-
button.textContent =
|
|
257
|
+
button.textContent = M.loading;
|
|
233
258
|
|
|
234
259
|
try {
|
|
235
260
|
const url = new URL(requestPath, window.location.origin);
|
|
@@ -252,14 +277,14 @@ function renderListingLoadMoreScript() {
|
|
|
252
277
|
if (payload.hasMore === true && typeof payload.nextOffset === 'number') {
|
|
253
278
|
button.dataset.nextOffset = String(payload.nextOffset);
|
|
254
279
|
button.disabled = false;
|
|
255
|
-
button.textContent = previousLabel ??
|
|
280
|
+
button.textContent = previousLabel ?? M.loadMore;
|
|
256
281
|
return;
|
|
257
282
|
}
|
|
258
283
|
|
|
259
284
|
button.remove();
|
|
260
285
|
} catch {
|
|
261
286
|
button.disabled = false;
|
|
262
|
-
button.textContent = previousLabel ??
|
|
287
|
+
button.textContent = previousLabel ?? M.loadMore;
|
|
263
288
|
}
|
|
264
289
|
};
|
|
265
290
|
|
|
@@ -269,7 +294,7 @@ function renderListingLoadMoreScript() {
|
|
|
269
294
|
})();
|
|
270
295
|
</script>`;
|
|
271
296
|
}
|
|
272
|
-
function renderSearchScript() {
|
|
297
|
+
function renderSearchScript(messages, localeFilter) {
|
|
273
298
|
return [
|
|
274
299
|
'<script>',
|
|
275
300
|
'(function () {',
|
|
@@ -281,41 +306,59 @@ function renderSearchScript() {
|
|
|
281
306
|
' const input = root.querySelector(".site-search__input");',
|
|
282
307
|
' const results = root.querySelector("[data-site-search-results]");',
|
|
283
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)};`,
|
|
284
319
|
' let controller = null;',
|
|
285
320
|
' function renderMessage(message) {',
|
|
286
321
|
' results.innerHTML = `<p class="site-search__message">${escapeHtmlForScript(message)}</p>`;',
|
|
287
322
|
' }',
|
|
288
323
|
' function renderHits(hits) {',
|
|
289
324
|
' if (!Array.isArray(hits) || hits.length === 0) {',
|
|
290
|
-
' renderMessage(
|
|
325
|
+
' renderMessage(M.resultsEmpty);',
|
|
291
326
|
' return;',
|
|
292
327
|
' }',
|
|
293
328
|
' results.innerHTML = hits.map((hit) => {',
|
|
294
329
|
' const href = escapeHtmlForScript(hit.canonicalUrl || hit.docId || "#");',
|
|
295
|
-
' const title = escapeHtmlForScript(hit.title || hit.relativePath ||
|
|
330
|
+
' const title = escapeHtmlForScript(hit.title || hit.relativePath || M.untitled);',
|
|
296
331
|
' const summary = typeof hit.summary === "string" ? `<span class="site-search__item-summary">${escapeHtmlForScript(hit.summary)}</span>` : "";',
|
|
297
332
|
' const excerpt = hit.bestMatch && typeof hit.bestMatch.excerpt === "string" ? `<span class="site-search__item-excerpt">${escapeHtmlForScript(hit.bestMatch.excerpt)}</span>` : "";',
|
|
298
333
|
' return `<a class="site-search__item" href="${href}"><strong class="site-search__item-title">${title}</strong>${summary}${excerpt}</a>`;',
|
|
299
334
|
' }).join("");',
|
|
300
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
|
+
' }',
|
|
301
344
|
' async function runSearch(query) {',
|
|
302
345
|
' if (controller) controller.abort();',
|
|
303
346
|
' controller = new AbortController();',
|
|
304
|
-
' renderMessage(
|
|
347
|
+
' renderMessage(M.searching);',
|
|
305
348
|
' try {',
|
|
306
349
|
' const url = new URL("/api/search", window.location.origin);',
|
|
307
350
|
' url.searchParams.set("q", query);',
|
|
308
351
|
' url.searchParams.set("topK", "8");',
|
|
309
352
|
' const response = await fetch(url, { signal: controller.signal });',
|
|
310
353
|
' if (!response.ok) {',
|
|
311
|
-
' renderMessage(
|
|
354
|
+
' renderMessage(M.failedWithStatus.replaceAll("{status}", String(response.status)));',
|
|
312
355
|
' return;',
|
|
313
356
|
' }',
|
|
314
357
|
' const payload = await response.json();',
|
|
315
|
-
' renderHits(payload.hits);',
|
|
358
|
+
' renderHits(filterHits(payload.hits));',
|
|
316
359
|
' } catch (error) {',
|
|
317
360
|
' if (error && typeof error === "object" && "name" in error && error.name === "AbortError") return;',
|
|
318
|
-
' renderMessage(
|
|
361
|
+
' renderMessage(M.failed);',
|
|
319
362
|
' }',
|
|
320
363
|
' }',
|
|
321
364
|
' function setOpen(open) {',
|
|
@@ -323,7 +366,7 @@ function renderSearchScript() {
|
|
|
323
366
|
' panel.hidden = !open;',
|
|
324
367
|
' if (open) {',
|
|
325
368
|
' input.focus();',
|
|
326
|
-
' if (!results.innerHTML) renderMessage(
|
|
369
|
+
' if (!results.innerHTML) renderMessage(M.initialHint);',
|
|
327
370
|
' }',
|
|
328
371
|
' }',
|
|
329
372
|
' toggle.addEventListener("click", () => setOpen(panel.hidden));',
|
|
@@ -331,7 +374,7 @@ function renderSearchScript() {
|
|
|
331
374
|
' event.preventDefault();',
|
|
332
375
|
' const query = input.value.trim();',
|
|
333
376
|
' if (!query) {',
|
|
334
|
-
' renderMessage(
|
|
377
|
+
' renderMessage(M.enterQuery);',
|
|
335
378
|
' return;',
|
|
336
379
|
' }',
|
|
337
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;
|