@jet-w/astro-blog 0.2.2 → 0.2.4
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/plugins/remark-containers.mjs +235 -79
- 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
- package/templates/default/content/posts/blog_docs_en/01.get-started/03-create-post.md +124 -8
- package/templates/default/content/posts/blog_docs_zh/01.get-started/03-create-post.md +124 -9
- package/templates/default/package-lock.json +2 -2
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧边栏工具函数
|
|
3
|
+
* 处理配置解析和树形结构生成
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// 使用内联类型定义避免 astro:content 导入问题
|
|
7
|
+
interface PostEntry {
|
|
8
|
+
id: string;
|
|
9
|
+
data: {
|
|
10
|
+
title?: string;
|
|
11
|
+
icon?: string;
|
|
12
|
+
pubDate?: Date;
|
|
13
|
+
tags?: string[];
|
|
14
|
+
draft?: boolean;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
SidebarConfig,
|
|
20
|
+
SidebarGroup,
|
|
21
|
+
SidebarItem,
|
|
22
|
+
ScanConfig,
|
|
23
|
+
ManualConfig,
|
|
24
|
+
MixedConfig,
|
|
25
|
+
} from '../config/sidebar';
|
|
26
|
+
|
|
27
|
+
// 树节点类型
|
|
28
|
+
export interface TreeNode {
|
|
29
|
+
name: string;
|
|
30
|
+
slug?: string;
|
|
31
|
+
title?: string;
|
|
32
|
+
displayName?: string;
|
|
33
|
+
icon?: string;
|
|
34
|
+
badge?: string;
|
|
35
|
+
badgeType?: 'info' | 'success' | 'warning' | 'error';
|
|
36
|
+
children: TreeNode[];
|
|
37
|
+
isFolder: boolean;
|
|
38
|
+
isReadme?: boolean;
|
|
39
|
+
link?: string;
|
|
40
|
+
collapsed?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 处理后的侧边栏组
|
|
44
|
+
export interface ProcessedGroup {
|
|
45
|
+
type: 'tree' | 'items' | 'divider';
|
|
46
|
+
title: string;
|
|
47
|
+
icon?: string;
|
|
48
|
+
collapsed?: boolean;
|
|
49
|
+
tree?: TreeNode[];
|
|
50
|
+
items?: SidebarItem[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 从文章集合构建树形结构
|
|
55
|
+
*/
|
|
56
|
+
export function buildTreeFromPosts(
|
|
57
|
+
posts: PostEntry[],
|
|
58
|
+
scanPath: string = '',
|
|
59
|
+
options: {
|
|
60
|
+
maxDepth?: number;
|
|
61
|
+
exclude?: string[];
|
|
62
|
+
include?: string[];
|
|
63
|
+
sortBy?: 'name' | 'date' | 'title' | 'custom';
|
|
64
|
+
sortOrder?: 'asc' | 'desc';
|
|
65
|
+
} = {}
|
|
66
|
+
): TreeNode[] {
|
|
67
|
+
const { maxDepth, exclude = [], include = [], sortBy = 'name', sortOrder = 'asc' } = options;
|
|
68
|
+
|
|
69
|
+
// 过滤出指定路径下的文章
|
|
70
|
+
const filteredPosts = posts.filter(post => {
|
|
71
|
+
const postPath = post.id.toLowerCase();
|
|
72
|
+
const targetPath = scanPath.toLowerCase();
|
|
73
|
+
|
|
74
|
+
// 检查是否在指定路径下
|
|
75
|
+
if (targetPath && !postPath.startsWith(targetPath + '/') && postPath !== targetPath) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 检查排除规则
|
|
80
|
+
const pathParts = post.id.split('/');
|
|
81
|
+
for (const part of pathParts as string[]) {
|
|
82
|
+
if (exclude.some((pattern: string) => matchPattern(part, pattern))) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 检查包含规则
|
|
88
|
+
if (include.length > 0) {
|
|
89
|
+
const matchesInclude = pathParts.some((part: string) =>
|
|
90
|
+
include.some((pattern: string) => matchPattern(part, pattern))
|
|
91
|
+
);
|
|
92
|
+
if (!matchesInclude) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return true;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// 收集文件夹的 README 标题和图标
|
|
101
|
+
const folderTitles: Record<string, string> = {};
|
|
102
|
+
const folderIcons: Record<string, string> = {};
|
|
103
|
+
|
|
104
|
+
filteredPosts.forEach(post => {
|
|
105
|
+
const pathParts = post.id.split('/');
|
|
106
|
+
const fileName = pathParts[pathParts.length - 1].toLowerCase();
|
|
107
|
+
|
|
108
|
+
if (fileName === 'readme' || fileName === 'readme.md') {
|
|
109
|
+
const folderPath = pathParts.slice(0, -1).join('/');
|
|
110
|
+
if (folderPath) {
|
|
111
|
+
if (post.data.title) {
|
|
112
|
+
folderTitles[folderPath] = post.data.title;
|
|
113
|
+
}
|
|
114
|
+
if (post.data.icon) {
|
|
115
|
+
folderIcons[folderPath] = post.data.icon;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// 构建树
|
|
122
|
+
const tree: TreeNode[] = [];
|
|
123
|
+
|
|
124
|
+
filteredPosts.forEach(post => {
|
|
125
|
+
// 移除 scanPath 前缀
|
|
126
|
+
let relativePath = post.id;
|
|
127
|
+
if (scanPath) {
|
|
128
|
+
const scanPathLower = scanPath.toLowerCase();
|
|
129
|
+
const postIdLower = post.id.toLowerCase();
|
|
130
|
+
if (postIdLower.startsWith(scanPathLower + '/')) {
|
|
131
|
+
relativePath = post.id.slice(scanPath.length + 1);
|
|
132
|
+
} else if (postIdLower === scanPathLower) {
|
|
133
|
+
relativePath = post.id.split('/').pop() || post.id;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const pathParts = relativePath.split('/');
|
|
138
|
+
|
|
139
|
+
// 检查深度限制
|
|
140
|
+
if (maxDepth !== undefined && pathParts.length > maxDepth) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let currentLevel = tree;
|
|
145
|
+
let currentPath = scanPath;
|
|
146
|
+
|
|
147
|
+
pathParts.forEach((part: string, index: number) => {
|
|
148
|
+
const isLast = index === pathParts.length - 1;
|
|
149
|
+
const existing = currentLevel.find(n => n.name.toLowerCase() === part.toLowerCase());
|
|
150
|
+
|
|
151
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
152
|
+
const isReadme = isLast && (part.toLowerCase() === 'readme' || part.toLowerCase() === 'readme.md');
|
|
153
|
+
|
|
154
|
+
if (existing) {
|
|
155
|
+
if (isLast) {
|
|
156
|
+
existing.slug = post.id;
|
|
157
|
+
existing.title = post.data.title;
|
|
158
|
+
existing.icon = post.data.icon;
|
|
159
|
+
existing.isReadme = isReadme;
|
|
160
|
+
} else {
|
|
161
|
+
// 更新文件夹信息
|
|
162
|
+
const folderPath = scanPath ? `${scanPath}/${pathParts.slice(0, index + 1).join('/')}` : pathParts.slice(0, index + 1).join('/');
|
|
163
|
+
if (folderTitles[folderPath]) {
|
|
164
|
+
existing.displayName = folderTitles[folderPath];
|
|
165
|
+
}
|
|
166
|
+
if (folderIcons[folderPath]) {
|
|
167
|
+
existing.icon = folderIcons[folderPath];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
currentLevel = existing.children;
|
|
171
|
+
} else {
|
|
172
|
+
const folderPath = scanPath ? `${scanPath}/${pathParts.slice(0, index + 1).join('/')}` : pathParts.slice(0, index + 1).join('/');
|
|
173
|
+
const newNode: TreeNode = {
|
|
174
|
+
name: part,
|
|
175
|
+
slug: isLast ? post.id : undefined,
|
|
176
|
+
title: isLast ? post.data.title : undefined,
|
|
177
|
+
displayName: isLast ? post.data.title : folderTitles[folderPath],
|
|
178
|
+
icon: isLast ? post.data.icon : folderIcons[folderPath],
|
|
179
|
+
children: [],
|
|
180
|
+
isFolder: !isLast,
|
|
181
|
+
isReadme: isReadme,
|
|
182
|
+
};
|
|
183
|
+
currentLevel.push(newNode);
|
|
184
|
+
currentLevel = newNode.children;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// 排序并过滤 README
|
|
190
|
+
return sortTree(tree, sortBy, sortOrder);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 排序树并过滤 README 文件
|
|
195
|
+
*/
|
|
196
|
+
function sortTree(
|
|
197
|
+
nodes: TreeNode[],
|
|
198
|
+
sortBy: 'name' | 'date' | 'title' | 'custom' = 'name',
|
|
199
|
+
sortOrder: 'asc' | 'desc' = 'asc'
|
|
200
|
+
): TreeNode[] {
|
|
201
|
+
const filtered = nodes.filter(node => !node.isReadme);
|
|
202
|
+
|
|
203
|
+
const sorted = filtered.sort((a, b) => {
|
|
204
|
+
// 文件夹优先
|
|
205
|
+
if (a.isFolder && !b.isFolder) return -1;
|
|
206
|
+
if (!a.isFolder && b.isFolder) return 1;
|
|
207
|
+
|
|
208
|
+
let comparison = 0;
|
|
209
|
+
switch (sortBy) {
|
|
210
|
+
case 'title':
|
|
211
|
+
comparison = (a.displayName || a.title || a.name).localeCompare(
|
|
212
|
+
b.displayName || b.title || b.name,
|
|
213
|
+
'zh-CN'
|
|
214
|
+
);
|
|
215
|
+
break;
|
|
216
|
+
case 'name':
|
|
217
|
+
default:
|
|
218
|
+
comparison = a.name.localeCompare(b.name, 'zh-CN', { numeric: true });
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return sortOrder === 'desc' ? -comparison : comparison;
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return sorted.map(node => ({
|
|
226
|
+
...node,
|
|
227
|
+
children: sortTree(node.children, sortBy, sortOrder),
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 简单的模式匹配(支持 * 通配符)
|
|
233
|
+
*/
|
|
234
|
+
function matchPattern(str: string, pattern: string): boolean {
|
|
235
|
+
if (pattern === '*') return true;
|
|
236
|
+
if (pattern.includes('*')) {
|
|
237
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
|
|
238
|
+
return regex.test(str);
|
|
239
|
+
}
|
|
240
|
+
return str.toLowerCase() === pattern.toLowerCase();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 路径 glob 模式匹配
|
|
245
|
+
* 支持:
|
|
246
|
+
* - /posts/tech/** 匹配 /posts/tech 及其所有子路径
|
|
247
|
+
* - /posts/tech/* 匹配 /posts/tech 的直接子路径
|
|
248
|
+
* - /posts/tech 精确匹配
|
|
249
|
+
*/
|
|
250
|
+
export function matchPathPattern(currentPath: string, pattern: string): boolean {
|
|
251
|
+
// 规范化路径
|
|
252
|
+
const normalizedPath = currentPath.replace(/\/$/, '').toLowerCase();
|
|
253
|
+
const normalizedPattern = pattern.replace(/\/$/, '').toLowerCase();
|
|
254
|
+
|
|
255
|
+
// ** 匹配任意深度
|
|
256
|
+
if (normalizedPattern.endsWith('/**')) {
|
|
257
|
+
const basePath = normalizedPattern.slice(0, -3);
|
|
258
|
+
return normalizedPath === basePath || normalizedPath.startsWith(basePath + '/');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// * 匹配单层
|
|
262
|
+
if (normalizedPattern.endsWith('/*')) {
|
|
263
|
+
const basePath = normalizedPattern.slice(0, -2);
|
|
264
|
+
if (normalizedPath === basePath) return true;
|
|
265
|
+
// 检查是否是直接子路径
|
|
266
|
+
if (normalizedPath.startsWith(basePath + '/')) {
|
|
267
|
+
const remaining = normalizedPath.slice(basePath.length + 1);
|
|
268
|
+
return !remaining.includes('/');
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// 精确匹配
|
|
274
|
+
return normalizedPath === normalizedPattern;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 检查侧边栏组是否应该在当前路径显示
|
|
279
|
+
*/
|
|
280
|
+
export function shouldShowGroup(
|
|
281
|
+
group: { showForPaths?: string[]; hideForPaths?: string[] },
|
|
282
|
+
currentPath: string
|
|
283
|
+
): boolean {
|
|
284
|
+
// 如果没有配置路径规则,默认显示
|
|
285
|
+
if (!group.showForPaths && !group.hideForPaths) {
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 检查隐藏规则
|
|
290
|
+
if (group.hideForPaths && group.hideForPaths.length > 0) {
|
|
291
|
+
for (const pattern of group.hideForPaths) {
|
|
292
|
+
if (matchPathPattern(currentPath, pattern)) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 检查显示规则
|
|
299
|
+
if (group.showForPaths && group.showForPaths.length > 0) {
|
|
300
|
+
for (const pattern of group.showForPaths) {
|
|
301
|
+
if (matchPathPattern(currentPath, pattern)) {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// 配置了 showForPaths 但没有匹配,则不显示
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// 默认显示
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 根据当前路径过滤侧边栏组
|
|
315
|
+
*/
|
|
316
|
+
export function filterGroupsByPath(
|
|
317
|
+
groups: SidebarGroup[],
|
|
318
|
+
currentPath: string
|
|
319
|
+
): SidebarGroup[] {
|
|
320
|
+
return groups.filter(group => shouldShowGroup(group, currentPath));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 将手动配置的项目转换为树节点
|
|
325
|
+
*/
|
|
326
|
+
export function manualItemsToTree(items: SidebarItem[]): TreeNode[] {
|
|
327
|
+
return items.map(item => ({
|
|
328
|
+
name: item.title,
|
|
329
|
+
slug: item.slug,
|
|
330
|
+
title: item.title,
|
|
331
|
+
displayName: item.title,
|
|
332
|
+
icon: item.icon,
|
|
333
|
+
badge: item.badge,
|
|
334
|
+
badgeType: item.badgeType,
|
|
335
|
+
link: item.link,
|
|
336
|
+
children: item.children ? manualItemsToTree(item.children) : [],
|
|
337
|
+
isFolder: !!(item.children && item.children.length > 0),
|
|
338
|
+
collapsed: item.collapsed,
|
|
339
|
+
}));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 处理侧边栏配置,生成可渲染的数据结构
|
|
344
|
+
*/
|
|
345
|
+
export async function processSidebarConfig(
|
|
346
|
+
config: SidebarConfig,
|
|
347
|
+
posts: PostEntry[]
|
|
348
|
+
): Promise<ProcessedGroup[]> {
|
|
349
|
+
const processedGroups: ProcessedGroup[] = [];
|
|
350
|
+
|
|
351
|
+
for (const group of config.groups) {
|
|
352
|
+
const processed = await processGroup(group, posts);
|
|
353
|
+
if (processed) {
|
|
354
|
+
processedGroups.push(processed);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return processedGroups;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 处理单个侧边栏组
|
|
363
|
+
*/
|
|
364
|
+
async function processGroup(
|
|
365
|
+
group: SidebarGroup,
|
|
366
|
+
posts: PostEntry[]
|
|
367
|
+
): Promise<ProcessedGroup | null> {
|
|
368
|
+
switch (group.type) {
|
|
369
|
+
case 'scan': {
|
|
370
|
+
const scanConfig = group as ScanConfig;
|
|
371
|
+
const tree = buildTreeFromPosts(posts, scanConfig.scanPath, {
|
|
372
|
+
maxDepth: scanConfig.maxDepth,
|
|
373
|
+
exclude: scanConfig.exclude,
|
|
374
|
+
include: scanConfig.include,
|
|
375
|
+
sortBy: scanConfig.sortBy,
|
|
376
|
+
sortOrder: scanConfig.sortOrder,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
type: 'tree',
|
|
381
|
+
title: scanConfig.title,
|
|
382
|
+
icon: scanConfig.icon,
|
|
383
|
+
collapsed: scanConfig.collapsed,
|
|
384
|
+
tree,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
case 'manual': {
|
|
389
|
+
const manualConfig = group as ManualConfig;
|
|
390
|
+
const tree = manualItemsToTree(manualConfig.items);
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
type: 'tree',
|
|
394
|
+
title: manualConfig.title,
|
|
395
|
+
icon: manualConfig.icon,
|
|
396
|
+
collapsed: manualConfig.collapsed,
|
|
397
|
+
tree,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
case 'mixed': {
|
|
402
|
+
const mixedConfig = group as MixedConfig;
|
|
403
|
+
const combinedTree: TreeNode[] = [];
|
|
404
|
+
|
|
405
|
+
for (const section of mixedConfig.sections) {
|
|
406
|
+
const processed = await processGroup(section, posts);
|
|
407
|
+
if (processed && processed.tree) {
|
|
408
|
+
// 将每个子部分作为一个文件夹节点
|
|
409
|
+
combinedTree.push({
|
|
410
|
+
name: section.title,
|
|
411
|
+
displayName: section.title,
|
|
412
|
+
icon: section.icon,
|
|
413
|
+
children: processed.tree,
|
|
414
|
+
isFolder: true,
|
|
415
|
+
collapsed: section.collapsed,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
type: 'tree',
|
|
422
|
+
title: mixedConfig.title,
|
|
423
|
+
icon: mixedConfig.icon,
|
|
424
|
+
collapsed: mixedConfig.collapsed,
|
|
425
|
+
tree: combinedTree,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
case 'divider': {
|
|
430
|
+
return {
|
|
431
|
+
type: 'divider',
|
|
432
|
+
title: group.title || '',
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
default:
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 获取最新文章
|
|
443
|
+
*/
|
|
444
|
+
export function getRecentPosts(
|
|
445
|
+
posts: PostEntry[],
|
|
446
|
+
count: number = 5
|
|
447
|
+
): PostEntry[] {
|
|
448
|
+
return posts
|
|
449
|
+
.filter(p => p.data.pubDate)
|
|
450
|
+
.sort((a, b) => (b.data.pubDate?.getTime() ?? 0) - (a.data.pubDate?.getTime() ?? 0))
|
|
451
|
+
.slice(0, count);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 获取热门标签
|
|
456
|
+
*/
|
|
457
|
+
export function getPopularTags(
|
|
458
|
+
posts: PostEntry[],
|
|
459
|
+
count: number = 8
|
|
460
|
+
): Array<{ name: string; count: number; slug: string }> {
|
|
461
|
+
const tagCounts: Record<string, number> = {};
|
|
462
|
+
|
|
463
|
+
posts.forEach(post => {
|
|
464
|
+
(post.data.tags || []).forEach((tag: string) => {
|
|
465
|
+
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
return Object.entries(tagCounts)
|
|
470
|
+
.sort((a, b) => b[1] - a[1])
|
|
471
|
+
.slice(0, count)
|
|
472
|
+
.map(([name, count]) => ({
|
|
473
|
+
name,
|
|
474
|
+
count,
|
|
475
|
+
slug: name.toLowerCase().replace(/\s+/g, '-'),
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* 获取归档数据
|
|
481
|
+
*/
|
|
482
|
+
export function getArchives(
|
|
483
|
+
posts: PostEntry[],
|
|
484
|
+
count: number = 6
|
|
485
|
+
): Array<{ year: number; month: number; count: number }> {
|
|
486
|
+
const archiveMap: Record<string, number> = {};
|
|
487
|
+
|
|
488
|
+
posts.forEach(post => {
|
|
489
|
+
if (post.data.pubDate) {
|
|
490
|
+
const date = new Date(post.data.pubDate);
|
|
491
|
+
const key = `${date.getFullYear()}-${date.getMonth() + 1}`;
|
|
492
|
+
archiveMap[key] = (archiveMap[key] || 0) + 1;
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
return Object.entries(archiveMap)
|
|
497
|
+
.sort((a, b) => b[0].localeCompare(a[0]))
|
|
498
|
+
.slice(0, count)
|
|
499
|
+
.map(([key, count]) => {
|
|
500
|
+
const [year, month] = key.split('-').map(Number);
|
|
501
|
+
return { year, month, count };
|
|
502
|
+
});
|
|
503
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue Composable for i18n
|
|
3
|
+
*
|
|
4
|
+
* Provides i18n support for Vue components in the blog.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { inject, computed, type ComputedRef } from 'vue';
|
|
8
|
+
import type { UITranslations, I18nConfig } from '../config/i18n';
|
|
9
|
+
import { getUITranslations } from '../config/i18n';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* i18n injection keys
|
|
13
|
+
*/
|
|
14
|
+
export const I18N_LOCALE_KEY = Symbol('i18n-locale');
|
|
15
|
+
export const I18N_CONFIG_KEY = Symbol('i18n-config');
|
|
16
|
+
export const I18N_TRANSLATIONS_KEY = Symbol('i18n-translations');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* i18n context provided to Vue components
|
|
20
|
+
*/
|
|
21
|
+
export interface I18nContext {
|
|
22
|
+
locale: string;
|
|
23
|
+
translations: UITranslations;
|
|
24
|
+
config?: I18nConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Return type of useI18n composable
|
|
29
|
+
*/
|
|
30
|
+
export interface UseI18nReturn {
|
|
31
|
+
/** Current locale code */
|
|
32
|
+
locale: ComputedRef<string>;
|
|
33
|
+
/** Translation function */
|
|
34
|
+
t: (key: keyof UITranslations) => string;
|
|
35
|
+
/** Format date according to locale */
|
|
36
|
+
formatDate: (date: Date | string, options?: Intl.DateTimeFormatOptions) => string;
|
|
37
|
+
/** Format date in short format */
|
|
38
|
+
formatDateShort: (date: Date | string) => string;
|
|
39
|
+
/** All translations for current locale */
|
|
40
|
+
translations: ComputedRef<UITranslations>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Vue composable for i18n support
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```vue
|
|
48
|
+
* <script setup>
|
|
49
|
+
* import { useI18n } from '@jet-w/astro-blog/utils/useI18n';
|
|
50
|
+
*
|
|
51
|
+
* const { t, formatDate, locale } = useI18n();
|
|
52
|
+
* </script>
|
|
53
|
+
*
|
|
54
|
+
* <template>
|
|
55
|
+
* <h1>{{ t('postList') }}</h1>
|
|
56
|
+
* <span>{{ formatDate(post.pubDate) }}</span>
|
|
57
|
+
* </template>
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function useI18n(): UseI18nReturn {
|
|
61
|
+
// Inject locale from parent (provided by Astro layout)
|
|
62
|
+
const injectedLocale = inject<string>(I18N_LOCALE_KEY, 'zh-CN');
|
|
63
|
+
const injectedTranslations = inject<UITranslations | undefined>(
|
|
64
|
+
I18N_TRANSLATIONS_KEY,
|
|
65
|
+
undefined
|
|
66
|
+
);
|
|
67
|
+
const injectedConfig = inject<I18nConfig | undefined>(I18N_CONFIG_KEY, undefined);
|
|
68
|
+
|
|
69
|
+
// Computed locale
|
|
70
|
+
const locale = computed(() => injectedLocale);
|
|
71
|
+
|
|
72
|
+
// Computed translations
|
|
73
|
+
const translations = computed<UITranslations>(() => {
|
|
74
|
+
if (injectedTranslations) {
|
|
75
|
+
return injectedTranslations;
|
|
76
|
+
}
|
|
77
|
+
// Fallback to getting translations by locale
|
|
78
|
+
return getUITranslations(injectedLocale, injectedConfig);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get translation for a key
|
|
83
|
+
*/
|
|
84
|
+
function t(key: keyof UITranslations): string {
|
|
85
|
+
return translations.value[key] || key;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format date according to current locale
|
|
90
|
+
*/
|
|
91
|
+
function formatDate(
|
|
92
|
+
date: Date | string,
|
|
93
|
+
options?: Intl.DateTimeFormatOptions
|
|
94
|
+
): string {
|
|
95
|
+
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
|
96
|
+
|
|
97
|
+
const defaultOptions: Intl.DateTimeFormatOptions = {
|
|
98
|
+
year: 'numeric',
|
|
99
|
+
month: 'long',
|
|
100
|
+
day: 'numeric',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return new Intl.DateTimeFormat(
|
|
104
|
+
locale.value,
|
|
105
|
+
options || defaultOptions
|
|
106
|
+
).format(dateObj);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Format date in short format
|
|
111
|
+
*/
|
|
112
|
+
function formatDateShort(date: Date | string): string {
|
|
113
|
+
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
|
114
|
+
|
|
115
|
+
return new Intl.DateTimeFormat(locale.value, {
|
|
116
|
+
year: 'numeric',
|
|
117
|
+
month: 'numeric',
|
|
118
|
+
day: 'numeric',
|
|
119
|
+
}).format(dateObj);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
locale,
|
|
124
|
+
t,
|
|
125
|
+
formatDate,
|
|
126
|
+
formatDateShort,
|
|
127
|
+
translations,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Create i18n context for providing to Vue components
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* ```astro
|
|
136
|
+
* ---
|
|
137
|
+
* import { createI18nContext } from '@jet-w/astro-blog/utils/useI18n';
|
|
138
|
+
* const i18nContext = createI18nContext('en', i18nConfig);
|
|
139
|
+
* ---
|
|
140
|
+
* <Component client:load {...i18nContext} />
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export function createI18nContext(
|
|
144
|
+
locale: string,
|
|
145
|
+
config?: I18nConfig
|
|
146
|
+
): I18nContext {
|
|
147
|
+
return {
|
|
148
|
+
locale,
|
|
149
|
+
translations: getUITranslations(locale, config),
|
|
150
|
+
config,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Re-export types
|
|
155
|
+
export type { UITranslations, Locale, I18nConfig } from '../config/i18n';
|