@jet-w/astro-blog 0.2.2 → 0.2.3
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/package.json +4 -1
- package/src/config/footer.ts +58 -0
- package/src/config/i18n.ts +485 -0
- package/src/config/index.ts +42 -0
- package/src/config/menu.ts +32 -0
- package/src/config/sidebar.ts +142 -0
- package/src/config/site.ts +61 -0
- package/src/config/social.ts +29 -0
- package/src/types/index.ts +80 -0
- package/src/utils/i18n.ts +418 -0
- package/src/utils/sidebar.ts +503 -0
- package/src/utils/useI18n.ts +155 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n Utility Functions
|
|
3
|
+
*
|
|
4
|
+
* Provides helper functions for multi-language support.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
I18nConfig,
|
|
9
|
+
Locale,
|
|
10
|
+
UITranslations,
|
|
11
|
+
} from '../config/i18n';
|
|
12
|
+
import {
|
|
13
|
+
defaultI18nConfig,
|
|
14
|
+
getUITranslations,
|
|
15
|
+
} from '../config/i18n';
|
|
16
|
+
import type { SiteConfig, NavigationItem } from '../types';
|
|
17
|
+
import type { FooterConfig } from '../config/footer';
|
|
18
|
+
import type { SidebarConfig } from '../config/sidebar';
|
|
19
|
+
import { defaultSiteConfig } from '../config/site';
|
|
20
|
+
import { defaultMenu } from '../config/menu';
|
|
21
|
+
import { defaultFooterConfig } from '../config/footer';
|
|
22
|
+
import { sidebarConfig as defaultSidebarConfig } from '../config/sidebar';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Merged locale configuration with all defaults applied
|
|
26
|
+
*/
|
|
27
|
+
export interface MergedLocaleConfig {
|
|
28
|
+
locale: Locale;
|
|
29
|
+
site: SiteConfig;
|
|
30
|
+
menu: NavigationItem[];
|
|
31
|
+
footer: FooterConfig;
|
|
32
|
+
sidebar: SidebarConfig;
|
|
33
|
+
ui: UITranslations;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Alternate link for SEO (hreflang)
|
|
38
|
+
*/
|
|
39
|
+
export interface AlternateLink {
|
|
40
|
+
locale: string;
|
|
41
|
+
url: string;
|
|
42
|
+
hreflang: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get current locale from URL pathname
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* getLocaleFromPath('/en/posts', config) // 'en'
|
|
50
|
+
* getLocaleFromPath('/posts', config) // 'zh-CN' (default)
|
|
51
|
+
* getLocaleFromPath('/zh-CN/about', config) // 'zh-CN'
|
|
52
|
+
*/
|
|
53
|
+
export function getLocaleFromPath(
|
|
54
|
+
pathname: string,
|
|
55
|
+
config: I18nConfig = defaultI18nConfig
|
|
56
|
+
): string {
|
|
57
|
+
// Remove leading slash and get first segment
|
|
58
|
+
const segments = pathname.replace(/^\//, '').split('/');
|
|
59
|
+
const firstSegment = segments[0];
|
|
60
|
+
|
|
61
|
+
// Check if first segment matches any locale code
|
|
62
|
+
const matchedLocale = config.locales.find(
|
|
63
|
+
(locale) => locale.code === firstSegment
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (matchedLocale) {
|
|
67
|
+
return matchedLocale.code;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Return default locale
|
|
71
|
+
return config.defaultLocale;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get locale data by code
|
|
76
|
+
*/
|
|
77
|
+
export function getLocaleByCode(
|
|
78
|
+
code: string,
|
|
79
|
+
config: I18nConfig = defaultI18nConfig
|
|
80
|
+
): Locale | undefined {
|
|
81
|
+
return config.locales.find((locale) => locale.code === code);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Remove locale prefix from pathname
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* removeLocalePrefix('/en/posts', config) // '/posts'
|
|
89
|
+
* removeLocalePrefix('/posts', config) // '/posts'
|
|
90
|
+
*/
|
|
91
|
+
export function removeLocalePrefix(
|
|
92
|
+
pathname: string,
|
|
93
|
+
config: I18nConfig = defaultI18nConfig
|
|
94
|
+
): string {
|
|
95
|
+
const locale = getLocaleFromPath(pathname, config);
|
|
96
|
+
|
|
97
|
+
// If it's default locale and prefix is not required, pathname has no prefix
|
|
98
|
+
if (locale === config.defaultLocale && !config.routing.prefixDefaultLocale) {
|
|
99
|
+
return pathname;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Remove the locale prefix
|
|
103
|
+
const prefix = `/${locale}`;
|
|
104
|
+
if (pathname.startsWith(prefix)) {
|
|
105
|
+
const rest = pathname.slice(prefix.length);
|
|
106
|
+
return rest || '/';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return pathname;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get localized path for a given locale
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* getLocalizedPath('/posts', 'en', config) // '/en/posts'
|
|
117
|
+
* getLocalizedPath('/en/posts', 'zh-CN', config) // '/posts' (if zh-CN is default)
|
|
118
|
+
*/
|
|
119
|
+
export function getLocalizedPath(
|
|
120
|
+
pathname: string,
|
|
121
|
+
targetLocale: string,
|
|
122
|
+
config: I18nConfig = defaultI18nConfig
|
|
123
|
+
): string {
|
|
124
|
+
// First, remove any existing locale prefix
|
|
125
|
+
const basePath = removeLocalePrefix(pathname, config);
|
|
126
|
+
|
|
127
|
+
// If target is default locale and prefix is not required
|
|
128
|
+
if (
|
|
129
|
+
targetLocale === config.defaultLocale &&
|
|
130
|
+
!config.routing.prefixDefaultLocale
|
|
131
|
+
) {
|
|
132
|
+
return basePath;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Add locale prefix
|
|
136
|
+
if (basePath === '/') {
|
|
137
|
+
return `/${targetLocale}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return `/${targetLocale}${basePath}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get all alternate links for SEO (hreflang tags)
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* getAlternateLinks('/posts', 'https://example.com', config)
|
|
148
|
+
* // Returns links for all locales
|
|
149
|
+
*/
|
|
150
|
+
export function getAlternateLinks(
|
|
151
|
+
pathname: string,
|
|
152
|
+
baseUrl: string,
|
|
153
|
+
config: I18nConfig = defaultI18nConfig
|
|
154
|
+
): AlternateLink[] {
|
|
155
|
+
const links: AlternateLink[] = [];
|
|
156
|
+
|
|
157
|
+
for (const locale of config.locales) {
|
|
158
|
+
const localizedPath = getLocalizedPath(pathname, locale.code, config);
|
|
159
|
+
links.push({
|
|
160
|
+
locale: locale.code,
|
|
161
|
+
url: `${baseUrl.replace(/\/$/, '')}${localizedPath}`,
|
|
162
|
+
hreflang: locale.htmlLang,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Add x-default pointing to default locale
|
|
167
|
+
const defaultPath = getLocalizedPath(
|
|
168
|
+
pathname,
|
|
169
|
+
config.defaultLocale,
|
|
170
|
+
config
|
|
171
|
+
);
|
|
172
|
+
links.push({
|
|
173
|
+
locale: 'x-default',
|
|
174
|
+
url: `${baseUrl.replace(/\/$/, '')}${defaultPath}`,
|
|
175
|
+
hreflang: 'x-default',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return links;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Deep merge two objects
|
|
183
|
+
*/
|
|
184
|
+
function deepMerge<T extends object>(base: T, override: Partial<T>): T {
|
|
185
|
+
const result = { ...base };
|
|
186
|
+
|
|
187
|
+
for (const key in override) {
|
|
188
|
+
if (Object.prototype.hasOwnProperty.call(override, key)) {
|
|
189
|
+
const overrideValue = override[key];
|
|
190
|
+
const baseValue = base[key];
|
|
191
|
+
|
|
192
|
+
if (
|
|
193
|
+
typeof overrideValue === 'object' &&
|
|
194
|
+
overrideValue !== null &&
|
|
195
|
+
!Array.isArray(overrideValue) &&
|
|
196
|
+
typeof baseValue === 'object' &&
|
|
197
|
+
baseValue !== null &&
|
|
198
|
+
!Array.isArray(baseValue)
|
|
199
|
+
) {
|
|
200
|
+
(result as any)[key] = deepMerge(baseValue as object, overrideValue as object);
|
|
201
|
+
} else if (overrideValue !== undefined) {
|
|
202
|
+
(result as any)[key] = overrideValue;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get merged configuration for a specific locale
|
|
212
|
+
* Combines default config with locale-specific overrides
|
|
213
|
+
*/
|
|
214
|
+
export function getLocaleConfig(
|
|
215
|
+
locale: string,
|
|
216
|
+
config: I18nConfig = defaultI18nConfig
|
|
217
|
+
): MergedLocaleConfig {
|
|
218
|
+
const localeData = getLocaleByCode(locale, config);
|
|
219
|
+
const localeOverrides = config.localeConfigs[locale] || {};
|
|
220
|
+
|
|
221
|
+
// Get locale info (fallback to creating one from code)
|
|
222
|
+
const localeInfo: Locale = localeData || {
|
|
223
|
+
code: locale,
|
|
224
|
+
name: locale,
|
|
225
|
+
htmlLang: locale,
|
|
226
|
+
dateLocale: locale,
|
|
227
|
+
direction: 'ltr',
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Merge site config
|
|
231
|
+
const site = deepMerge(defaultSiteConfig, localeOverrides.site || {});
|
|
232
|
+
|
|
233
|
+
// Use locale-specific menu or default menu
|
|
234
|
+
const menu = localeOverrides.menu || defaultMenu;
|
|
235
|
+
|
|
236
|
+
// Merge footer config
|
|
237
|
+
const footer = deepMerge(defaultFooterConfig, localeOverrides.footer || {});
|
|
238
|
+
|
|
239
|
+
// Merge sidebar config
|
|
240
|
+
const sidebar = localeOverrides.sidebar
|
|
241
|
+
? {
|
|
242
|
+
...defaultSidebarConfig,
|
|
243
|
+
...localeOverrides.sidebar,
|
|
244
|
+
// Use locale-specific groups if provided, otherwise keep default
|
|
245
|
+
groups: localeOverrides.sidebar.groups || defaultSidebarConfig.groups,
|
|
246
|
+
}
|
|
247
|
+
: defaultSidebarConfig;
|
|
248
|
+
|
|
249
|
+
// Get UI translations
|
|
250
|
+
const ui = getUITranslations(locale, config);
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
locale: localeInfo,
|
|
254
|
+
site,
|
|
255
|
+
menu,
|
|
256
|
+
footer,
|
|
257
|
+
sidebar,
|
|
258
|
+
ui,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Translation function - get a UI translation string
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* t('readMore', 'en') // 'Read more'
|
|
267
|
+
* t('readMore', 'zh-CN') // '阅读更多'
|
|
268
|
+
*/
|
|
269
|
+
export function t(
|
|
270
|
+
key: keyof UITranslations,
|
|
271
|
+
locale: string,
|
|
272
|
+
config?: I18nConfig
|
|
273
|
+
): string {
|
|
274
|
+
const translations = getUITranslations(locale, config);
|
|
275
|
+
return translations[key] || key;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Format date according to locale
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* formatDate(new Date(), 'en') // 'January 1, 2024'
|
|
283
|
+
* formatDate(new Date(), 'zh-CN') // '2024年1月1日'
|
|
284
|
+
*/
|
|
285
|
+
export function formatDate(
|
|
286
|
+
date: Date | string,
|
|
287
|
+
locale: string,
|
|
288
|
+
options?: Intl.DateTimeFormatOptions
|
|
289
|
+
): string {
|
|
290
|
+
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
|
291
|
+
|
|
292
|
+
const defaultOptions: Intl.DateTimeFormatOptions = {
|
|
293
|
+
year: 'numeric',
|
|
294
|
+
month: 'long',
|
|
295
|
+
day: 'numeric',
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
return new Intl.DateTimeFormat(locale, options || defaultOptions).format(
|
|
299
|
+
dateObj
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Format date in short format
|
|
305
|
+
*
|
|
306
|
+
* @example
|
|
307
|
+
* formatDateShort(new Date(), 'en') // '1/1/2024'
|
|
308
|
+
* formatDateShort(new Date(), 'zh-CN') // '2024/1/1'
|
|
309
|
+
*/
|
|
310
|
+
export function formatDateShort(
|
|
311
|
+
date: Date | string,
|
|
312
|
+
locale: string
|
|
313
|
+
): string {
|
|
314
|
+
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
|
315
|
+
|
|
316
|
+
return new Intl.DateTimeFormat(locale, {
|
|
317
|
+
year: 'numeric',
|
|
318
|
+
month: 'numeric',
|
|
319
|
+
day: 'numeric',
|
|
320
|
+
}).format(dateObj);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Check if a locale is RTL (right-to-left)
|
|
325
|
+
*/
|
|
326
|
+
export function isRTL(
|
|
327
|
+
locale: string,
|
|
328
|
+
config: I18nConfig = defaultI18nConfig
|
|
329
|
+
): boolean {
|
|
330
|
+
const localeData = getLocaleByCode(locale, config);
|
|
331
|
+
return localeData?.direction === 'rtl';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Get the HTML dir attribute value
|
|
336
|
+
*/
|
|
337
|
+
export function getTextDirection(
|
|
338
|
+
locale: string,
|
|
339
|
+
config: I18nConfig = defaultI18nConfig
|
|
340
|
+
): 'ltr' | 'rtl' {
|
|
341
|
+
return isRTL(locale, config) ? 'rtl' : 'ltr';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Check if multi-language is enabled (more than one locale)
|
|
346
|
+
*/
|
|
347
|
+
export function isMultiLanguageEnabled(
|
|
348
|
+
config: I18nConfig = defaultI18nConfig
|
|
349
|
+
): boolean {
|
|
350
|
+
return config.locales.length > 1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Get prefix for a locale in routes
|
|
355
|
+
* Returns empty string for default locale if prefixDefaultLocale is false
|
|
356
|
+
*/
|
|
357
|
+
export function getLocalePrefix(
|
|
358
|
+
locale: string,
|
|
359
|
+
config: I18nConfig = defaultI18nConfig
|
|
360
|
+
): string {
|
|
361
|
+
if (locale === config.defaultLocale && !config.routing.prefixDefaultLocale) {
|
|
362
|
+
return '';
|
|
363
|
+
}
|
|
364
|
+
return `/${locale}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Get content path prefix for a specific locale
|
|
369
|
+
* Returns the contentPathPrefix from locale config, or undefined if not set
|
|
370
|
+
*/
|
|
371
|
+
export function getContentPathPrefix(
|
|
372
|
+
locale: string,
|
|
373
|
+
config: I18nConfig = defaultI18nConfig
|
|
374
|
+
): string | undefined {
|
|
375
|
+
const localeConfig = config.localeConfigs[locale];
|
|
376
|
+
return localeConfig?.contentPathPrefix;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Filter posts by locale based on contentPathPrefix
|
|
381
|
+
* If contentPathPrefix is set, only return posts that start with that prefix
|
|
382
|
+
* If not set, return all posts (backward compatible)
|
|
383
|
+
*
|
|
384
|
+
* @example
|
|
385
|
+
* // If en locale has contentPathPrefix: 'blog_docs_en'
|
|
386
|
+
* filterPostsByLocale(posts, 'en', config)
|
|
387
|
+
* // Returns only posts with id starting with 'blog_docs_en/'
|
|
388
|
+
*/
|
|
389
|
+
export function filterPostsByLocale<T extends { id: string }>(
|
|
390
|
+
posts: T[],
|
|
391
|
+
locale: string,
|
|
392
|
+
config: I18nConfig = defaultI18nConfig
|
|
393
|
+
): T[] {
|
|
394
|
+
const contentPathPrefix = getContentPathPrefix(locale, config);
|
|
395
|
+
|
|
396
|
+
// If no contentPathPrefix is set, return all posts (backward compatible)
|
|
397
|
+
if (!contentPathPrefix) {
|
|
398
|
+
return posts;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Filter posts that start with the content path prefix
|
|
402
|
+
return posts.filter((post) => {
|
|
403
|
+
const postPath = post.id.toLowerCase();
|
|
404
|
+
const prefix = contentPathPrefix.toLowerCase();
|
|
405
|
+
return postPath.startsWith(prefix + '/') || postPath === prefix;
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Re-export types and config functions
|
|
410
|
+
export type { I18nConfig, Locale, LocaleConfig, UITranslations } from '../config/i18n';
|
|
411
|
+
export {
|
|
412
|
+
defaultI18nConfig,
|
|
413
|
+
defineI18nConfig,
|
|
414
|
+
getUITranslations,
|
|
415
|
+
builtInTranslations,
|
|
416
|
+
zhCNTranslations,
|
|
417
|
+
enTranslations,
|
|
418
|
+
} from '../config/i18n';
|