@bbki.ng/site 5.5.14 → 5.5.16

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @bbki.ng/site
2
2
 
3
+ ## 5.5.16
4
+
5
+ ### Patch Changes
6
+
7
+ - 22df0c4: fix global loading
8
+
9
+ ## 5.5.15
10
+
11
+ ### Patch Changes
12
+
13
+ - b377c66: add new plugin
14
+ - Updated dependencies [b377c66]
15
+ - @bbki.ng/ui@0.2.11
16
+
3
17
  ## 5.5.14
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bbki.ng/site",
3
- "version": "5.5.14",
3
+ "version": "5.5.16",
4
4
  "description": "code behind bbki.ng",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "react-dom": "^18.0.0",
15
15
  "react-router-dom": "6",
16
16
  "swr": "^2.2.5",
17
- "@bbki.ng/ui": "0.2.10"
17
+ "@bbki.ng/ui": "0.2.11"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@eslint/compat": "^1.0.0",
Binary file
package/src/blog/app.tsx CHANGED
@@ -11,10 +11,9 @@ import { SWR } from '@/swr';
11
11
  import { GlobalLoadingContext } from '@/context/global_loading_state_provider';
12
12
  import { BotRedirect } from '@/pages/bot';
13
13
  import { BBContext } from '@/context/bbcontext';
14
+ import { Slot } from '#/core/components/SlotComp';
15
+ import { usePlugins } from '#/core/hooks/use_plugins';
14
16
  import { useDynamicLogo } from './hooks/use_dynamic_logo';
15
- import { Slot } from '../core/components/SlotComp';
16
- import { pluginManager } from '#/core/pluginManager';
17
- import { useSticker } from './hooks/use_sticker';
18
17
 
19
18
  const Layout = () => {
20
19
  const paths = usePaths();
@@ -60,7 +59,7 @@ const Layout = () => {
60
59
  };
61
60
 
62
61
  export const App = () => {
63
- useSticker();
62
+ usePlugins(['sticker', 'fontstyler']);
64
63
 
65
64
  return (
66
65
  <SWR>
@@ -1,20 +1,10 @@
1
- import React, { useContext, useEffect } from 'react';
2
- import { GlobalLoadingContext } from '@/context/global_loading_state_provider';
1
+ import React from 'react';
2
+ import { useLoading } from '@/hooks';
3
3
 
4
4
  export const Spinner = (props: { disableDotIndicator?: boolean }) => {
5
5
  const { disableDotIndicator } = props;
6
6
 
7
- const { setIsLoading } = useContext(GlobalLoadingContext);
8
-
9
- useEffect(() => {
10
- if (disableDotIndicator) {
11
- return;
12
- }
13
- setIsLoading(true);
14
- return () => {
15
- setIsLoading(false);
16
- };
17
- }, []);
7
+ useLoading('spinner', !disableDotIndicator);
18
8
 
19
9
  return <div className="h-full w-full grid place-items-center"></div>;
20
10
  };
@@ -1,29 +1,69 @@
1
- import React, { createContext, ReactNode, useState, Dispatch, SetStateAction } from 'react';
1
+ import React, { createContext, ReactNode, useState, useCallback, useMemo } from 'react';
2
2
  import { useFontLoading } from '@/hooks/use_font_loading';
3
3
 
4
+ type LoadingStates = Map<string, boolean>;
5
+
4
6
  type LoadingContext = {
5
7
  isLoading: boolean;
6
8
  isFontLoading: boolean;
7
- setIsLoading: Dispatch<SetStateAction<boolean>>;
9
+ register: (id: string) => void;
10
+ setLoading: (id: string, loading: boolean) => void;
11
+ unregister: (id: string) => void;
8
12
  };
9
13
 
10
14
  export const GlobalLoadingContext = createContext<LoadingContext>({
11
15
  isFontLoading: false,
12
16
  isLoading: false,
13
- setIsLoading: () => false,
17
+ register: () => {},
18
+ setLoading: () => {},
19
+ unregister: () => {},
14
20
  });
15
21
 
16
22
  export const GlobalLoadingStateProvider = (props: { children: ReactNode }) => {
17
- const [isLoading, setIsLoading] = useState(false);
23
+ const [loadingStates, setLoadingStates] = useState<LoadingStates>(new Map());
18
24
  const isFontLoading = useFontLoading();
25
+
26
+ const register = useCallback((id: string) => {
27
+ setLoadingStates(prev => {
28
+ const next = new Map(prev);
29
+ next.set(id, false);
30
+ return next;
31
+ });
32
+ }, []);
33
+
34
+ const setLoading = useCallback((id: string, loading: boolean) => {
35
+ setLoadingStates(prev => {
36
+ const next = new Map(prev);
37
+ next.set(id, loading);
38
+ return next;
39
+ });
40
+ }, []);
41
+
42
+ const unregister = useCallback((id: string) => {
43
+ setLoadingStates(prev => {
44
+ const next = new Map(prev);
45
+ next.delete(id);
46
+ return next;
47
+ });
48
+ }, []);
49
+
50
+ const isLoading = useMemo(() => {
51
+ return Array.from(loadingStates.values()).some(Boolean);
52
+ }, [loadingStates]);
53
+
54
+ const contextValue = useMemo(
55
+ () => ({
56
+ isLoading,
57
+ isFontLoading,
58
+ register,
59
+ setLoading,
60
+ unregister,
61
+ }),
62
+ [isLoading, isFontLoading, register, setLoading, unregister]
63
+ );
64
+
19
65
  return (
20
- <GlobalLoadingContext.Provider
21
- value={{
22
- isLoading,
23
- setIsLoading,
24
- isFontLoading,
25
- }}
26
- >
66
+ <GlobalLoadingContext.Provider value={contextValue}>
27
67
  {props.children}
28
68
  </GlobalLoadingContext.Provider>
29
69
  );
@@ -1,3 +1,4 @@
1
1
  export { usePaths } from './use_paths';
2
2
  export { useStreaming } from './use_streaming';
3
3
  export type { StreamingItem } from './use_streaming';
4
+ export { useLoading } from './use_loading';
@@ -0,0 +1,29 @@
1
+ import { useContext, useEffect } from 'react';
2
+ import { GlobalLoadingContext } from '@/context/global_loading_state_provider';
3
+
4
+ /**
5
+ * Hook to register a loading state with the global loading context.
6
+ * The global loading state is true if ANY registered loading state is true.
7
+ *
8
+ * @param id - Unique identifier for this loading state
9
+ * @param loading - Whether this specific loading state is active
10
+ *
11
+ * @example
12
+ * // In a data fetching hook
13
+ * useLoading('posts', isDataLoading);
14
+ *
15
+ * @example
16
+ * // In a component
17
+ * useLoading('spinner', true);
18
+ */
19
+ export function useLoading(id: string, loading: boolean) {
20
+ const { register, setLoading, unregister } = useContext(GlobalLoadingContext);
21
+
22
+ useEffect(() => {
23
+ register(id);
24
+ setLoading(id, loading);
25
+ return () => {
26
+ unregister(id);
27
+ };
28
+ }, [id, loading, register, setLoading, unregister]);
29
+ }
@@ -1,55 +1,71 @@
1
1
  import useSWR from 'swr';
2
- import { useContext, useEffect } from 'react';
3
- import { GlobalLoadingContext } from '@/context/global_loading_state_provider';
2
+ import { useMemo } from 'react';
3
+ import { useLoading } from '@/hooks';
4
4
  import { baseFetcher } from '@/utils';
5
5
  import { API_ENDPOINT } from '@/constants/routes';
6
+ import { useMiddlewareTransData } from '#/core/hooks';
6
7
 
7
8
  // In dev, use /api prefix to leverage Vite proxy to localhost:8787
8
9
  const isProd = true;
9
10
  // const isProd = typeof window !== 'undefined' && /^https:\/\/bbki\.ng/.test(window.location.href);
10
11
  const POSTS_API = !isProd ? '/api/posts' : `${API_ENDPOINT}/posts`;
11
12
 
12
- // Use baseFetcher for full URLs, cfApiFetcher is for relative paths
13
- const postsFetcher = (resource: string) => baseFetcher(resource);
13
+ export interface TitleListItem {
14
+ name: string;
15
+ to: string;
16
+ children: string;
17
+ className?: string;
18
+ }
14
19
 
15
20
  export const usePosts = (name: string = '', suspense?: boolean) => {
16
- const { data: response, error } = useSWR(POSTS_API, postsFetcher, {
21
+ const { data: response, error: swrError } = useSWR(POSTS_API, baseFetcher, {
17
22
  revalidateOnFocus: false,
18
23
  suspense,
19
24
  });
20
25
 
21
26
  // Extract posts array from API response { status: "success", data: [...] }
22
27
  const data = response?.data;
28
+ const isDataLoading = !data && !swrError;
23
29
 
24
- let isLoading = !data && !error;
25
- const { setIsLoading } = useContext(GlobalLoadingContext);
26
- const titleList =
27
- isLoading || error || !data
28
- ? []
29
- : [
30
- ...data.map((p: any) => ({
31
- name: p.title,
32
- to: p.title,
33
- children: p.title,
34
- })),
35
- {
36
- name: 'cd ~',
37
- to: '/',
38
- children: 'cd ~',
39
- },
40
- ];
41
-
42
- useEffect(() => {
43
- setIsLoading(isLoading);
44
- }, [isLoading]);
30
+ // Build base title list from posts data
31
+ const baseTitleList: TitleListItem[] = useMemo(() => {
32
+ if (!data || swrError) return [];
33
+ return [
34
+ ...data.map((p: any) => ({
35
+ name: p.title,
36
+ to: p.title,
37
+ children: p.title,
38
+ })),
39
+ {
40
+ name: 'cd ~',
41
+ to: '/',
42
+ children: 'cd ~',
43
+ },
44
+ ];
45
+ }, [data, swrError]);
46
+
47
+ // Use middleware hook to transform title list
48
+ const {
49
+ data: titleList,
50
+ loading: isTransforming,
51
+ error: transformError,
52
+ } = useMiddlewareTransData({
53
+ hookPoint: 'transformTitleList',
54
+ baseData: baseTitleList,
55
+ immediate: baseTitleList.length > 0,
56
+ });
57
+
58
+ useLoading('posts', isDataLoading || isTransforming);
45
59
 
46
60
  const posts =
47
- isLoading || name == '' || error || !data ? data : data.find((p: any) => p.title == name);
61
+ isDataLoading || name === '' || swrError || !data
62
+ ? data
63
+ : data.find((p: any) => p.title === name);
48
64
 
49
65
  return {
50
- posts: posts,
51
- titleList,
52
- isError: error,
53
- isLoading: !data && !error,
66
+ posts,
67
+ titleList: titleList ?? [],
68
+ isError: swrError || transformError,
69
+ isLoading: isDataLoading || (baseTitleList.length > 0 && isTransforming),
54
70
  };
55
71
  };
@@ -2,8 +2,8 @@ import useSWR from 'swr';
2
2
  import useSWRInfinite from 'swr/infinite';
3
3
  import { baseFetcher, withBBApi } from '@/utils';
4
4
  import { API_ENDPOINT } from '@/constants/routes';
5
- import { useContext, useEffect, useState, useCallback } from 'react';
6
- import { GlobalLoadingContext } from '@/context/global_loading_state_provider';
5
+ import { useEffect, useState, useCallback } from 'react';
6
+ import { useLoading } from '@/hooks';
7
7
 
8
8
  // In dev, use /api prefix to leverage Vite proxy to localhost:8787
9
9
  // const isProd = typeof window !== 'undefined' && /^https:\/\/bbki.ng/.test(window.location.href);
@@ -101,11 +101,7 @@ export function useStreaming(options: UseStreamingOptions = {}) {
101
101
  });
102
102
  }, []);
103
103
 
104
- const { setIsLoading } = useContext(GlobalLoadingContext);
105
-
106
- useEffect(() => {
107
- setIsLoading(isLoading);
108
- }, [isLoading, setIsLoading]);
104
+ useLoading('streaming', isLoading);
109
105
 
110
106
  return {
111
107
  streaming: data?.data ?? [],
@@ -4,7 +4,7 @@ import { useSlotComp } from '../hooks/useSlotComp';
4
4
 
5
5
  export interface ISlotProps {
6
6
  name: SlotName;
7
- data?: any;
7
+ data?: unknown;
8
8
  }
9
9
 
10
10
  export const Slot: React.FC<ISlotProps> = ({ name, data }) => {
@@ -0,0 +1,6 @@
1
+ export { useSlotComp } from './useSlotComp';
2
+ export { useMiddlewareTransData } from './useMiddlewareTransData';
3
+ export type {
4
+ UseMiddlewareTransDataOptions,
5
+ UseMiddlewareTransDataResult,
6
+ } from './useMiddlewareTransData';
@@ -0,0 +1,68 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+
3
+ import { registry } from '#/core/registry';
4
+ import type { HookPoint } from '#/types/slots';
5
+
6
+ export interface UseMiddlewareTransDataOptions<T> {
7
+ hookPoint: HookPoint;
8
+ baseData: T;
9
+ immediate?: boolean;
10
+ }
11
+
12
+ export interface UseMiddlewareTransDataResult<T> {
13
+ data: T | undefined;
14
+ loading: boolean;
15
+ error: Error | null;
16
+ run: () => Promise<T>;
17
+ }
18
+
19
+ export function useMiddlewareTransData<T>({
20
+ hookPoint,
21
+ baseData,
22
+ immediate = true,
23
+ }: UseMiddlewareTransDataOptions<T>): UseMiddlewareTransDataResult<T> {
24
+ const [data, setData] = useState<T | undefined>(undefined);
25
+ const [loading, setLoading] = useState(false);
26
+ const [error, setError] = useState<Error | null>(null);
27
+
28
+ const immediateRef = useRef(immediate);
29
+ immediateRef.current = immediate;
30
+
31
+ const run = useCallback(async (): Promise<T> => {
32
+ setLoading(true);
33
+ setError(null);
34
+
35
+ try {
36
+ const result = await registry.runMiddleware(hookPoint, baseData);
37
+ setData(result);
38
+ return result;
39
+ } catch (err) {
40
+ const error = err instanceof Error ? err : new Error(String(err));
41
+ setError(error);
42
+ throw error;
43
+ } finally {
44
+ setLoading(false);
45
+ }
46
+ }, [hookPoint, baseData]);
47
+
48
+ useEffect(() => {
49
+ if (!immediateRef.current) return;
50
+
51
+ let cancelled = false;
52
+
53
+ run()
54
+ .then(result => {
55
+ if (cancelled) return;
56
+ setData(result);
57
+ })
58
+ .catch(() => {
59
+ // 错误已在 run 中设置到 state
60
+ });
61
+
62
+ return () => {
63
+ cancelled = true;
64
+ };
65
+ }, [hookPoint, baseData, run]);
66
+
67
+ return { data, loading, error, run };
68
+ }
@@ -0,0 +1,28 @@
1
+ import { useEffect } from 'react';
2
+
3
+ import { pluginManager } from '#/core/pluginManager';
4
+
5
+ /**
6
+ * 通用插件管理 hook
7
+ * 根据传入的插件ID列表加载和卸载插件
8
+ *
9
+ * @param pluginIds - 要加载的插件ID列表
10
+ *
11
+ * @example
12
+ * usePlugins(['sticker', 'fontstyler']);
13
+ */
14
+ export const usePlugins = (pluginIds: string[]) => {
15
+ useEffect(() => {
16
+ // 加载指定的插件
17
+ pluginIds.forEach(id => {
18
+ pluginManager.loadPlugin(id);
19
+ });
20
+
21
+ return () => {
22
+ // 卸载所有指定的插件
23
+ pluginIds.forEach(id => {
24
+ pluginManager.disablePlugin(id);
25
+ });
26
+ };
27
+ }, [pluginIds]);
28
+ };
@@ -1,3 +1,5 @@
1
+ import React from 'react';
2
+
1
3
  import { IHostContext } from '#/types/hostApi';
2
4
  import { registry } from './registry';
3
5
  import type { SlotName, HookPoint } from '#/types/slots';
@@ -0,0 +1,132 @@
1
+ import { IHostContext } from '#/types/hostApi';
2
+ import { IPlugin } from '#/types/plugin';
3
+
4
+ export interface TitleListItem {
5
+ name: string;
6
+ to: string;
7
+ children: string;
8
+ className?: string;
9
+ }
10
+
11
+ export interface FontConfig {
12
+ name: string;
13
+ src: string;
14
+ format: 'woff2' | 'woff' | 'ttf' | 'otf';
15
+ fontWeight?: string;
16
+ fontStyle?: string;
17
+ }
18
+
19
+ export interface FontRule {
20
+ match: string | RegExp;
21
+ fontFamily: string;
22
+ fontConfig?: FontConfig;
23
+ extraCls?: string;
24
+ }
25
+
26
+ class FontStylerPlugin implements IPlugin {
27
+ id = 'fontstyler';
28
+ name = 'Font Styler';
29
+ description = 'Apply custom fonts to specific post titles';
30
+ version = '1.0.0';
31
+ author = 'bbki.ng';
32
+
33
+ /**
34
+ * 配置:标题匹配规则 → 字体配置
35
+ * 支持精确字符串匹配或正则表达式匹配
36
+ *
37
+ * 示例:
38
+ * - 精确匹配: { match: '某篇文章标题', fontFamily: 'CustomFont1', fontConfig: {...} }
39
+ * - 正则匹配: { match: /^示例/, fontFamily: 'CustomFont2', fontConfig: {...} }
40
+ */
41
+ private fontRules: FontRule[] = [
42
+ {
43
+ match: '小乌鸦合集',
44
+ fontFamily: 'xwy',
45
+ fontConfig: {
46
+ name: 'xwy',
47
+ src: '/fonts/xwy.woff2',
48
+ format: 'woff2',
49
+ },
50
+ extraCls: 'text-2xl',
51
+ },
52
+ ];
53
+
54
+ private loadedFonts = new Set<string>();
55
+
56
+ onInstall = (ctx: IHostContext) => {
57
+ // 加载所有需要的字体文件
58
+ this.fontRules.forEach(rule => {
59
+ if (rule.fontConfig && !this.loadedFonts.has(rule.fontConfig.name)) {
60
+ this.loadFont(rule.fontConfig);
61
+ this.loadedFonts.add(rule.fontConfig.name);
62
+ }
63
+ });
64
+
65
+ ctx.api.registerMiddleware(
66
+ 'transformTitleList',
67
+ this.transformTitleList,
68
+ this.id,
69
+ 10 // weight
70
+ );
71
+ };
72
+
73
+ /**
74
+ * 动态加载字体文件
75
+ * 通过创建 style 标签添加 @font-face 规则
76
+ */
77
+ private loadFont = (config: FontConfig) => {
78
+ const styleId = `font-${config.name}`;
79
+ if (document.getElementById(styleId)) return;
80
+
81
+ const style = document.createElement('style');
82
+ style.id = styleId;
83
+ style.textContent = `
84
+ @font-face {
85
+ font-family: '${config.name}';
86
+ src: url('${config.src}') format('${config.format}');
87
+ font-weight: ${config.fontWeight || 'normal'};
88
+ font-style: ${config.fontStyle || 'normal'};
89
+ font-display: swap;
90
+ }
91
+ .font-${config.name} {
92
+ font-family: '${config.name}', monospace;
93
+ }
94
+ `;
95
+ document.head.appendChild(style);
96
+ };
97
+
98
+ /**
99
+ * 转换标题列表,为匹配的标题添加字体类名
100
+ */
101
+ private transformTitleList = (titleList: TitleListItem[]): TitleListItem[] => {
102
+ return titleList.map(item => {
103
+ for (const rule of this.fontRules) {
104
+ const match =
105
+ typeof rule.match === 'string' ? item.name === rule.match : rule.match.test(item.name);
106
+
107
+ if (match) {
108
+ return {
109
+ ...item,
110
+ className: `font-${rule.fontFamily} ${rule.extraCls || ''}`.trim(),
111
+ };
112
+ }
113
+ }
114
+ return item;
115
+ });
116
+ };
117
+
118
+ onDisable = async () => {
119
+ // 清理加载的字体样式
120
+ this.loadedFonts.forEach(fontName => {
121
+ const styleEl = document.getElementById(`font-${fontName}`);
122
+ if (styleEl) {
123
+ styleEl.remove();
124
+ }
125
+ });
126
+ this.loadedFonts.clear();
127
+ };
128
+
129
+ onDestroy = () => {};
130
+ }
131
+
132
+ export default new FontStylerPlugin();
@@ -11,4 +11,10 @@ export const PLUGIN_MANIFEST = [
11
11
  version: '0.1.0',
12
12
  description: 'A sticker plugin',
13
13
  },
14
+ {
15
+ name: 'fontstyler',
16
+ id: 'fontstyler',
17
+ version: '0.1.0',
18
+ description: 'Apply custom fonts to specific post',
19
+ },
14
20
  ];
@@ -1,12 +1,19 @@
1
+ import React from 'react';
2
+
1
3
  import { HookPoint, SlotName } from './slots';
2
4
  import { type FingerprintData } from '@/utils/fingerprints';
3
5
 
4
6
  export interface IHostApi {
5
7
  getDeviceId: () => Promise<{ id: string; fp: FingerprintData }>;
6
- registerMiddleware: (point: HookPoint, fn: Function, pluginId: string, weight?: number) => void;
7
- registerSlot: (
8
+ registerMiddleware: <T>(
9
+ point: HookPoint,
10
+ fn: (baseData: T) => T,
11
+ pluginId: string,
12
+ weight?: number
13
+ ) => void;
14
+ registerSlot: <T>(
8
15
  slotName: SlotName,
9
- component: React.ComponentType<any>,
16
+ component: React.ComponentType<T>,
10
17
  pluginId: string,
11
18
  weight?: number
12
19
  ) => void;
@@ -1,2 +1,2 @@
1
1
  export type SlotName = 'leftCol' | 'rightCol' | 'articleActionRow';
2
- export type HookPoint = 'filterPosts' | 'transformPostContent';
2
+ export type HookPoint = 'filterPosts' | 'transformPostContent' | 'transformTitleList';