@autional-cn/shared 0.1.0-rc
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/LICENSE +661 -0
- package/package.json +47 -0
- package/src/api/__tests__/entry-plane.test.ts +119 -0
- package/src/api/bff-client.ts +94 -0
- package/src/api/client.ts +174 -0
- package/src/api/entry-plane.ts +61 -0
- package/src/api/trace.ts +21 -0
- package/src/auth/__tests__/ac007-role-source.test.ts +124 -0
- package/src/auth/__tests__/auth-machine.test.ts +157 -0
- package/src/auth/__tests__/build-login-url.test.ts +219 -0
- package/src/auth/__tests__/get-current-role.test.ts +61 -0
- package/src/auth/__tests__/oauth-client-from-slug.test.ts +132 -0
- package/src/auth/__tests__/pkce-uniformity.test.ts +48 -0
- package/src/auth/__tests__/session-client-id.test.ts +124 -0
- package/src/auth/__tests__/slug-from-url.test.ts +112 -0
- package/src/auth/auth-machine.ts +151 -0
- package/src/auth/auth-queries.ts +118 -0
- package/src/auth/build-tenant-urls.ts +48 -0
- package/src/auth/oauth-client-from-slug.ts +119 -0
- package/src/auth/oauth-client-id-store.ts +41 -0
- package/src/auth/oauth-login.ts +291 -0
- package/src/auth/pkce.ts +141 -0
- package/src/auth/portal-config.ts +68 -0
- package/src/auth/roles.ts +102 -0
- package/src/auth/service.ts +368 -0
- package/src/auth/slug-from-url.ts +92 -0
- package/src/auth/store.ts +226 -0
- package/src/auth/tenant-route-middleware.ts +113 -0
- package/src/auth/tenant-slug-context.tsx +9 -0
- package/src/branding/BrandingInitializer.tsx +60 -0
- package/src/branding/brand-color.ts +155 -0
- package/src/branding/branding-cache.ts +71 -0
- package/src/branding/extract-branding.ts +27 -0
- package/src/branding/index.ts +15 -0
- package/src/branding/tenant-store.ts +13 -0
- package/src/branding/types.ts +14 -0
- package/src/branding/useBranding.ts +85 -0
- package/src/components/AuditStatsOnly.tsx +65 -0
- package/src/components/OAuthCallbackPage.tsx +56 -0
- package/src/components/PortalGuard.tsx +105 -0
- package/src/components/RequireAuth.tsx +142 -0
- package/src/components/TenantIndexGuard.tsx +131 -0
- package/src/components/__tests__/RequireAuth.test.tsx +244 -0
- package/src/components/__tests__/TenantIndexGuard.test.tsx +218 -0
- package/src/config/__tests__/portal-resolver.test.ts +247 -0
- package/src/config/__tests__/valid-redirect.test.ts +47 -0
- package/src/config/constants.ts +27 -0
- package/src/config/index.ts +411 -0
- package/src/config/resolve-basename.ts +57 -0
- package/src/constants/api-paths.ts +450 -0
- package/src/generated/api.ts +12566 -0
- package/src/generated/queries.ts +12623 -0
- package/src/generated/types.ts +13888 -0
- package/src/hooks/__tests__/useBootstrap.test.tsx +91 -0
- package/src/hooks/__tests__/useCurrentRole.test.tsx +122 -0
- package/src/hooks/useAuth.ts +239 -0
- package/src/hooks/useBootstrap.ts +74 -0
- package/src/hooks/useCurrentRole.ts +18 -0
- package/src/hooks/useLogout.ts +33 -0
- package/src/hooks/usePermission.ts +116 -0
- package/src/hooks/useSodMode.ts +73 -0
- package/src/index.ts +209 -0
- package/src/lib/push.ts +41 -0
- package/src/seo/dom-utils.ts +94 -0
- package/src/seo/index.ts +6 -0
- package/src/seo/jsonld.ts +29 -0
- package/src/seo/types.ts +29 -0
- package/src/seo/usePageMeta.ts +17 -0
- package/src/seo/usePageTitle.ts +14 -0
- package/src/seo/useSEO.ts +80 -0
- package/src/types/index.ts +185 -0
- package/src/utils/__tests__/mock-test.ts +15 -0
- package/src/utils/browser.ts +6 -0
- package/src/utils/case.ts +44 -0
- package/src/utils/error.ts +26 -0
- package/src/utils/format.ts +63 -0
- package/src/utils/password-transmission.test.ts +145 -0
- package/src/utils/password-transmission.ts +253 -0
- package/src/utils/response.ts +62 -0
- package/src/utils/vite.ts +4 -0
- package/src/vite-env.d.ts +18 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSodMode — 租户 SoD 模式 Hook
|
|
3
|
+
*
|
|
4
|
+
* 通过 tenant-service API 读取当前租户的 sod_mode 配置,
|
|
5
|
+
* 决定 `admin` 角色对审计功能的可见性。
|
|
6
|
+
*
|
|
7
|
+
* 模式:
|
|
8
|
+
* 'single' — 小客户模式(默认): admin 可看 L1 统计 + L2 操作反馈
|
|
9
|
+
* 'strict' — 大客户/受监管: admin 仅看 L1 统计摘要
|
|
10
|
+
*
|
|
11
|
+
* API: GET /api/v1/admin/tenants/{tenantSlug}/sod-config
|
|
12
|
+
* PUT /api/v1/admin/tenants/{tenantSlug}/sod-config
|
|
13
|
+
*
|
|
14
|
+
* @see document/architecture/decisions/007-security-dashboard-audit.md §2.3
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use client';
|
|
18
|
+
|
|
19
|
+
import { useMemo } from 'react';
|
|
20
|
+
import { useQuery } from '@tanstack/react-query';
|
|
21
|
+
import { apiClient } from '../api/client';
|
|
22
|
+
import { extractItem } from '../utils/response';
|
|
23
|
+
import { useTenantSlug } from '../auth/tenant-slug-context';
|
|
24
|
+
import { useCurrentRole } from './useCurrentRole';
|
|
25
|
+
|
|
26
|
+
export type SodMode = 'single' | 'strict';
|
|
27
|
+
|
|
28
|
+
interface SodConfigResponse {
|
|
29
|
+
tenant_id: string;
|
|
30
|
+
sod_mode: SodMode;
|
|
31
|
+
updated_at: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 返回当前用户的 sod_mode。
|
|
36
|
+
* 从 tenant-service 的 /api/v1/admin/tenants/{slug}/sod-config 读取。
|
|
37
|
+
* 失败或未配置时默认返回 'single'。
|
|
38
|
+
*/
|
|
39
|
+
export function useSodMode(): SodMode {
|
|
40
|
+
const tenantSlug = useTenantSlug();
|
|
41
|
+
|
|
42
|
+
const { data: config } = useQuery<SodConfigResponse | null>({
|
|
43
|
+
queryKey: ['sod-config', tenantSlug],
|
|
44
|
+
queryFn: () => fetchSodConfig(tenantSlug),
|
|
45
|
+
staleTime: 5 * 60 * 1000, // 5 min cache
|
|
46
|
+
retry: 1,
|
|
47
|
+
enabled: !!tenantSlug,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return config?.sod_mode ?? 'single';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function fetchSodConfig(slug: string | undefined): Promise<SodConfigResponse | null> {
|
|
54
|
+
if (!slug) return null;
|
|
55
|
+
try {
|
|
56
|
+
const res = await apiClient.get(`/api/v1/admin/tenants/${slug}/sod-config`);
|
|
57
|
+
return extractItem<SodConfigResponse>(res.data);
|
|
58
|
+
} catch {
|
|
59
|
+
return null; // 默认 single
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 判断当前用户在 strict 模式下是否被限制查看审计详情。
|
|
65
|
+
*/
|
|
66
|
+
export function useIsAuditRestricted(): boolean {
|
|
67
|
+
const sodMode = useSodMode();
|
|
68
|
+
const role = useCurrentRole();
|
|
69
|
+
|
|
70
|
+
return useMemo(() => {
|
|
71
|
+
return sodMode === 'strict' && role === 'admin';
|
|
72
|
+
}, [sodMode, role]);
|
|
73
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// API
|
|
2
|
+
export { apiClient, createApiClient } from './api/client';
|
|
3
|
+
export {
|
|
4
|
+
setTraceParentProvider,
|
|
5
|
+
getTraceParentHeader,
|
|
6
|
+
resetSessionTraceId,
|
|
7
|
+
enableOtelBridge,
|
|
8
|
+
} from './api/trace';
|
|
9
|
+
export { bffLogin, bffRefresh, bffLogout, isBFFAvailable } from './api/bff-client';
|
|
10
|
+
|
|
11
|
+
// Auth
|
|
12
|
+
export {
|
|
13
|
+
useAuthStore,
|
|
14
|
+
getAccessToken,
|
|
15
|
+
getRefreshToken,
|
|
16
|
+
getCurrentTenantId,
|
|
17
|
+
getCurrentRole,
|
|
18
|
+
loginWithTokens,
|
|
19
|
+
logout,
|
|
20
|
+
isTokenExpired,
|
|
21
|
+
refreshAccessToken,
|
|
22
|
+
} from './auth/store';
|
|
23
|
+
export { AuthService } from './auth/service';
|
|
24
|
+
export { generatePKCE, generateCodeChallenge } from './auth/pkce';
|
|
25
|
+
export type { PKCEPair } from './auth/pkce';
|
|
26
|
+
export { initiateOAuthLogin, handleOAuthCallback, isOAuthEnabled } from './auth/oauth-login';
|
|
27
|
+
export { buildLoginUrl } from './auth/roles';
|
|
28
|
+
|
|
29
|
+
// Types
|
|
30
|
+
export type {
|
|
31
|
+
User,
|
|
32
|
+
LoginRequest,
|
|
33
|
+
LoginResponse,
|
|
34
|
+
RegisterRequest,
|
|
35
|
+
Tenant,
|
|
36
|
+
TenantMember,
|
|
37
|
+
Role,
|
|
38
|
+
Permission,
|
|
39
|
+
Application,
|
|
40
|
+
AuditLog,
|
|
41
|
+
Session,
|
|
42
|
+
Department,
|
|
43
|
+
ListResponse,
|
|
44
|
+
PageRequest,
|
|
45
|
+
ApiError,
|
|
46
|
+
ApiResponse,
|
|
47
|
+
} from './types';
|
|
48
|
+
|
|
49
|
+
// Hooks
|
|
50
|
+
export {
|
|
51
|
+
useAuth,
|
|
52
|
+
useAuthActions,
|
|
53
|
+
useIsAuthenticated,
|
|
54
|
+
useUser,
|
|
55
|
+
useUserId,
|
|
56
|
+
useAccessToken,
|
|
57
|
+
useAuthPermissions,
|
|
58
|
+
useCurrentTenantId,
|
|
59
|
+
useTenants,
|
|
60
|
+
} from './hooks/useAuth';
|
|
61
|
+
export type { AuthSnapshot, AuthActions } from './hooks/useAuth';
|
|
62
|
+
export { usePermission } from './hooks/usePermission';
|
|
63
|
+
export type { PermissionResult } from './hooks/usePermission';
|
|
64
|
+
export { useLogout } from './hooks/useLogout';
|
|
65
|
+
export { useBootstrap } from './hooks/useBootstrap';
|
|
66
|
+
export type { BootstrapState } from './hooks/useBootstrap';
|
|
67
|
+
export { useSodMode, useIsAuditRestricted } from './hooks/useSodMode';
|
|
68
|
+
export type { SodMode } from './hooks/useSodMode';
|
|
69
|
+
export { useCurrentRole } from './hooks/useCurrentRole';
|
|
70
|
+
|
|
71
|
+
// Constants
|
|
72
|
+
export { API as API_PATHS } from './constants/api-paths';
|
|
73
|
+
|
|
74
|
+
// Config
|
|
75
|
+
export {
|
|
76
|
+
getAUTH_PAGES_URL as AUTH_PAGES_URL,
|
|
77
|
+
getAUTH_PAGES_URL,
|
|
78
|
+
getADMIN_CONSOLE_URL as ADMIN_CONSOLE_URL,
|
|
79
|
+
getADMIN_CONSOLE_URL,
|
|
80
|
+
getDEVELOPER_PORTAL_URL as DEVELOPER_PORTAL_URL,
|
|
81
|
+
getDEVELOPER_PORTAL_URL,
|
|
82
|
+
getEND_USER_PORTAL_URL as END_USER_PORTAL_URL,
|
|
83
|
+
getEND_USER_PORTAL_URL,
|
|
84
|
+
getSECURITY_DASHBOARD_URL as SECURITY_DASHBOARD_URL,
|
|
85
|
+
getSECURITY_DASHBOARD_URL,
|
|
86
|
+
getSTATUS_PAGE_URL as STATUS_PAGE_URL,
|
|
87
|
+
getSTATUS_PAGE_URL,
|
|
88
|
+
getLANDING_SITE_URL as LANDING_SITE_URL,
|
|
89
|
+
getLANDING_SITE_URL,
|
|
90
|
+
getAUTHENTICATOR_APP_URL as AUTHENTICATOR_APP_URL,
|
|
91
|
+
getAUTHENTICATOR_APP_URL,
|
|
92
|
+
getTRUST_CENTER_URL as TRUST_CENTER_URL,
|
|
93
|
+
getTRUST_CENTER_URL,
|
|
94
|
+
getPLATFORM_CONSOLE_URL as PLATFORM_CONSOLE_URL,
|
|
95
|
+
getPLATFORM_CONSOLE_URL,
|
|
96
|
+
API_BASE_URL,
|
|
97
|
+
BASE_PATH,
|
|
98
|
+
ROUTER_BASENAME,
|
|
99
|
+
getRouterBasename,
|
|
100
|
+
VITE_BASE,
|
|
101
|
+
appPath,
|
|
102
|
+
navigateTo,
|
|
103
|
+
crossAppUrl,
|
|
104
|
+
isValidRedirect,
|
|
105
|
+
// Portal URL resolver (VITE_PORTAL_CONFIG driven)
|
|
106
|
+
getRootDomain,
|
|
107
|
+
getPortalUrl,
|
|
108
|
+
getAppPortalSlugs,
|
|
109
|
+
} from './config';
|
|
110
|
+
export {
|
|
111
|
+
SITE_DOMAIN,
|
|
112
|
+
SITE_EMAIL,
|
|
113
|
+
SITE_BASE_URL,
|
|
114
|
+
AUTH_DOMAIN,
|
|
115
|
+
USER_DOMAIN,
|
|
116
|
+
APP_DOMAIN,
|
|
117
|
+
AUTHENTICATOR_DOMAIN,
|
|
118
|
+
AUTH_BASE_URL,
|
|
119
|
+
USER_BASE_URL,
|
|
120
|
+
APP_BASE_URL,
|
|
121
|
+
AUTHENTICATOR_BASE_URL,
|
|
122
|
+
DEFAULT_OG_IMAGE,
|
|
123
|
+
DEFAULT_LOGO,
|
|
124
|
+
} from './config/constants';
|
|
125
|
+
|
|
126
|
+
// SEO
|
|
127
|
+
export * from './seo';
|
|
128
|
+
|
|
129
|
+
// Components
|
|
130
|
+
export { RequireAuth } from './components/RequireAuth';
|
|
131
|
+
export { OAuthCallbackPage } from './components/OAuthCallbackPage';
|
|
132
|
+
export { TenantSlugProvider, useTenantSlug } from './auth/tenant-slug-context';
|
|
133
|
+
export { useTenantSlugFromUrl, extractSlugFromPath, registerNonTenantSegments, clearNonTenantSegments } from './auth/slug-from-url';
|
|
134
|
+
export { useOAuthClientIdFromUrl, fetchOAuthClientIdBySlug } from './auth/oauth-client-from-slug';
|
|
135
|
+
export {
|
|
136
|
+
useTenantRoute,
|
|
137
|
+
resolveEffectiveClientId,
|
|
138
|
+
isSameDomainOAuth,
|
|
139
|
+
} from './auth/tenant-route-middleware';
|
|
140
|
+
export {
|
|
141
|
+
usePermissionsQuery,
|
|
142
|
+
useTenantsQuery,
|
|
143
|
+
useMeQuery,
|
|
144
|
+
AUTH_DATA_STALE_TIME,
|
|
145
|
+
} from './auth/auth-queries';
|
|
146
|
+
export { authQueryKeys } from './auth/auth-queries';
|
|
147
|
+
export type { TenantRouteResult } from './auth/tenant-route-middleware';
|
|
148
|
+
export {
|
|
149
|
+
AdminGuard,
|
|
150
|
+
UserMgmtGuard,
|
|
151
|
+
SecurityGuard,
|
|
152
|
+
SecurityAdminGuard,
|
|
153
|
+
AuditorGuard,
|
|
154
|
+
PlatformGuard,
|
|
155
|
+
AuthGuard,
|
|
156
|
+
} from './components/PortalGuard';
|
|
157
|
+
export { AuditStatsOnly } from './components/AuditStatsOnly';
|
|
158
|
+
export { TenantIndexGuard, usePublicTenantSlugs } from './components/TenantIndexGuard';
|
|
159
|
+
export type { TenantIndexGuardProps } from './components/TenantIndexGuard';
|
|
160
|
+
export { buildTenantUrls } from './auth/build-tenant-urls';
|
|
161
|
+
export { resolvePortalBasename } from './config/resolve-basename';
|
|
162
|
+
|
|
163
|
+
// 这里原来有 4 行 re-export:GeneratedApi / ApiGenerated / GeneratedTypes / ApiTypes,
|
|
164
|
+
// 全部指向 @autional-cn/api-generated。2026-09 实测:**全舰队 0 处使用它们**
|
|
165
|
+
// (唯一的出现就是这 4 行自己),而它们正是那份 file: 依赖的唯一消费者。
|
|
166
|
+
// 删掉之后 shared 才能作为 npm 包发布——否则发布出去的清单里会留着一条
|
|
167
|
+
// file:../../scripts/generate/api-generated-package,消费方永远解析不到。
|
|
168
|
+
// 真正的生成代码走的是 @autional-cn/shared/generated/{api,types,queries}(src/generated/*),
|
|
169
|
+
// 五个子路径里全舰队都在用的是那一组,不是这一组。
|
|
170
|
+
|
|
171
|
+
// Branding(租户品牌 → CSS 变量 / favicon / customCss)
|
|
172
|
+
export {
|
|
173
|
+
useBranding,
|
|
174
|
+
applyBrandColors,
|
|
175
|
+
BrandingInitializer,
|
|
176
|
+
useTenantBrandingStore,
|
|
177
|
+
extractBranding,
|
|
178
|
+
readCachedBranding,
|
|
179
|
+
writeCachedBranding,
|
|
180
|
+
BRANDING_CACHE_PREFIX,
|
|
181
|
+
} from './branding';
|
|
182
|
+
export type { Branding } from './branding';
|
|
183
|
+
|
|
184
|
+
// Lib
|
|
185
|
+
export { getVapidPublicKey, subscribeBrowserPush, unsubscribeBrowserPush } from './lib/push';
|
|
186
|
+
|
|
187
|
+
// Utils
|
|
188
|
+
export { urlBase64ToUint8Array } from './utils/browser';
|
|
189
|
+
export { camelCaseKeys, snakeCaseKeys } from './utils/case';
|
|
190
|
+
export { extractApiErrorMessage, extractApiError, createHandleApiError } from './utils/error';
|
|
191
|
+
export {
|
|
192
|
+
extractList,
|
|
193
|
+
extractItem,
|
|
194
|
+
extractPagination,
|
|
195
|
+
extractListResult,
|
|
196
|
+
fetchList,
|
|
197
|
+
fetchItem,
|
|
198
|
+
fetchListResult,
|
|
199
|
+
} from './utils/response';
|
|
200
|
+
export { formatTime, formatDate, formatRelativeTime, setLocaleGetter } from './utils/format';
|
|
201
|
+
export { normalizeViteBase } from './utils/vite';
|
|
202
|
+
export {
|
|
203
|
+
processPasswordForTransmission,
|
|
204
|
+
hashPasswordForTransmission,
|
|
205
|
+
} from './utils/password-transmission';
|
|
206
|
+
export type { TransmissionResult } from './utils/password-transmission';
|
|
207
|
+
export type { ExtractedApiError } from './utils/error';
|
|
208
|
+
export type { PaginationInfo, ListResult } from './utils/response';
|
|
209
|
+
// force rebuild 1781616516
|
package/src/lib/push.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { pushVapidPublicKey } from '../generated/api';
|
|
2
|
+
import { extractItem } from '../utils/response';
|
|
3
|
+
import { urlBase64ToUint8Array } from '../utils/browser';
|
|
4
|
+
|
|
5
|
+
export async function getVapidPublicKey(): Promise<string> {
|
|
6
|
+
const res = await pushVapidPublicKey();
|
|
7
|
+
const data = extractItem(res) as { public_key?: string } | null;
|
|
8
|
+
if (!data?.public_key) {
|
|
9
|
+
throw new Error('Failed to get VAPID public key');
|
|
10
|
+
}
|
|
11
|
+
return data.public_key;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function subscribeBrowserPush(
|
|
15
|
+
vapidPublicKey: string,
|
|
16
|
+
): Promise<PushSubscription | null> {
|
|
17
|
+
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null;
|
|
18
|
+
const registration = await navigator.serviceWorker.ready;
|
|
19
|
+
const existing = await registration.pushManager.getSubscription();
|
|
20
|
+
if (existing) return existing;
|
|
21
|
+
try {
|
|
22
|
+
return await registration.pushManager.subscribe({
|
|
23
|
+
userVisibleOnly: true,
|
|
24
|
+
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
|
25
|
+
});
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error('Push subscription failed:', err);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function unsubscribeBrowserPush(): Promise<boolean> {
|
|
33
|
+
if (!('serviceWorker' in navigator)) return false;
|
|
34
|
+
const registration = await navigator.serviceWorker.ready;
|
|
35
|
+
const sub = await registration.pushManager.getSubscription();
|
|
36
|
+
if (sub) {
|
|
37
|
+
await sub.unsubscribe();
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export function upsertMeta(
|
|
2
|
+
attribute: string,
|
|
3
|
+
value: string,
|
|
4
|
+
attrName: 'name' | 'property' = 'name',
|
|
5
|
+
): () => void {
|
|
6
|
+
const selector = `meta[${attrName}="${attribute}"]`;
|
|
7
|
+
const el = document.querySelector<HTMLMetaElement>(selector);
|
|
8
|
+
const previous = el?.content;
|
|
9
|
+
|
|
10
|
+
if (el) {
|
|
11
|
+
el.content = value;
|
|
12
|
+
} else {
|
|
13
|
+
const meta = document.createElement('meta');
|
|
14
|
+
meta.setAttribute(attrName, attribute);
|
|
15
|
+
meta.content = value;
|
|
16
|
+
document.head.appendChild(meta);
|
|
17
|
+
return () => {
|
|
18
|
+
meta.remove();
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return () => {
|
|
23
|
+
if (previous !== undefined) {
|
|
24
|
+
el.content = previous;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function upsertCanonical(href: string): () => void {
|
|
30
|
+
let link = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
|
31
|
+
const previous = link?.href;
|
|
32
|
+
|
|
33
|
+
if (link) {
|
|
34
|
+
link.href = href;
|
|
35
|
+
} else {
|
|
36
|
+
link = document.createElement('link');
|
|
37
|
+
link.rel = 'canonical';
|
|
38
|
+
link.href = href;
|
|
39
|
+
document.head.appendChild(link);
|
|
40
|
+
return () => {
|
|
41
|
+
link!.remove();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
if (previous !== undefined && link) {
|
|
47
|
+
link.href = previous;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function upsertLink(
|
|
53
|
+
rel: string,
|
|
54
|
+
href: string,
|
|
55
|
+
extraAttrs?: Record<string, string>,
|
|
56
|
+
): () => void {
|
|
57
|
+
const selector = `link[rel="${rel}"]${extraAttrs?.hreflang ? `[hreflang="${extraAttrs.hreflang}"]` : ''}`;
|
|
58
|
+
const existing = document.querySelector<HTMLLinkElement>(selector);
|
|
59
|
+
const previous = existing?.href;
|
|
60
|
+
|
|
61
|
+
if (existing) {
|
|
62
|
+
existing.href = href;
|
|
63
|
+
return () => {
|
|
64
|
+
if (previous !== undefined) {
|
|
65
|
+
existing.href = previous;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
} else {
|
|
69
|
+
const link = document.createElement('link');
|
|
70
|
+
link.rel = rel;
|
|
71
|
+
link.href = href;
|
|
72
|
+
if (extraAttrs) {
|
|
73
|
+
Object.entries(extraAttrs).forEach(([k, v]) => link.setAttribute(k, v));
|
|
74
|
+
}
|
|
75
|
+
document.head.appendChild(link);
|
|
76
|
+
return () => {
|
|
77
|
+
link.remove();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function injectJSONLD(schemas: object[]): () => void {
|
|
83
|
+
schemas.forEach((schema, idx) => {
|
|
84
|
+
const script = document.createElement('script');
|
|
85
|
+
script.type = 'application/ld+json';
|
|
86
|
+
script.setAttribute('data-seo-jsonld', String(idx));
|
|
87
|
+
script.textContent = JSON.stringify(schema);
|
|
88
|
+
document.head.appendChild(script);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return () => {
|
|
92
|
+
document.querySelectorAll('script[data-seo-jsonld]').forEach((el) => el.remove());
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/seo/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { useSEO } from './useSEO';
|
|
2
|
+
export { usePageTitle } from './usePageTitle';
|
|
3
|
+
export { usePageMeta } from './usePageMeta';
|
|
4
|
+
export { getArticleSchema } from './jsonld';
|
|
5
|
+
export { upsertMeta, upsertCanonical, upsertLink, injectJSONLD } from './dom-utils';
|
|
6
|
+
export type { SEOConfig, SiteSEOConfig, BlogPostSchema } from './types';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BlogPostSchema } from './types';
|
|
2
|
+
|
|
3
|
+
export function getArticleSchema(post: BlogPostSchema, baseBlogUrl: string) {
|
|
4
|
+
const article: Record<string, unknown> = {
|
|
5
|
+
'@context': 'https://schema.org',
|
|
6
|
+
'@type': 'BlogPosting',
|
|
7
|
+
headline: post.title,
|
|
8
|
+
description: post.excerpt,
|
|
9
|
+
datePublished: post.date,
|
|
10
|
+
url: `${baseBlogUrl}/${post.slug}`,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
if (post.author) {
|
|
14
|
+
article.author = {
|
|
15
|
+
'@type': 'Person',
|
|
16
|
+
name: post.author,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (post.image) {
|
|
21
|
+
article.image = post.image;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (post.tags && post.tags.length > 0) {
|
|
25
|
+
article.keywords = post.tags.join(', ');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return article;
|
|
29
|
+
}
|
package/src/seo/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface SiteSEOConfig {
|
|
2
|
+
siteName?: string;
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
defaultOgImage?: string;
|
|
5
|
+
hreflang?: boolean;
|
|
6
|
+
hreflangLanguages?: string[];
|
|
7
|
+
hreflangXDefault?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SEOConfig {
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
ogImage?: string;
|
|
14
|
+
canonical?: string;
|
|
15
|
+
type?: 'website' | 'article';
|
|
16
|
+
publishedTime?: string;
|
|
17
|
+
author?: string;
|
|
18
|
+
jsonld?: object[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface BlogPostSchema {
|
|
22
|
+
title: string;
|
|
23
|
+
excerpt: string;
|
|
24
|
+
date: string;
|
|
25
|
+
author?: string;
|
|
26
|
+
image?: string;
|
|
27
|
+
slug: string;
|
|
28
|
+
tags?: string[];
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export function usePageMeta(description: string) {
|
|
4
|
+
useEffect(() => {
|
|
5
|
+
let meta = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
|
|
6
|
+
const previous = meta?.content;
|
|
7
|
+
if (!meta) {
|
|
8
|
+
meta = document.createElement('meta');
|
|
9
|
+
meta.name = 'description';
|
|
10
|
+
document.head.appendChild(meta);
|
|
11
|
+
}
|
|
12
|
+
meta.content = description;
|
|
13
|
+
return () => {
|
|
14
|
+
if (meta && previous !== undefined) meta.content = previous;
|
|
15
|
+
};
|
|
16
|
+
}, [description]);
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SITE_NAME = 'Autional';
|
|
4
|
+
|
|
5
|
+
export function usePageTitle(title: string, siteName?: string) {
|
|
6
|
+
const name = siteName || DEFAULT_SITE_NAME;
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const previous = document.title;
|
|
9
|
+
document.title = title ? `${title} — ${name}` : name;
|
|
10
|
+
return () => {
|
|
11
|
+
document.title = previous;
|
|
12
|
+
};
|
|
13
|
+
}, [title, name]);
|
|
14
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import type { SEOConfig, SiteSEOConfig } from './types';
|
|
3
|
+
import { upsertMeta, upsertCanonical, upsertLink, injectJSONLD } from './dom-utils';
|
|
4
|
+
import { SITE_BASE_URL, DEFAULT_OG_IMAGE } from '../config/constants';
|
|
5
|
+
|
|
6
|
+
const DEFAULTS: Required<SiteSEOConfig> = {
|
|
7
|
+
siteName: 'Autional',
|
|
8
|
+
baseUrl: SITE_BASE_URL,
|
|
9
|
+
defaultOgImage: DEFAULT_OG_IMAGE,
|
|
10
|
+
hreflang: false,
|
|
11
|
+
hreflangLanguages: ['zh', 'en'],
|
|
12
|
+
hreflangXDefault: `${SITE_BASE_URL}/zh/`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function useSEO(config: SEOConfig, siteConfig?: SiteSEOConfig) {
|
|
16
|
+
const s = { ...DEFAULTS, ...siteConfig };
|
|
17
|
+
const { title, description, ogImage, canonical, type, publishedTime, author, jsonld } = {
|
|
18
|
+
ogImage: s.defaultOgImage,
|
|
19
|
+
type: 'website' as const,
|
|
20
|
+
...config,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const pageTitle = title ? `${title} — ${s.siteName}` : s.siteName;
|
|
25
|
+
const prevTitle = document.title;
|
|
26
|
+
document.title = pageTitle;
|
|
27
|
+
|
|
28
|
+
const cleanups: (() => void)[] = [];
|
|
29
|
+
|
|
30
|
+
if (s.hreflang) {
|
|
31
|
+
try {
|
|
32
|
+
const currentPath = window.location.pathname;
|
|
33
|
+
const pathMatch = currentPath.match(/^\/(zh|en)(\/.*)?$/);
|
|
34
|
+
if (pathMatch) {
|
|
35
|
+
const pagePath = pathMatch[2] || '/';
|
|
36
|
+
for (const lang of s.hreflangLanguages) {
|
|
37
|
+
const url = `${s.baseUrl}/${lang}${pagePath === '/' ? '' : pagePath}`;
|
|
38
|
+
cleanups.push(upsertLink('alternate', url, { hreflang: lang }));
|
|
39
|
+
}
|
|
40
|
+
cleanups.push(upsertLink('alternate', s.hreflangXDefault, { hreflang: 'x-default' }));
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
/* SSR-safe */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
cleanups.push(upsertMeta('description', description));
|
|
48
|
+
cleanups.push(upsertMeta('og:title', pageTitle, 'property'));
|
|
49
|
+
cleanups.push(upsertMeta('og:description', description, 'property'));
|
|
50
|
+
cleanups.push(upsertMeta('og:image', ogImage, 'property'));
|
|
51
|
+
cleanups.push(upsertMeta('og:type', type, 'property'));
|
|
52
|
+
cleanups.push(upsertMeta('og:url', canonical || window.location.href, 'property'));
|
|
53
|
+
cleanups.push(upsertMeta('twitter:card', 'summary_large_image'));
|
|
54
|
+
cleanups.push(upsertMeta('twitter:title', pageTitle));
|
|
55
|
+
cleanups.push(upsertMeta('twitter:description', description));
|
|
56
|
+
cleanups.push(upsertMeta('twitter:image', ogImage));
|
|
57
|
+
|
|
58
|
+
if (type === 'article' && publishedTime) {
|
|
59
|
+
cleanups.push(upsertMeta('article:published_time', publishedTime, 'property'));
|
|
60
|
+
}
|
|
61
|
+
if (type === 'article' && author) {
|
|
62
|
+
cleanups.push(upsertMeta('article:author', author, 'property'));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (canonical) {
|
|
66
|
+
cleanups.push(upsertCanonical(canonical));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cleanupJSONLD: (() => void) | undefined;
|
|
70
|
+
if (jsonld && jsonld.length > 0) {
|
|
71
|
+
cleanupJSONLD = injectJSONLD(jsonld);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
document.title = prevTitle;
|
|
76
|
+
cleanups.forEach((fn) => fn());
|
|
77
|
+
cleanupJSONLD?.();
|
|
78
|
+
};
|
|
79
|
+
}, [title, description, ogImage, canonical, type, publishedTime, author, JSON.stringify(jsonld)]);
|
|
80
|
+
}
|