@coffic/cosy-ui 0.4.5 → 0.4.9

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.
@@ -0,0 +1,18 @@
1
+ ---
2
+ import { Image } from 'astro:assets';
3
+ import type { ImageMetadata } from 'astro';
4
+
5
+ interface Props {
6
+ title: string;
7
+ description: string;
8
+ image: ImageMetadata;
9
+ }
10
+
11
+ const { title, description, image } = Astro.props;
12
+ ---
13
+
14
+ <div class="flex flex-col items-center gap-4">
15
+ <Image src={image} alt={title} class="w-48 h-48 object-contain" />
16
+ <h3 class="text-xl font-semibold">{title}</h3>
17
+ <p class="text-gray-600">{description}</p>
18
+ </div>
@@ -0,0 +1,22 @@
1
+ ---
2
+ import { Image } from 'astro:assets';
3
+ import type { ImageMetadata } from 'astro';
4
+
5
+ interface Props {
6
+ speakerName: string;
7
+ speakerTitle: string;
8
+ image: ImageMetadata;
9
+ words: string;
10
+ }
11
+
12
+ const { speakerName, speakerTitle, image, words } = Astro.props;
13
+ ---
14
+
15
+ <div class="flex flex-col items-center gap-4 p-6 bg-white rounded-lg shadow-lg">
16
+ <Image src={image} alt={speakerName} class="w-32 h-32 object-contain" width={128} height={128} />
17
+ <div class="text-center">
18
+ <h3 class="text-xl font-semibold">{speakerName}</h3>
19
+ <p class="text-gray-600">{speakerTitle}</p>
20
+ </div>
21
+ <p class="text-gray-700 italic">{words}</p>
22
+ </div>
@@ -184,7 +184,7 @@ const {
184
184
  showSidebar = true,
185
185
  class: className,
186
186
  'class:list': classList,
187
- debug = true,
187
+ debug = false,
188
188
  mainContentConfig,
189
189
  footerConfig,
190
190
  headerConfig,
@@ -58,7 +58,7 @@ import '../../app.css';
58
58
  import Link from '../base/Link.astro';
59
59
  import Image from '../base/Image.astro';
60
60
  import type { HeaderProps } from '../../index';
61
- import Logo from '../../assets/logo.png';
61
+ import Logo from '../../assets/logo-rounded.png';
62
62
 
63
63
  interface Props extends HeaderProps {}
64
64
 
@@ -26,9 +26,9 @@ import { isPathMatch } from '../../utils/path';
26
26
  import Modal from '../display/Modal.astro';
27
27
  import SidebarNav from './SidebarNav.astro';
28
28
  import MenuIcon from '../icons/MenuIcon.astro';
29
- import type { SidebarProps } from '../../types/sidebar';
29
+ import type { SidebarProps } from '../../index';
30
30
 
31
- export interface Props extends SidebarProps{}
31
+ export interface Props extends SidebarProps {}
32
32
 
33
33
  const {
34
34
  sidebarItems,
@@ -127,6 +127,7 @@ const baseClasses = [
127
127
  'cosy:mx-auto',
128
128
  'cosy:dark:prose-invert', // 暗黑模式支持,
129
129
  'cosy:m-0',
130
+ 'cosy:min-h-screen',
130
131
  'cosy:pb-96',
131
132
  widthClasses,
132
133
  className,
@@ -0,0 +1,164 @@
1
+ import { getCollection, getEntry, type CollectionEntry, type DataEntryMap } from "astro:content";
2
+ import { logger } from "../utils/logger";
3
+
4
+ /**
5
+ * BaseDB 是所有数据库类的基类,提供了通用的文档操作功能。
6
+ *
7
+ * 使用方法:
8
+ * ```typescript
9
+ * class MyDB extends BaseDB<'collection', MyEntry, MyDoc> {
10
+ * protected collectionName = 'collection' as const;
11
+ * protected createDoc(entry: MyEntry): MyDoc {
12
+ * return new MyDoc(entry);
13
+ * }
14
+ * }
15
+ *
16
+ * // 使用单例模式获取实例
17
+ * const db = MyDB.getInstance();
18
+ * const docs = await db.allTopLevelDocs();
19
+ * ```
20
+ *
21
+ * 类型参数说明:
22
+ * @template Collection - Astro content collection 的名称,必须是 DataEntryMap 的键
23
+ * @template Entry - Collection 对应的条目类型
24
+ * @template Doc - 文档类型,通常是自定义的文档类
25
+ */
26
+ export abstract class BaseDB<
27
+ Collection extends keyof DataEntryMap,
28
+ Entry extends CollectionEntry<Collection>,
29
+ Doc
30
+ > {
31
+ /** 集合名称,必须在子类中指定 */
32
+ protected abstract collectionName: Collection;
33
+
34
+ /**
35
+ * 创建文档实例的方法,必须在子类中实现
36
+ * @param entry - 集合条目
37
+ * @returns 文档实例
38
+ */
39
+ protected abstract createDoc(entry: Entry): Doc;
40
+
41
+ /**
42
+ * 获取集合中的所有条目
43
+ * @returns 返回所有条目的数组
44
+ */
45
+ async getEntries(): Promise<Entry[]> {
46
+ const entries = await getCollection(this.collectionName);
47
+ return entries.map(entry => entry as Entry);
48
+ }
49
+
50
+ /**
51
+ * 获取指定深度的文档
52
+ * 深度是指文档 ID 中斜杠的数量加1
53
+ * 例如:
54
+ * - "blog.md" 深度为1
55
+ * - "zh-cn/blog.md" 深度为2
56
+ * - "zh-cn/tech/blog.md" 深度为3
57
+ *
58
+ * @param depth - 文档深度
59
+ * @returns 返回指定深度的文档数组
60
+ */
61
+ async getDocsByDepth(depth: number): Promise<Doc[]> {
62
+ const entries = await getCollection(this.collectionName, ({ id }) => id.split('/').length === depth);
63
+ return entries.map(entry => this.createDoc(entry as Entry));
64
+ }
65
+
66
+ /**
67
+ * 根据ID查找单个文档
68
+ * @param id - 文档ID
69
+ * @returns 返回找到的文档,如果不存在则返回null
70
+ */
71
+ async find(id: string): Promise<Doc | null> {
72
+ const entry = await getEntry(this.collectionName, id);
73
+ return entry ? this.createDoc(entry as Entry) : null;
74
+ }
75
+
76
+ /**
77
+ * 获取指定文档的直接子文档(不包括更深层级的文档)
78
+ * 例如对于文档 "zh-cn/blog":
79
+ * - "zh-cn/blog/post1.md" 会被包含
80
+ * - "zh-cn/blog/2024/post2.md" 不会被包含
81
+ *
82
+ * @param parentId - 父文档ID
83
+ * @returns 返回子文档数组
84
+ */
85
+ async getChildren(parentId: string): Promise<Doc[]> {
86
+ const parentLevel = parentId.split('/').length;
87
+ const childrenLevel = parentLevel + 1;
88
+
89
+ const entries = await getCollection(this.collectionName,
90
+ ({ id }) => id.startsWith(parentId) && id.split('/').length === childrenLevel);
91
+ return entries.map(entry => this.createDoc(entry as Entry));
92
+ }
93
+
94
+ /**
95
+ * 获取指定文档的所有后代文档(包括所有层级)
96
+ * 例如对于文档 "zh-cn/blog",以下都会被包含:
97
+ * - "zh-cn/blog/post1.md"
98
+ * - "zh-cn/blog/2024/post2.md"
99
+ * - "zh-cn/blog/2024/tech/post3.md"
100
+ *
101
+ * @param parentId - 父文档ID
102
+ * @returns 返回所有后代文档数组
103
+ */
104
+ async getDescendantDocs(parentId: string): Promise<Doc[]> {
105
+ const entries = await getCollection(this.collectionName, ({ id }) => id.startsWith(parentId));
106
+ return entries.map(entry => this.createDoc(entry as Entry));
107
+ }
108
+
109
+ /**
110
+ * 获取指定语言的顶级文档
111
+ * 通过检查文档ID是否以指定语言代码开头来筛选
112
+ *
113
+ * @param lang - 语言代码(如 'zh-cn', 'en')
114
+ * @returns 返回指定语言的顶级文档数组
115
+ */
116
+ async allDocsByLang(lang: string): Promise<Doc[]> {
117
+ const debug = false;
118
+ const docs = await this.getDocsByDepth(2);
119
+
120
+ if (debug) {
121
+ logger.array('所有顶级文档', docs);
122
+ }
123
+
124
+ return docs.filter(doc => {
125
+ const id = (doc as any).getId();
126
+ return id && typeof id === 'string' && id.startsWith(lang);
127
+ });
128
+ }
129
+
130
+ /**
131
+ * 获取用于 Astro 静态路由生成的路径参数
132
+ * 为每个文档生成包含语言和slug的路径参数
133
+ *
134
+ * @returns 返回路径参数数组,每个元素包含 lang 和 slug
135
+ * @example
136
+ * ```typescript
137
+ * const paths = await db.getStaticPaths();
138
+ * // 返回格式:
139
+ * // [
140
+ * // { params: { lang: 'zh-cn', slug: 'post1' } },
141
+ * // { params: { lang: 'en', slug: 'post1' } }
142
+ * // ]
143
+ * ```
144
+ */
145
+ async getStaticPaths() {
146
+ const debug = false;
147
+ const docs = await this.getDescendantDocs('');
148
+ const paths = docs.map((doc) => {
149
+ const docWithMethods = doc as any;
150
+ return {
151
+ params: {
152
+ lang: docWithMethods.getLang?.() || '',
153
+ slug: docWithMethods.getSlug?.() || '',
154
+ },
155
+ };
156
+ });
157
+
158
+ if (debug) {
159
+ logger.array('所有文档的路径', paths);
160
+ }
161
+
162
+ return paths;
163
+ }
164
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 侧边栏项目接口
3
+ */
4
+ export interface SidebarItemProps {
5
+ label: string;
6
+ link?: string;
7
+ items?: SidebarItem[];
8
+ }
9
+
10
+ /**
11
+ * 侧边栏项目类
12
+ * 用于构建网站的侧边栏导航
13
+ */
14
+ export class SidebarItem {
15
+ label: string;
16
+ link: string;
17
+ items: SidebarItem[];
18
+
19
+ constructor(props: SidebarItemProps) {
20
+ this.label = props.label;
21
+ this.link = props.link || '';
22
+ this.items = props.items || [];
23
+ }
24
+
25
+ /**
26
+ * 添加子项目
27
+ * @param item 要添加的子项目
28
+ */
29
+ addItem(item: SidebarItem): void {
30
+ this.items.push(item);
31
+ }
32
+
33
+ /**
34
+ * 获取所有子项目
35
+ * @returns 子项目数组
36
+ */
37
+ getItems(): SidebarItem[] {
38
+ return this.items;
39
+ }
40
+
41
+ /**
42
+ * 获取项目标签
43
+ * @returns 项目标签
44
+ */
45
+ getLabel(): string {
46
+ return this.label;
47
+ }
48
+
49
+ /**
50
+ * 获取项目链接
51
+ * @returns 项目链接
52
+ */
53
+ getLink(): string {
54
+ return this.link;
55
+ }
56
+
57
+ /**
58
+ * 获取包括自身在内的所有项目
59
+ * @returns 包括自身在内的所有项目
60
+ */
61
+ getItemsIncludingSelf(): SidebarItem[] {
62
+ return [this, ...this.getItems()];
63
+ }
64
+
65
+ /**
66
+ * 判断是否是分组(有子项目)
67
+ * @returns 是否是分组
68
+ */
69
+ isGroup(): boolean {
70
+ return this.items.length > 0;
71
+ }
72
+
73
+ /**
74
+ * 判断是否不是分组
75
+ * @returns 是否不是分组
76
+ */
77
+ isNotGroup(): boolean {
78
+ return !this.isGroup();
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 侧边栏提供者接口
84
+ * 实现此接口的类可以提供侧边栏项目
85
+ */
86
+ export interface SidebarProvider {
87
+ /**
88
+ * 转换为侧边栏项目
89
+ */
90
+ toSidebarItem(): Promise<SidebarItem>;
91
+
92
+ /**
93
+ * 获取顶级侧边栏项目
94
+ */
95
+ getTopSidebarItem?(): Promise<SidebarItem>;
96
+ }
@@ -0,0 +1,25 @@
1
+ export type LangCode = 'zh-cn' | 'en';
2
+
3
+ export default class Language {
4
+ code: LangCode;
5
+
6
+ constructor(code: LangCode) {
7
+ this.code = code;
8
+ }
9
+
10
+ static fromString(lang: string): Language {
11
+ return new Language(lang as LangCode);
12
+ }
13
+
14
+ isChinese(): boolean {
15
+ return this.code === 'zh-cn';
16
+ }
17
+
18
+ isEnglish(): boolean {
19
+ return this.code === 'en';
20
+ }
21
+
22
+ static isCodeValid(code: string): boolean {
23
+ return code === 'zh-cn' || code === 'en';
24
+ }
25
+ }
@@ -0,0 +1,18 @@
1
+ export const languages = {
2
+ en: 'English',
3
+ 'zh-cn': '简体中文',
4
+ };
5
+
6
+ export const defaultLang = 'zh-cn';
7
+
8
+ export const ui = {
9
+ en: {
10
+ 'nav.home': 'Home',
11
+ 'nav.about': 'About',
12
+ 'nav.twitter': 'Twitter',
13
+ },
14
+ 'zh-cn': {
15
+ 'nav.home': '首页',
16
+ 'nav.about': '关于',
17
+ },
18
+ } as const;
@@ -0,0 +1,54 @@
1
+ import { ui, defaultLang } from './ui';
2
+
3
+ export function getLangFromUrl(url: URL) {
4
+ const [, lang] = url.pathname.split('/');
5
+ if (lang in ui) return lang as keyof typeof ui;
6
+ return defaultLang;
7
+ }
8
+
9
+ export function t(lang: string, key: keyof typeof ui[typeof defaultLang]) {
10
+ return ui[lang in ui ? lang as keyof typeof ui : defaultLang][key];
11
+ }
12
+
13
+ export const normalizeLang = (lang: string) => {
14
+ if (lang === 'zh-CN') {
15
+ return 'zh-cn';
16
+ }
17
+ return lang;
18
+ }
19
+
20
+ export const getLangFromSlug = (slug: string) => {
21
+ let lang = slug.split('/')[0];
22
+
23
+ return normalizeLang(lang);
24
+ }
25
+
26
+ export const getLangFromPathname = (pathname: string) => {
27
+ // 去除开头的 /
28
+ pathname = pathname.slice(1);
29
+
30
+ let lang = pathname.split('/')[0];
31
+
32
+ return normalizeLang(lang);
33
+ }
34
+
35
+ export const isValidLang = (lang: string) => {
36
+ return ['zh-cn', 'en'].includes(lang);
37
+ }
38
+
39
+ export const getLang = (...args: string[]) => {
40
+ const debug = false
41
+
42
+ if (debug) {
43
+ // eslint-disable-next-line no-console
44
+ console.log('getLang', args);
45
+ }
46
+
47
+ for (const arg of args) {
48
+ let normalizedLang = normalizeLang(arg);
49
+ if (isValidLang(normalizedLang)) {
50
+ return normalizedLang;
51
+ }
52
+ }
53
+ return 'zh-cn';
54
+ }
package/dist/index.ts CHANGED
@@ -4,6 +4,8 @@ export { default as Link } from './components/base/Link.astro';
4
4
  export { default as Image } from './components/base/Image.astro';
5
5
  export { default as ThemeItem } from './components/base/ThemeItem.astro';
6
6
  export { default as Alert } from './components/base/Alert.astro';
7
+ export { default as Speak } from './components/base/Speak.astro';
8
+ export { default as Module } from './components/base/Module.astro';
7
9
 
8
10
  // Navigation
9
11
  export { default as ThemeSwitcher } from './components/navigation/ThemeSwitcher.astro';
@@ -74,6 +76,8 @@ export * from './utils/i18n';
74
76
  export * from './utils/path';
75
77
  export * from './utils/url';
76
78
  export * from './utils/language';
79
+ export * from './utils/logger';
80
+ export * from './utils/link';
77
81
 
78
82
  // Types
79
83
  export * from './types/sidebar';
@@ -81,5 +85,12 @@ export * from './types/main';
81
85
  export * from './types/article';
82
86
  export * from './types/layout';
83
87
  export * from './types/header';
88
+ export * from './types/heading';
84
89
  export * from './types/meta';
85
90
  export type { ImageProvider, ImageOptions } from './utils/image';
91
+
92
+ // Models
93
+ export * from './models/BaseDoc';
94
+
95
+ // Database
96
+ export * from './database/BaseDB';
@@ -0,0 +1,164 @@
1
+ import { render, type RenderResult, type CollectionEntry, type DataEntryMap } from "astro:content";
2
+ import { SidebarItem, type SidebarProvider } from "../entities/SidebarItem";
3
+ import { logger } from "../utils/logger";
4
+
5
+ /**
6
+ * 文档基类,提供所有文档类型共享的基本功能
7
+ */
8
+ export abstract class BaseDoc<Collection extends keyof DataEntryMap, T extends CollectionEntry<Collection>> implements SidebarProvider {
9
+ protected entry: T;
10
+
11
+ constructor(entry: T) {
12
+ this.entry = entry;
13
+ }
14
+
15
+ /**
16
+ * 获取文档ID
17
+ */
18
+ getId(): string {
19
+ return this.entry.id;
20
+ }
21
+
22
+ /**
23
+ * 获取文档标题
24
+ */
25
+ getTitle(): string {
26
+ return this.entry.data.title as string;
27
+ }
28
+
29
+ /**
30
+ * 获取文档语言
31
+ */
32
+ getLang(): string {
33
+ return this.entry.id.split('/')[0];
34
+ }
35
+
36
+ /**
37
+ * 获取文档slug
38
+ */
39
+ getSlug(): string {
40
+ return this.getId().split('/').slice(1).join('/');
41
+ }
42
+
43
+ /**
44
+ * 获取文档描述
45
+ */
46
+ getDescription(): string {
47
+ return this.entry.data.description as string;
48
+ }
49
+
50
+ /**
51
+ * 获取文档链接
52
+ * 每个子类必须实现此方法以提供正确的链接
53
+ */
54
+ abstract getLink(): string;
55
+
56
+ /**
57
+ * 渲染文档内容
58
+ */
59
+ async render(): Promise<RenderResult> {
60
+ return await render(this.entry);
61
+ }
62
+
63
+ /**
64
+ * 获取文档的层级深度
65
+ * 例如:对于 ID 为 "zh-cn/blog/typescript" 的文档,深度为3
66
+ */
67
+ getLevel(): number {
68
+ return this.entry.id.split('/').length;
69
+ }
70
+
71
+ /**
72
+ * 转换为侧边栏项目
73
+ * 基本实现,只包含当前文档
74
+ */
75
+ async toSidebarItem(): Promise<SidebarItem> {
76
+ return new SidebarItem({
77
+ label: this.getTitle(),
78
+ link: this.getLink(),
79
+ });
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 层级文档基类,为有层级结构的文档类型提供额外功能
85
+ * 例如课程等有父子关系的文档
86
+ */
87
+ export abstract class HierarchicalDoc<Collection extends keyof DataEntryMap, T extends CollectionEntry<Collection>> extends BaseDoc<Collection, T> {
88
+ /**
89
+ * 获取父文档ID
90
+ * 例如:对于 ID 为 "zh-cn/blog/typescript" 的文档,父ID为 "zh-cn/blog"
91
+ *
92
+ * @returns 父文档ID,如果没有父文档则返回null
93
+ */
94
+ getParentId(): string | null {
95
+ const parts = this.entry.id.split('/');
96
+ return parts.length > 1 ? parts.slice(0, -1).join('/') : null;
97
+ }
98
+
99
+ /**
100
+ * 获取顶级文档的ID
101
+ * 例如:对于 ID 为 "zh-cn/blog/typescript" 的文档,顶级ID为 "zh-cn/blog"
102
+ *
103
+ * 默认实现假设顶级ID是前两部分
104
+ * 子类可以根据需要覆盖此方法
105
+ */
106
+ async getTopDocId(): Promise<string> {
107
+ const id = this.entry.id;
108
+ const parts = id.split('/');
109
+ return parts[0] + '/' + parts[1];
110
+ }
111
+
112
+ /**
113
+ * 获取顶级文档
114
+ * 子类应该实现此方法以提供正确的顶级文档
115
+ */
116
+ abstract getTopDoc(): Promise<HierarchicalDoc<Collection, T> | null>;
117
+
118
+ /**
119
+ * 获取子文档
120
+ * 子类应该实现此方法以提供正确的子文档列表
121
+ */
122
+ abstract getChildren(): Promise<HierarchicalDoc<Collection, T>[]>;
123
+
124
+ /**
125
+ * 转换为侧边栏项目
126
+ * 如果文档有子文档,会包含子文档的侧边栏项目
127
+ */
128
+ override async toSidebarItem(): Promise<SidebarItem> {
129
+ const debug = false;
130
+
131
+ const children = await this.getChildren();
132
+ const childItems = await Promise.all(children.map(child => child.toSidebarItem()));
133
+
134
+ if (debug) {
135
+ logger.info(`${this.entry.id} 的侧边栏项目`);
136
+ // eslint-disable-next-line no-console
137
+ console.log(childItems);
138
+ }
139
+
140
+ return new SidebarItem({
141
+ label: this.getTitle(),
142
+ items: childItems,
143
+ link: this.getLink(),
144
+ });
145
+ }
146
+
147
+ /**
148
+ * 获取顶级侧边栏项目
149
+ * 如果有顶级文档,返回顶级文档的侧边栏项目
150
+ * 否则返回当前文档的侧边栏项目
151
+ */
152
+ async getTopSidebarItem(): Promise<SidebarItem> {
153
+ const topDoc = await this.getTopDoc();
154
+ if (topDoc) {
155
+ return await topDoc.toSidebarItem();
156
+ }
157
+
158
+ return new SidebarItem({
159
+ label: this.getTitle(),
160
+ items: [],
161
+ link: this.getLink(),
162
+ });
163
+ }
164
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 表示文档中的标题结构
3
+ */
4
+ export interface Heading {
5
+ /** 标题深度,如 h1=1, h2=2, h3=3 等 */
6
+ depth: number;
7
+
8
+ /** 标题的唯一标识符,用于锚点链接 */
9
+ slug: string;
10
+
11
+ /** 标题文本内容 */
12
+ text: string;
13
+ }
@@ -0,0 +1,216 @@
1
+ import { logger } from "./logger";
2
+
3
+ export class LinkUtil {
4
+ /**
5
+ * 规范化语言代码
6
+ * @param lang - 语言代码
7
+ * @returns 规范化后的语言代码
8
+ */
9
+ static normalizeLanguage(lang: string): string {
10
+ const normalizedLang = lang.toLowerCase().replace('zh-CN', 'zh-cn');
11
+ if (normalizedLang.length == 0) {
12
+ console.error('lang is empty');
13
+ return 'en';
14
+ }
15
+ return normalizedLang;
16
+ }
17
+
18
+ static getHomeLink(lang: string): string {
19
+ return `/${lang}`;
20
+ }
21
+
22
+ static getLessonsLink(lang: string): string {
23
+ return `/${lang}/lessons`;
24
+ }
25
+
26
+ static getExperimentsLink(lang: string): string {
27
+ return `/${lang}/experiments`;
28
+ }
29
+
30
+ static getExperimentLink(lang: string, experimentId: string): string {
31
+ if (experimentId.endsWith(lang)) {
32
+ return `/${lang}/experiments/${experimentId.replace(`${lang}`, '')}`;
33
+ } else {
34
+ const idWithoutLang = experimentId.replace(`${lang}/`, '');
35
+ return `/${lang}/experiments/${idWithoutLang}`;
36
+ }
37
+ }
38
+
39
+ static getCoursesLink(lang: string): string {
40
+ return `/${lang}/courses`;
41
+ }
42
+
43
+ static getBlogsLink(lang: string): string {
44
+ return `/${lang}/blogs`;
45
+ }
46
+
47
+ static getLessonLink(lang: string, lessonId: string): string {
48
+ if (lessonId.endsWith(lang)) {
49
+ return `/${lang}/lessons/${lessonId.replace(`${lang}`, '')}`;
50
+ } else {
51
+ const idWithoutLang = lessonId.replace(`${lang}/`, '');
52
+ return `/${lang}/lessons/${idWithoutLang}`;
53
+ }
54
+ }
55
+
56
+ static getTagLink(lang: string, tagName: string): string {
57
+ return `/${lang}/blogs/tag/${tagName}`;
58
+ }
59
+
60
+ static getBlogLink(blogId: string, lang: string): string {
61
+ const debug = false
62
+ const blogIdWithoutLang = blogId.replace(`${lang}/`, '')
63
+
64
+ if (debug) {
65
+ logger.info(`获取博客文档链接,博客文档ID: ${blogId}`);
66
+ }
67
+
68
+ return `/${lang}/blogs/${blogIdWithoutLang}`;
69
+ }
70
+
71
+ static getCourseLink(courseId: string): string {
72
+ const debug = false
73
+ const lang = courseId.split('/')[0]
74
+ const courseIdWithoutLang = courseId.replace(`${lang}/`, '')
75
+
76
+ if (debug) {
77
+ logger.info(`获取课程文档链接,课程文档ID: ${courseId}`);
78
+ }
79
+
80
+ return `/${lang}/courses/${courseIdWithoutLang}`;
81
+ }
82
+
83
+ static getMetaLink(lang: string, slug: string): string {
84
+ return `/${this.normalizeLanguage(lang)}/meta/${slug}`;
85
+ }
86
+
87
+ static getSigninLink(lang: string): string {
88
+ return `/${this.normalizeLanguage(lang)}/signin`;
89
+ }
90
+
91
+ static getAuthCallbackCookieLink(lang: string): string {
92
+ return `/${this.normalizeLanguage(lang)}/auth/callback_cookie`;
93
+ }
94
+
95
+ static getAuthCallbackTokenLink(lang: string): string {
96
+ return `/${this.normalizeLanguage(lang)}/auth/callback_token`;
97
+ }
98
+
99
+ static getAuthAccountLink(lang: string): string {
100
+ return `/${this.normalizeLanguage(lang)}/auth/account`;
101
+ }
102
+
103
+ static getDashboardUrl(lang: string): string {
104
+ return `/${this.normalizeLanguage(lang)}/auth/dashboard`;
105
+ }
106
+
107
+ static getAuthErrorLink(lang: string): string {
108
+ return `/${this.normalizeLanguage(lang)}/auth/error`;
109
+ }
110
+
111
+ static getPrivacyLink(lang: string): string {
112
+ return this.getMetaLink(lang, 'privacy');
113
+ }
114
+
115
+ static getTermsLink(lang: string): string {
116
+ return this.getMetaLink(lang, 'terms');
117
+ }
118
+
119
+ static getAboutLink(lang: string): string {
120
+ return this.getMetaLink(lang, 'about');
121
+ }
122
+
123
+ /**
124
+ * 根据ID生成链接
125
+ *
126
+ * 该函数根据文档ID生成对应的链接路径。
127
+ *
128
+ * @param {string} id - 文档ID, 例如 'courses/zh-cn/supervisor/index.md'
129
+ * @returns {string} 返回生成的链接路径
130
+ * @example
131
+ * // 例如:
132
+ * // id=courses/zh-cn/supervisor/index.md,则返回/zh-cn/courses/supervisor
133
+ * // id=courses/en/supervisor/index.md,则返回/en/courses/supervisor
134
+ */
135
+ static getLink(id: string): string {
136
+ let category = id.split('/')[0];
137
+ let lang = id.split('/')[1];
138
+ let path = id.split('/').slice(2).join('/');
139
+
140
+ let link = `/${lang}/${category}/${path}`;
141
+ return link.replace(/\/+/g, '/');
142
+ }
143
+
144
+ /**
145
+ * 根据分类生成顶级链接
146
+ *
147
+ * @param {string} category - 分类名称
148
+ * @param {string} lang - 语言代码,例如 'zh-cn', 'en'
149
+ * @returns {string} 返回生成的顶级链接路径
150
+ * @example
151
+ * // 例如:
152
+ * // category=courses, lang=zh-cn,则返回/zh-cn/courses
153
+ * // category=courses, lang=en,则返回/en/courses
154
+ */
155
+ static getTopLevelLink(category: string, lang: string): string {
156
+ return `/${lang}/${category}`;
157
+ }
158
+
159
+ /**
160
+ * 处理首页重定向
161
+ * @param locale - 语言代码
162
+ * @returns 规范化的语言代码
163
+ */
164
+ static homeRedirect(locale: string): string {
165
+ return locale || "en";
166
+ }
167
+
168
+ /**
169
+ * 检查是否为首页路径
170
+ * @param pathname - 路径
171
+ * @returns 是否为首页
172
+ */
173
+ static isHomePath(pathname: string): boolean {
174
+ return pathname === "/" || pathname === "";
175
+ }
176
+
177
+ static isHomeLink(path: string, lang: string): boolean {
178
+ return path === `/${lang}` || path === `/${lang}/`;
179
+ }
180
+
181
+ static isLessonsLink(path: string, lang: string): boolean {
182
+ return path === `/${lang}/lessons` ||
183
+ path === `/${lang}/lessons/` ||
184
+ path.startsWith(`/${lang}/lessons/`);
185
+ }
186
+
187
+ static isExperimentsLink(path: string, lang: string): boolean {
188
+ return path === `/${lang}/experiments` ||
189
+ path === `/${lang}/experiments/` ||
190
+ path.startsWith(`/${lang}/experiments/`);
191
+ }
192
+
193
+ static isCoursesLink(path: string, lang: string): boolean {
194
+ return path === `/${lang}/courses`
195
+ || path === `/${lang}/courses/`
196
+ || path.startsWith(`/${lang}/courses/`);
197
+ }
198
+
199
+ static isBlogsLink(path: string, lang: string): boolean {
200
+ return path === `/${lang}/blogs`
201
+ || path === `/${lang}/blogs/`
202
+ || path.startsWith(`/${lang}/blogs/`);
203
+ }
204
+
205
+ static getOAuthSuccessLink(currentOrigin: string): string {
206
+ return `${currentOrigin}/api/callback_success`;
207
+ }
208
+
209
+ static getOAuthErrorLink(currentOrigin: string): string {
210
+ return `${currentOrigin}/api/callback_error`;
211
+ }
212
+
213
+ static getLoginLink(currentOrigin: string): string {
214
+ return `${currentOrigin}/api/login`;
215
+ }
216
+ }
@@ -0,0 +1,139 @@
1
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+
3
+ // 控制是否显示时间戳
4
+ const SHOW_TIMESTAMP = false;
5
+
6
+ // ANSI 颜色代码
7
+ const colors = {
8
+ reset: '\x1b[0m',
9
+ debug: '\x1b[36m', // 青色
10
+ info: '\x1b[32m', // 绿色
11
+ warn: '\x1b[33m', // 黄色
12
+ error: '\x1b[31m', // 红色
13
+ gray: '\x1b[90m', // 灰色用于时间戳
14
+ };
15
+
16
+ class Logger {
17
+ private getCallerInfo(): string {
18
+ const error = new Error();
19
+ const stackLines = error.stack?.split('\n') || [];
20
+
21
+ // 从第3行开始查找,跳过不是来自logger.ts的第一个调用
22
+ let targetLine = '';
23
+ for (let i = 3; i < stackLines.length; i++) {
24
+ const line = stackLines[i];
25
+ if (!line.includes('logger.ts')) {
26
+ targetLine = line;
27
+ break;
28
+ }
29
+ }
30
+
31
+ if (!targetLine) return '';
32
+
33
+ // 匹配文件路径和行号
34
+ const match = targetLine.match(/\((.+):(\d+):\d+\)/) || targetLine.match(/at (.+):(\d+):\d+/);
35
+ if (!match) return '';
36
+
37
+ const [_, filePath, line] = match;
38
+ return `${filePath}:${line}`;
39
+ }
40
+
41
+ private formatArray(arr: any[]): string {
42
+ const MAX_LINES = 30;
43
+ const MAX_LENGTH = 100;
44
+
45
+ const truncateString = (str: string): string => {
46
+ return str.length > MAX_LENGTH ? str.slice(0, MAX_LENGTH) + '...' : str;
47
+ };
48
+
49
+ const truncateObject = (obj: any): any => {
50
+ if (typeof obj !== 'object' || obj === null) {
51
+ return typeof obj === 'string' ? truncateString(obj) : obj;
52
+ }
53
+
54
+ const result: any = Array.isArray(obj) ? [] : {};
55
+ for (const [key, value] of Object.entries(obj)) {
56
+ result[key] = typeof value === 'string' ? truncateString(value)
57
+ : typeof value === 'object' ? truncateObject(value)
58
+ : value;
59
+ }
60
+ return result;
61
+ };
62
+
63
+ const items = arr.slice(0, MAX_LINES).map(item => {
64
+ const truncatedItem = truncateObject(item);
65
+ // 使用2个空格缩进,并在每行前添加 " • "
66
+ const jsonString = JSON.stringify(truncatedItem, null, 2)
67
+ .split('\n')
68
+ .map((line, index) => index === 0 ? ` • ${line}` : ` ${line}`)
69
+ .join('\n');
70
+ return jsonString;
71
+ });
72
+
73
+ let output = items.join('\n');
74
+ if (arr.length > MAX_LINES) {
75
+ const remainingCount = arr.length - MAX_LINES;
76
+ output += `\n ⋮ ... and ${remainingCount} more items`;
77
+ }
78
+
79
+ return output;
80
+ }
81
+
82
+ private log(level: LogLevel, message: string | object | any[]) {
83
+ const caller = this.getCallerInfo();
84
+ // 使用本地时间,并格式化为 HH:mm:ss 格式
85
+ const timestamp = new Date().toLocaleTimeString('zh-CN', {
86
+ hour12: false,
87
+ hour: '2-digit',
88
+ minute: '2-digit',
89
+ second: '2-digit'
90
+ });
91
+
92
+ const formattedMessage = Array.isArray(message)
93
+ ? this.formatArray(message)
94
+ : typeof message === 'object'
95
+ ? JSON.stringify(message, null, 2)
96
+ : message;
97
+
98
+ const timestampPart = SHOW_TIMESTAMP
99
+ ? `${colors.gray}${timestamp}${colors.reset} `
100
+ : '';
101
+
102
+ const emoji = {
103
+ debug: '🔍',
104
+ info: '🐳',
105
+ warn: '🚨',
106
+ error: '❌'
107
+ }[level];
108
+
109
+ // eslint-disable-next-line no-console
110
+ console.log(
111
+ timestampPart +
112
+ `${colors[level]}${emoji} ${level.toUpperCase()}${colors.reset} ` +
113
+ `${colors.gray}:${colors.reset} ` +
114
+ `${colors[level]}${formattedMessage}${colors.reset}`
115
+ );
116
+ }
117
+
118
+ debug(message: string | object) {
119
+ this.log('debug', message);
120
+ }
121
+
122
+ info(message: string | object) {
123
+ this.log('info', message);
124
+ }
125
+
126
+ warn(message: string | object) {
127
+ this.log('warn', message);
128
+ }
129
+
130
+ error(message: string | object) {
131
+ this.log('error', message);
132
+ }
133
+
134
+ array(title: string, arr: any[]) {
135
+ this.log('info', title + '\n' + this.formatArray(arr));
136
+ }
137
+ }
138
+
139
+ export const logger = new Logger();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffic/cosy-ui",
3
- "version": "0.4.5",
3
+ "version": "0.4.9",
4
4
  "description": "An astro component library",
5
5
  "author": {
6
6
  "name": "nookery",