talizen 0.1.3 → 0.2.0
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/README.md +51 -22
- package/captcha-ui.d.ts +37 -0
- package/captcha-ui.js +404 -0
- package/core.d.ts +19 -0
- package/core.js +69 -2
- package/form.d.ts +14 -1
- package/form.js +59 -4
- package/i18n.d.ts +55 -0
- package/i18n.js +62 -0
- package/index.d.ts +28 -0
- package/index.js +2 -0
- package/package.json +13 -1
package/README.md
CHANGED
|
@@ -14,78 +14,89 @@ The package is designed to hold the platform-level APIs that frontend projects u
|
|
|
14
14
|
npm install talizen
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
or use esm.sh
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
{
|
|
21
|
+
"imports": {
|
|
22
|
+
"talizen": "https://esm.sh/talizen@0.1.4"
|
|
23
|
+
"talizen/": "https://esm.sh/talizen@0.1.4/"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
17
28
|
## Usage
|
|
18
29
|
|
|
19
30
|
### Configure the client
|
|
20
31
|
|
|
21
32
|
```ts
|
|
22
|
-
import { setTalizenConfig } from "talizen/core"
|
|
33
|
+
import { setTalizenConfig } from "talizen/core";
|
|
23
34
|
|
|
24
35
|
setTalizenConfig({
|
|
25
36
|
baseUrl: "https://www.talizen.com",
|
|
26
37
|
onFileUploadProcess(key, process) {
|
|
27
|
-
console.log(key, process)
|
|
38
|
+
console.log(key, process);
|
|
28
39
|
},
|
|
29
|
-
})
|
|
40
|
+
});
|
|
30
41
|
```
|
|
31
42
|
|
|
32
43
|
### List CMS content
|
|
33
44
|
|
|
34
45
|
```ts
|
|
35
|
-
import { listContents, type BaseCmsItem } from "talizen/cms"
|
|
46
|
+
import { listContents, type BaseCmsItem } from "talizen/cms";
|
|
36
47
|
|
|
37
48
|
interface Blog extends BaseCmsItem {
|
|
38
|
-
readonly __cmsKey: "blogs"
|
|
49
|
+
readonly __cmsKey: "blogs";
|
|
39
50
|
body: {
|
|
40
|
-
title?: string
|
|
41
|
-
content?: string
|
|
42
|
-
}
|
|
51
|
+
title?: string;
|
|
52
|
+
content?: string;
|
|
53
|
+
};
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
const result = await listContents<Blog>("blogs", {
|
|
46
57
|
limit: 10,
|
|
47
58
|
orderBy: "-created_at",
|
|
48
|
-
})
|
|
59
|
+
});
|
|
49
60
|
|
|
50
|
-
console.log(result.list)
|
|
51
|
-
console.log(result.total)
|
|
61
|
+
console.log(result.list);
|
|
62
|
+
console.log(result.total);
|
|
52
63
|
```
|
|
53
64
|
|
|
54
65
|
### Get a single CMS content item
|
|
55
66
|
|
|
56
67
|
```ts
|
|
57
|
-
import { getContent, type BaseCmsItem } from "talizen/cms"
|
|
68
|
+
import { getContent, type BaseCmsItem } from "talizen/cms";
|
|
58
69
|
|
|
59
70
|
interface Blog extends BaseCmsItem {
|
|
60
|
-
readonly __cmsKey: "blogs"
|
|
71
|
+
readonly __cmsKey: "blogs";
|
|
61
72
|
body: {
|
|
62
|
-
title?: string
|
|
63
|
-
}
|
|
73
|
+
title?: string;
|
|
74
|
+
};
|
|
64
75
|
}
|
|
65
76
|
|
|
66
|
-
const blog = await getContent<Blog>("blogs", "hello-world")
|
|
77
|
+
const blog = await getContent<Blog>("blogs", "hello-world");
|
|
67
78
|
```
|
|
68
79
|
|
|
69
80
|
### Get CMS collection metadata
|
|
70
81
|
|
|
71
82
|
```ts
|
|
72
|
-
import { getContentCollection } from "talizen/cms"
|
|
83
|
+
import { getContentCollection } from "talizen/cms";
|
|
73
84
|
|
|
74
|
-
const collection = await getContentCollection("blogs")
|
|
85
|
+
const collection = await getContentCollection("blogs");
|
|
75
86
|
|
|
76
|
-
console.log(collection?.title)
|
|
77
|
-
console.log(collection?.jsonSchema)
|
|
87
|
+
console.log(collection?.title);
|
|
88
|
+
console.log(collection?.jsonSchema);
|
|
78
89
|
```
|
|
79
90
|
|
|
80
91
|
### Submit a form
|
|
81
92
|
|
|
82
93
|
```ts
|
|
83
|
-
import { submitForm } from "talizen/form"
|
|
94
|
+
import { submitForm } from "talizen/form";
|
|
84
95
|
|
|
85
96
|
await submitForm("contact-form", {
|
|
86
97
|
email: "hi@talizen.com",
|
|
87
98
|
message: "Hello from the website",
|
|
88
|
-
})
|
|
99
|
+
});
|
|
89
100
|
```
|
|
90
101
|
|
|
91
102
|
When a `File` object appears in the payload, `submitForm()` will:
|
|
@@ -118,3 +129,21 @@ git tag v0.0.8
|
|
|
118
129
|
git push origin main
|
|
119
130
|
git push origin v0.0.8
|
|
120
131
|
```
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
bun run dev
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
This will build the package and start a development server at http://localhost:8787.
|
|
140
|
+
|
|
141
|
+
Use the development server in your project:
|
|
142
|
+
|
|
143
|
+
```json
|
|
144
|
+
{
|
|
145
|
+
"imports": {
|
|
146
|
+
"talizen/form": "http://localhost:8787/form.js"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
```
|
package/captcha-ui.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface CaptchaChallenge {
|
|
2
|
+
token: string;
|
|
3
|
+
type: "slide";
|
|
4
|
+
master_img: string;
|
|
5
|
+
tile_img: string;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
tile_width: number;
|
|
9
|
+
tile_height: number;
|
|
10
|
+
tile_x: number;
|
|
11
|
+
tile_y: number;
|
|
12
|
+
}
|
|
13
|
+
export interface CaptchaUiTheme {
|
|
14
|
+
zIndex?: number;
|
|
15
|
+
width?: number;
|
|
16
|
+
title?: string;
|
|
17
|
+
subtitle?: string;
|
|
18
|
+
cancelText?: string;
|
|
19
|
+
sliderText?: string;
|
|
20
|
+
retryText?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface CaptchaVerifyResult {
|
|
23
|
+
token: string;
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
}
|
|
27
|
+
export interface CaptchaVerificationFlowOptions<T> {
|
|
28
|
+
initialCaptcha: CaptchaChallenge;
|
|
29
|
+
refreshCaptcha: () => Promise<CaptchaChallenge>;
|
|
30
|
+
signal?: AbortSignal;
|
|
31
|
+
shouldRetry: (error: unknown) => boolean;
|
|
32
|
+
theme?: CaptchaUiTheme;
|
|
33
|
+
verify: (result: CaptchaVerifyResult) => Promise<T>;
|
|
34
|
+
}
|
|
35
|
+
export declare function shouldRetryCaptchaSubmit(error: unknown, shouldTriggerCaptcha: (error: unknown) => boolean): boolean;
|
|
36
|
+
export declare function buildCaptchaAnswer(x: number, y: number): string;
|
|
37
|
+
export declare function runCaptchaVerification<T>(options: CaptchaVerificationFlowOptions<T>): Promise<T>;
|
package/captcha-ui.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { TalizenHttpError } from "./core.js";
|
|
2
|
+
export function shouldRetryCaptchaSubmit(error, shouldTriggerCaptcha) {
|
|
3
|
+
if (shouldTriggerCaptcha(error)) {
|
|
4
|
+
return true;
|
|
5
|
+
}
|
|
6
|
+
if (!(error instanceof TalizenHttpError)) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
if (isCaptchaErrorBody(error)) {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
return error.status >= 400 && error.status < 500;
|
|
13
|
+
}
|
|
14
|
+
export function buildCaptchaAnswer(x, y) {
|
|
15
|
+
return JSON.stringify({ x, y });
|
|
16
|
+
}
|
|
17
|
+
export function runCaptchaVerification(options) {
|
|
18
|
+
if (typeof document === "undefined") {
|
|
19
|
+
throw new Error("Talizen captcha UI requires browser DOM support.");
|
|
20
|
+
}
|
|
21
|
+
if (options.signal?.aborted) {
|
|
22
|
+
throw createAbortError();
|
|
23
|
+
}
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const dialogTheme = resolveCaptchaDialogTheme(options.initialCaptcha, options.theme);
|
|
26
|
+
const elements = createCaptchaDialogElements(options.initialCaptcha, dialogTheme);
|
|
27
|
+
const { host, tile, slider, errorText, cancelButton } = elements;
|
|
28
|
+
let currentCaptcha = options.initialCaptcha;
|
|
29
|
+
let settled = false;
|
|
30
|
+
let verifying = false;
|
|
31
|
+
document.body.append(host);
|
|
32
|
+
const cleanup = () => {
|
|
33
|
+
host.removeEventListener("click", handleBackdropClick);
|
|
34
|
+
document.removeEventListener("keydown", handleKeydown);
|
|
35
|
+
slider.removeEventListener("input", updateTilePosition);
|
|
36
|
+
slider.removeEventListener("change", submit);
|
|
37
|
+
cancelButton.removeEventListener("click", cancel);
|
|
38
|
+
options.signal?.removeEventListener("abort", abort);
|
|
39
|
+
host.remove();
|
|
40
|
+
};
|
|
41
|
+
const settle = (callback) => {
|
|
42
|
+
if (settled) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
settled = true;
|
|
46
|
+
cleanup();
|
|
47
|
+
callback();
|
|
48
|
+
};
|
|
49
|
+
const updateTilePosition = () => {
|
|
50
|
+
tile.style.left = `${getSliderValue(slider)}px`;
|
|
51
|
+
setCaptchaErrorText(errorText);
|
|
52
|
+
};
|
|
53
|
+
const submit = async () => {
|
|
54
|
+
if (verifying) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
verifying = true;
|
|
58
|
+
const x = getSliderValue(slider);
|
|
59
|
+
setCaptchaDialogDisabled(elements, true);
|
|
60
|
+
try {
|
|
61
|
+
const result = await options.verify({ token: currentCaptcha.token, x, y: currentCaptcha.tile_y });
|
|
62
|
+
settle(() => resolve(result));
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (!options.shouldRetry(error)) {
|
|
66
|
+
settle(() => reject(error));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
currentCaptcha = await options.refreshCaptcha();
|
|
71
|
+
updateCaptchaDialogCaptcha(elements, currentCaptcha, dialogTheme);
|
|
72
|
+
setCaptchaErrorText(errorText, dialogTheme.retryText);
|
|
73
|
+
}
|
|
74
|
+
catch (refreshError) {
|
|
75
|
+
settle(() => reject(refreshError));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
verifying = false;
|
|
81
|
+
if (!settled) {
|
|
82
|
+
setCaptchaDialogDisabled(elements, false);
|
|
83
|
+
slider.focus();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const cancel = () => {
|
|
88
|
+
settle(() => reject(new Error("Talizen captcha verification was cancelled by user.")));
|
|
89
|
+
};
|
|
90
|
+
const abort = () => {
|
|
91
|
+
settle(() => reject(createAbortError()));
|
|
92
|
+
};
|
|
93
|
+
const handleBackdropClick = (event) => {
|
|
94
|
+
if (event.target === host) {
|
|
95
|
+
cancel();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const handleKeydown = (event) => {
|
|
99
|
+
if (event.key === "Escape") {
|
|
100
|
+
cancel();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
slider.addEventListener("input", updateTilePosition);
|
|
104
|
+
slider.addEventListener("change", submit);
|
|
105
|
+
host.addEventListener("click", handleBackdropClick);
|
|
106
|
+
document.addEventListener("keydown", handleKeydown);
|
|
107
|
+
cancelButton.addEventListener("click", cancel);
|
|
108
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
109
|
+
updateTilePosition();
|
|
110
|
+
slider.focus();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function isCaptchaErrorBody(error) {
|
|
114
|
+
const code = String(error.bodyJson?.code ?? "").toLowerCase();
|
|
115
|
+
const message = String(error.bodyJson?.message ?? "").toLowerCase();
|
|
116
|
+
const body = error.body.toLowerCase();
|
|
117
|
+
return code.includes("captcha")
|
|
118
|
+
|| code.includes("verify")
|
|
119
|
+
|| message.includes("captcha")
|
|
120
|
+
|| message.includes("验证")
|
|
121
|
+
|| body.includes("captcha")
|
|
122
|
+
|| body.includes("验证");
|
|
123
|
+
}
|
|
124
|
+
function resolveCaptchaDialogTheme(captcha, theme) {
|
|
125
|
+
const localeText = getCaptchaLocaleText();
|
|
126
|
+
return {
|
|
127
|
+
zIndex: theme?.zIndex ?? 9999,
|
|
128
|
+
hasCustomWidth: theme?.width != null,
|
|
129
|
+
width: theme?.width ?? getDefaultCaptchaDialogWidth(captcha),
|
|
130
|
+
title: theme?.title ?? localeText.title,
|
|
131
|
+
subtitle: theme?.subtitle ?? localeText.subtitle,
|
|
132
|
+
cancelText: theme?.cancelText ?? localeText.cancelText,
|
|
133
|
+
sliderText: theme?.sliderText ?? localeText.sliderText,
|
|
134
|
+
retryText: theme?.retryText ?? localeText.retryText,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function createCaptchaDialogElements(captcha, theme) {
|
|
138
|
+
const host = createStyledElement("div", {
|
|
139
|
+
position: "fixed",
|
|
140
|
+
inset: "0",
|
|
141
|
+
display: "flex",
|
|
142
|
+
alignItems: "center",
|
|
143
|
+
justifyContent: "center",
|
|
144
|
+
background: "rgba(0, 0, 0, 0.4)",
|
|
145
|
+
zIndex: String(theme.zIndex),
|
|
146
|
+
});
|
|
147
|
+
const sliderClassName = "talizen-captcha-slider";
|
|
148
|
+
const sliderStyle = createCaptchaSliderStyle(sliderClassName);
|
|
149
|
+
const panel = createStyledElement("div", {
|
|
150
|
+
width: `${theme.width}px`,
|
|
151
|
+
maxWidth: "calc(100vw - 32px)",
|
|
152
|
+
background: "#ffffff",
|
|
153
|
+
borderRadius: "18px",
|
|
154
|
+
padding: "16px",
|
|
155
|
+
boxSizing: "border-box",
|
|
156
|
+
boxShadow: "0 24px 60px rgba(15,23,42,0.24)",
|
|
157
|
+
color: "#111827",
|
|
158
|
+
});
|
|
159
|
+
const title = createStyledElement("div", {
|
|
160
|
+
fontSize: "18px",
|
|
161
|
+
lineHeight: "26px",
|
|
162
|
+
fontWeight: "700",
|
|
163
|
+
marginBottom: "4px",
|
|
164
|
+
}, theme.title);
|
|
165
|
+
const subtitle = createStyledElement("div", {
|
|
166
|
+
fontSize: "14px",
|
|
167
|
+
lineHeight: "22px",
|
|
168
|
+
fontWeight: "600",
|
|
169
|
+
color: "#6b7280",
|
|
170
|
+
marginBottom: "12px",
|
|
171
|
+
}, theme.subtitle);
|
|
172
|
+
const stage = createCaptchaStage(captcha);
|
|
173
|
+
const sliderLabel = createStyledElement("div", {
|
|
174
|
+
fontSize: "14px",
|
|
175
|
+
lineHeight: "20px",
|
|
176
|
+
fontWeight: "600",
|
|
177
|
+
color: "#6b7280",
|
|
178
|
+
marginBottom: "8px",
|
|
179
|
+
}, theme.sliderText);
|
|
180
|
+
const slider = createCaptchaSlider(captcha, sliderClassName);
|
|
181
|
+
const errorText = createStyledElement("div", {
|
|
182
|
+
display: "none",
|
|
183
|
+
flex: "1",
|
|
184
|
+
fontSize: "13px",
|
|
185
|
+
lineHeight: "20px",
|
|
186
|
+
fontWeight: "500",
|
|
187
|
+
color: "#ef4444",
|
|
188
|
+
});
|
|
189
|
+
const actions = createStyledElement("div", {
|
|
190
|
+
display: "flex",
|
|
191
|
+
alignItems: "center",
|
|
192
|
+
gap: "12px",
|
|
193
|
+
justifyContent: "flex-start",
|
|
194
|
+
marginTop: "8px",
|
|
195
|
+
});
|
|
196
|
+
const cancelButton = createStyledElement("button", {
|
|
197
|
+
padding: "10px 18px",
|
|
198
|
+
border: "1px solid #e5e7eb",
|
|
199
|
+
background: "#ffffff",
|
|
200
|
+
borderRadius: "12px",
|
|
201
|
+
boxShadow: "0 1px 2px rgba(15,23,42,0.08)",
|
|
202
|
+
color: "#111827",
|
|
203
|
+
fontSize: "15px",
|
|
204
|
+
lineHeight: "20px",
|
|
205
|
+
fontWeight: "600",
|
|
206
|
+
cursor: "pointer",
|
|
207
|
+
marginLeft: "auto",
|
|
208
|
+
}, theme.cancelText);
|
|
209
|
+
cancelButton.type = "button";
|
|
210
|
+
actions.append(errorText, cancelButton);
|
|
211
|
+
panel.append(title, subtitle, stage.host, sliderLabel, slider, actions);
|
|
212
|
+
host.append(sliderStyle, panel);
|
|
213
|
+
return {
|
|
214
|
+
baseImage: stage.baseImage,
|
|
215
|
+
host,
|
|
216
|
+
panel,
|
|
217
|
+
stage: stage.host,
|
|
218
|
+
tile: stage.tile,
|
|
219
|
+
slider,
|
|
220
|
+
errorText,
|
|
221
|
+
cancelButton,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function createCaptchaStage(captcha) {
|
|
225
|
+
const host = createStyledElement("div", {
|
|
226
|
+
position: "relative",
|
|
227
|
+
width: `${captcha.width}px`,
|
|
228
|
+
height: `${captcha.height}px`,
|
|
229
|
+
maxWidth: "100%",
|
|
230
|
+
margin: "0 0 12px",
|
|
231
|
+
borderRadius: "12px",
|
|
232
|
+
overflow: "hidden",
|
|
233
|
+
background: "#f5f5f5",
|
|
234
|
+
});
|
|
235
|
+
const baseImage = createStyledElement("img", {
|
|
236
|
+
width: "100%",
|
|
237
|
+
height: "100%",
|
|
238
|
+
objectFit: "cover",
|
|
239
|
+
});
|
|
240
|
+
baseImage.src = captcha.master_img;
|
|
241
|
+
baseImage.draggable = false;
|
|
242
|
+
const tile = createStyledElement("img", {
|
|
243
|
+
position: "absolute",
|
|
244
|
+
top: `${captcha.tile_y}px`,
|
|
245
|
+
left: "0px",
|
|
246
|
+
width: `${captcha.tile_width}px`,
|
|
247
|
+
height: `${captcha.tile_height}px`,
|
|
248
|
+
pointerEvents: "none",
|
|
249
|
+
});
|
|
250
|
+
tile.src = captcha.tile_img;
|
|
251
|
+
tile.draggable = false;
|
|
252
|
+
host.append(baseImage, tile);
|
|
253
|
+
return { baseImage, host, tile };
|
|
254
|
+
}
|
|
255
|
+
function createCaptchaSlider(captcha, className) {
|
|
256
|
+
const slider = createStyledElement("input", {
|
|
257
|
+
width: "100%",
|
|
258
|
+
display: "block",
|
|
259
|
+
margin: "0",
|
|
260
|
+
});
|
|
261
|
+
slider.type = "range";
|
|
262
|
+
slider.className = className;
|
|
263
|
+
slider.min = "0";
|
|
264
|
+
slider.max = String(Math.max(0, captcha.width - captcha.tile_width));
|
|
265
|
+
slider.value = "0";
|
|
266
|
+
return slider;
|
|
267
|
+
}
|
|
268
|
+
function createCaptchaSliderStyle(className) {
|
|
269
|
+
const style = document.createElement("style");
|
|
270
|
+
style.textContent = `
|
|
271
|
+
.${className} {
|
|
272
|
+
appearance: none;
|
|
273
|
+
-webkit-appearance: none;
|
|
274
|
+
height: 40px;
|
|
275
|
+
background: transparent;
|
|
276
|
+
cursor: pointer;
|
|
277
|
+
}
|
|
278
|
+
.${className}:focus {
|
|
279
|
+
outline: none;
|
|
280
|
+
}
|
|
281
|
+
.${className}::-webkit-slider-runnable-track {
|
|
282
|
+
height: 12px;
|
|
283
|
+
border: 1px solid #d1d5db;
|
|
284
|
+
border-radius: 999px;
|
|
285
|
+
background: #f9fafb;
|
|
286
|
+
box-shadow: inset 0 1px 2px rgba(15,23,42,0.08);
|
|
287
|
+
}
|
|
288
|
+
.${className}::-webkit-slider-thumb {
|
|
289
|
+
-webkit-appearance: none;
|
|
290
|
+
width: 30px;
|
|
291
|
+
height: 30px;
|
|
292
|
+
margin-top: -10px;
|
|
293
|
+
border: 3px solid #ffffff;
|
|
294
|
+
border-radius: 999px;
|
|
295
|
+
background: #1677ff;
|
|
296
|
+
box-shadow: 0 4px 12px rgba(22,119,255,0.35);
|
|
297
|
+
}
|
|
298
|
+
.${className}::-moz-range-track {
|
|
299
|
+
height: 12px;
|
|
300
|
+
border: 1px solid #d1d5db;
|
|
301
|
+
border-radius: 999px;
|
|
302
|
+
background: #f9fafb;
|
|
303
|
+
box-shadow: inset 0 1px 2px rgba(15,23,42,0.08);
|
|
304
|
+
}
|
|
305
|
+
.${className}::-moz-range-thumb {
|
|
306
|
+
width: 24px;
|
|
307
|
+
height: 24px;
|
|
308
|
+
border: 3px solid #ffffff;
|
|
309
|
+
border-radius: 999px;
|
|
310
|
+
background: #1677ff;
|
|
311
|
+
box-shadow: 0 4px 12px rgba(22,119,255,0.35);
|
|
312
|
+
}
|
|
313
|
+
.${className}:disabled {
|
|
314
|
+
cursor: wait;
|
|
315
|
+
opacity: 0.72;
|
|
316
|
+
}
|
|
317
|
+
`;
|
|
318
|
+
return style;
|
|
319
|
+
}
|
|
320
|
+
function createStyledElement(tagName, styles, text) {
|
|
321
|
+
const element = document.createElement(tagName);
|
|
322
|
+
Object.assign(element.style, styles);
|
|
323
|
+
if (text != null) {
|
|
324
|
+
element.textContent = text;
|
|
325
|
+
}
|
|
326
|
+
return element;
|
|
327
|
+
}
|
|
328
|
+
function updateCaptchaDialogCaptcha(elements, captcha, theme) {
|
|
329
|
+
if (!theme.hasCustomWidth) {
|
|
330
|
+
elements.panel.style.width = `${getDefaultCaptchaDialogWidth(captcha)}px`;
|
|
331
|
+
}
|
|
332
|
+
elements.stage.style.width = `${captcha.width}px`;
|
|
333
|
+
elements.stage.style.height = `${captcha.height}px`;
|
|
334
|
+
elements.baseImage.src = captcha.master_img;
|
|
335
|
+
elements.tile.src = captcha.tile_img;
|
|
336
|
+
elements.tile.style.top = `${captcha.tile_y}px`;
|
|
337
|
+
elements.tile.style.left = "0px";
|
|
338
|
+
elements.tile.style.width = `${captcha.tile_width}px`;
|
|
339
|
+
elements.tile.style.height = `${captcha.tile_height}px`;
|
|
340
|
+
elements.slider.max = String(Math.max(0, captcha.width - captcha.tile_width));
|
|
341
|
+
elements.slider.value = "0";
|
|
342
|
+
}
|
|
343
|
+
function getDefaultCaptchaDialogWidth(captcha) {
|
|
344
|
+
return Math.max(captcha.width + 32, 320);
|
|
345
|
+
}
|
|
346
|
+
function setCaptchaDialogDisabled(elements, disabled) {
|
|
347
|
+
elements.slider.disabled = disabled;
|
|
348
|
+
}
|
|
349
|
+
function setCaptchaErrorText(element, message = "") {
|
|
350
|
+
element.textContent = message;
|
|
351
|
+
element.style.display = message === "" ? "none" : "block";
|
|
352
|
+
}
|
|
353
|
+
function getSliderValue(slider) {
|
|
354
|
+
return clampNumber(toSafeNumber(slider.value), toSafeNumber(slider.min), toSafeNumber(slider.max));
|
|
355
|
+
}
|
|
356
|
+
function toSafeNumber(value) {
|
|
357
|
+
const parsed = Number(value);
|
|
358
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
359
|
+
}
|
|
360
|
+
function clampNumber(value, min, max) {
|
|
361
|
+
return Math.min(max, Math.max(min, value));
|
|
362
|
+
}
|
|
363
|
+
function getCaptchaLocaleText() {
|
|
364
|
+
const languages = typeof navigator === "undefined"
|
|
365
|
+
? []
|
|
366
|
+
: [navigator.language, ...(navigator.languages ?? [])]
|
|
367
|
+
.filter((item) => typeof item === "string" && item !== "")
|
|
368
|
+
.map((item) => item.toLowerCase());
|
|
369
|
+
for (const language of languages) {
|
|
370
|
+
if (language.startsWith("en")) {
|
|
371
|
+
return {
|
|
372
|
+
title: "Complete security verification",
|
|
373
|
+
subtitle: "Drag the slider to move the puzzle into place",
|
|
374
|
+
cancelText: "Cancel",
|
|
375
|
+
sliderText: "Drag the slider to complete the puzzle",
|
|
376
|
+
retryText: "Verification failed, please try again.",
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (language.startsWith("zh")) {
|
|
380
|
+
return {
|
|
381
|
+
title: "请完成安全验证",
|
|
382
|
+
subtitle: "拖动滑块,将拼图移动到正确位置",
|
|
383
|
+
cancelText: "取消",
|
|
384
|
+
sliderText: "向右拖动滑块完成拼图",
|
|
385
|
+
retryText: "验证失败,请重试。",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
title: "Complete security verification",
|
|
391
|
+
subtitle: "Drag the slider to move the puzzle into place",
|
|
392
|
+
cancelText: "Cancel",
|
|
393
|
+
sliderText: "Drag the slider to complete the puzzle",
|
|
394
|
+
retryText: "Verification failed, please try again.",
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function createAbortError() {
|
|
398
|
+
if (typeof DOMException === "function") {
|
|
399
|
+
return new DOMException("The operation was aborted.", "AbortError");
|
|
400
|
+
}
|
|
401
|
+
const error = new Error("The operation was aborted.");
|
|
402
|
+
error.name = "AbortError";
|
|
403
|
+
return error;
|
|
404
|
+
}
|
package/core.d.ts
CHANGED
|
@@ -21,10 +21,29 @@ export interface TalizenClientConfig {
|
|
|
21
21
|
export interface TalizenRequestOptions extends TalizenClientConfig {
|
|
22
22
|
signal?: AbortSignal;
|
|
23
23
|
}
|
|
24
|
+
export interface TalizenErrorBody {
|
|
25
|
+
code?: number | string;
|
|
26
|
+
message?: string;
|
|
27
|
+
}
|
|
24
28
|
export declare function setTalizenConfig(config: TalizenClientConfig): void;
|
|
25
29
|
export declare function getTalizenConfig(): TalizenClientConfig;
|
|
26
30
|
declare global {
|
|
27
31
|
var TalizenConfig: TalizenClientConfig | undefined;
|
|
28
32
|
}
|
|
29
33
|
export declare function resolveTalizenConfig(config?: TalizenRequestOptions): Required<Pick<TalizenClientConfig, "baseUrl" | "fetch">> & TalizenRequestOptions;
|
|
34
|
+
export declare class TalizenHttpError extends Error {
|
|
35
|
+
readonly status: number;
|
|
36
|
+
readonly statusText: string;
|
|
37
|
+
readonly body: string;
|
|
38
|
+
readonly bodyJson: TalizenErrorBody | undefined;
|
|
39
|
+
readonly method: string | undefined;
|
|
40
|
+
readonly url: string | undefined;
|
|
41
|
+
constructor(status: number, statusText: string, body: string, bodyJson?: TalizenErrorBody, request?: TalizenErrorRequest);
|
|
42
|
+
}
|
|
43
|
+
export interface TalizenErrorRequest {
|
|
44
|
+
method?: string;
|
|
45
|
+
url?: string;
|
|
46
|
+
}
|
|
30
47
|
export declare function requestJson<T>(path: string, init?: RequestInit, config?: TalizenRequestOptions): Promise<T>;
|
|
48
|
+
export declare function stripUrlQuery(url: string): string;
|
|
49
|
+
export declare function normalizeRequestMethod(method: string | undefined): string;
|
package/core.js
CHANGED
|
@@ -33,6 +33,24 @@ function buildTalizenUrl(path, config) {
|
|
|
33
33
|
const resolved = resolveTalizenConfig(config);
|
|
34
34
|
return joinUrl(resolved.baseUrl, path);
|
|
35
35
|
}
|
|
36
|
+
export class TalizenHttpError extends Error {
|
|
37
|
+
status;
|
|
38
|
+
statusText;
|
|
39
|
+
body;
|
|
40
|
+
bodyJson;
|
|
41
|
+
method;
|
|
42
|
+
url;
|
|
43
|
+
constructor(status, statusText, body, bodyJson, request) {
|
|
44
|
+
super(formatTalizenRequestErrorMessage(status, statusText, body, request));
|
|
45
|
+
this.name = "TalizenHttpError";
|
|
46
|
+
this.status = status;
|
|
47
|
+
this.statusText = statusText;
|
|
48
|
+
this.body = body;
|
|
49
|
+
this.bodyJson = bodyJson;
|
|
50
|
+
this.method = request?.method;
|
|
51
|
+
this.url = request?.url;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
36
54
|
export async function requestJson(path, init, config) {
|
|
37
55
|
const resolved = resolveTalizenConfig(config);
|
|
38
56
|
const headers = new Headers(resolved.headers ?? {});
|
|
@@ -44,14 +62,20 @@ export async function requestJson(path, init, config) {
|
|
|
44
62
|
if (init?.body != null && !headers.has("content-type")) {
|
|
45
63
|
headers.set("content-type", "application/json");
|
|
46
64
|
}
|
|
47
|
-
const
|
|
65
|
+
const requestUrl = buildTalizenUrl(path, resolved);
|
|
66
|
+
const request = {
|
|
67
|
+
method: normalizeRequestMethod(init?.method),
|
|
68
|
+
url: stripUrlQuery(requestUrl),
|
|
69
|
+
};
|
|
70
|
+
const response = await resolved.fetch(requestUrl, {
|
|
48
71
|
...init,
|
|
49
72
|
headers,
|
|
50
73
|
signal: resolved.signal,
|
|
51
74
|
});
|
|
52
75
|
if (!response.ok) {
|
|
53
76
|
const text = await response.text();
|
|
54
|
-
|
|
77
|
+
const bodyJson = parseTalizenErrorBody(text);
|
|
78
|
+
throw new TalizenHttpError(response.status, response.statusText, text, bodyJson, request);
|
|
55
79
|
}
|
|
56
80
|
const text = await response.text();
|
|
57
81
|
if (text === "") {
|
|
@@ -59,6 +83,23 @@ export async function requestJson(path, init, config) {
|
|
|
59
83
|
}
|
|
60
84
|
return JSON.parse(text);
|
|
61
85
|
}
|
|
86
|
+
export function stripUrlQuery(url) {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = new URL(url);
|
|
89
|
+
parsed.search = "";
|
|
90
|
+
return parsed.toString();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return url.split("?")[0] ?? url;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export function normalizeRequestMethod(method) {
|
|
97
|
+
return (method ?? "GET").toUpperCase();
|
|
98
|
+
}
|
|
99
|
+
function formatTalizenRequestErrorMessage(status, statusText, body, request) {
|
|
100
|
+
const requestText = [request?.method, request?.url].filter(Boolean).join(" ");
|
|
101
|
+
return `Talizen request failed: ${requestText} ${status} ${statusText} ${body}`.replace(/\s+/g, " ").trim();
|
|
102
|
+
}
|
|
62
103
|
function getDefaultBaseUrl() {
|
|
63
104
|
if (typeof window !== "undefined" && window.location?.origin) {
|
|
64
105
|
return window.location.origin;
|
|
@@ -71,3 +112,29 @@ function getDefaultFetch() {
|
|
|
71
112
|
}
|
|
72
113
|
throw new Error("Talizen fetch implementation is required in the current runtime.");
|
|
73
114
|
}
|
|
115
|
+
function parseTalizenErrorBody(body) {
|
|
116
|
+
if (body === "") {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(body);
|
|
121
|
+
if (isTalizenErrorBody(parsed)) {
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
function isTalizenErrorBody(value) {
|
|
131
|
+
if (typeof value !== "object" || value == null) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
const record = value;
|
|
135
|
+
const code = record.code;
|
|
136
|
+
const message = record.message;
|
|
137
|
+
const isCodeValid = code === undefined || typeof code === "number" || typeof code === "string";
|
|
138
|
+
const isMessageValid = message === undefined || typeof message === "string";
|
|
139
|
+
return isCodeValid && isMessageValid;
|
|
140
|
+
}
|
package/form.d.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
|
+
import { type CaptchaChallenge, type CaptchaUiTheme } from "./captcha-ui.js";
|
|
1
2
|
import { type TalizenRequestOptions } from "./core.js";
|
|
3
|
+
export type { CaptchaChallenge as FormCaptcha, CaptchaUiTheme as FormCaptchaUiTheme } from "./captcha-ui.js";
|
|
2
4
|
export interface FormRecord {
|
|
3
5
|
readonly __formKey?: string;
|
|
4
6
|
[key: string]: unknown;
|
|
5
7
|
}
|
|
6
|
-
export
|
|
8
|
+
export interface SubmitFormOptions extends TalizenRequestOptions {
|
|
9
|
+
captchaToken?: string;
|
|
10
|
+
captchaAnswer?: string;
|
|
11
|
+
captchaX?: number;
|
|
12
|
+
captchaY?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface SubmitFormWithCaptchaOptions extends SubmitFormOptions {
|
|
15
|
+
shouldTriggerCaptcha?: (error: unknown) => boolean;
|
|
16
|
+
captchaUiTheme?: CaptchaUiTheme;
|
|
17
|
+
}
|
|
18
|
+
export declare function submitForm<T extends FormRecord>(keyOrToken: T["__formKey"] | string, payload: T, options?: SubmitFormWithCaptchaOptions): Promise<"ok">;
|
|
19
|
+
export declare function getFormCaptcha(keyOrToken: string, options?: TalizenRequestOptions): Promise<CaptchaChallenge>;
|
package/form.js
CHANGED
|
@@ -1,14 +1,58 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { buildCaptchaAnswer, runCaptchaVerification, shouldRetryCaptchaSubmit, } from "./captcha-ui.js";
|
|
2
|
+
import { normalizeRequestMethod, requestJson, resolveTalizenConfig, stripUrlQuery, TalizenHttpError, } from "./core.js";
|
|
2
3
|
export async function submitForm(keyOrToken, payload, options) {
|
|
3
4
|
const formKey = getFormKey(keyOrToken);
|
|
4
5
|
const data = await replaceFiles(formKey, payload, "", options);
|
|
6
|
+
try {
|
|
7
|
+
return await submitFormRequest(formKey, data, options);
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
if (!shouldShowCaptcha(error, options)) {
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return runCaptchaVerification({
|
|
15
|
+
initialCaptcha: await getFormCaptcha(formKey, options),
|
|
16
|
+
signal: options?.signal,
|
|
17
|
+
theme: options?.captchaUiTheme,
|
|
18
|
+
refreshCaptcha: () => getFormCaptcha(formKey, options),
|
|
19
|
+
shouldRetry: (error) => shouldRetryCaptchaSubmit(error, (item) => shouldShowCaptcha(item, options)),
|
|
20
|
+
verify: (result) => submitFormRequest(formKey, data, {
|
|
21
|
+
...options,
|
|
22
|
+
captchaToken: result.token,
|
|
23
|
+
captchaAnswer: buildCaptchaAnswer(result.x, result.y),
|
|
24
|
+
captchaX: result.x,
|
|
25
|
+
captchaY: result.y,
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function submitFormRequest(formKey, data, options) {
|
|
5
30
|
return requestJson(`/form/${formKey}/submit`, {
|
|
6
31
|
method: "POST",
|
|
7
32
|
body: JSON.stringify({
|
|
8
33
|
data,
|
|
34
|
+
captcha_token: options?.captchaToken,
|
|
35
|
+
captcha_answer: options?.captchaAnswer,
|
|
36
|
+
captcha_x: options?.captchaX,
|
|
37
|
+
captcha_y: options?.captchaY,
|
|
9
38
|
}),
|
|
10
39
|
}, options);
|
|
11
40
|
}
|
|
41
|
+
export async function getFormCaptcha(keyOrToken, options) {
|
|
42
|
+
const formKey = getFormKey(keyOrToken);
|
|
43
|
+
return requestJson(`/form/${formKey}/captcha`, {
|
|
44
|
+
method: "GET",
|
|
45
|
+
}, options);
|
|
46
|
+
}
|
|
47
|
+
function shouldShowCaptcha(error, options) {
|
|
48
|
+
return (options?.shouldTriggerCaptcha ?? isCaptchaRequiredError)(error);
|
|
49
|
+
}
|
|
50
|
+
function isCaptchaRequiredError(error) {
|
|
51
|
+
return error instanceof TalizenHttpError && isCaptchaRequiredCode(error.bodyJson?.code);
|
|
52
|
+
}
|
|
53
|
+
function isCaptchaRequiredCode(code) {
|
|
54
|
+
return code === 428 || code === "428";
|
|
55
|
+
}
|
|
12
56
|
function getFormKey(keyOrToken) {
|
|
13
57
|
if (keyOrToken == null || keyOrToken === "") {
|
|
14
58
|
throw new Error("Talizen form key is required.");
|
|
@@ -72,6 +116,10 @@ async function uploadToSignedUrl(target, file, fieldKey, options) {
|
|
|
72
116
|
const resolved = resolveTalizenConfig(options);
|
|
73
117
|
const headers = new Headers();
|
|
74
118
|
const body = file;
|
|
119
|
+
const request = {
|
|
120
|
+
method: normalizeRequestMethod("PUT"),
|
|
121
|
+
url: stripUrlQuery(target.uploadUrl),
|
|
122
|
+
};
|
|
75
123
|
if (file.type) {
|
|
76
124
|
headers.set("content-type", file.type);
|
|
77
125
|
}
|
|
@@ -83,7 +131,7 @@ async function uploadToSignedUrl(target, file, fieldKey, options) {
|
|
|
83
131
|
});
|
|
84
132
|
if (!response.ok) {
|
|
85
133
|
const text = await response.text();
|
|
86
|
-
throw new Error(
|
|
134
|
+
throw new Error(formatUploadError(request, `${response.status} ${response.statusText} ${text}`));
|
|
87
135
|
}
|
|
88
136
|
}
|
|
89
137
|
function uploadWithXhr(target, file, fieldKey, options) {
|
|
@@ -91,6 +139,10 @@ function uploadWithXhr(target, file, fieldKey, options) {
|
|
|
91
139
|
const xhr = new XMLHttpRequest();
|
|
92
140
|
const signal = options.signal;
|
|
93
141
|
const body = file;
|
|
142
|
+
const request = {
|
|
143
|
+
method: normalizeRequestMethod("PUT"),
|
|
144
|
+
url: stripUrlQuery(target.uploadUrl ?? ""),
|
|
145
|
+
};
|
|
94
146
|
xhr.open("PUT", target.uploadUrl ?? "");
|
|
95
147
|
if (file.type) {
|
|
96
148
|
xhr.setRequestHeader("content-type", file.type);
|
|
@@ -106,11 +158,11 @@ function uploadWithXhr(target, file, fieldKey, options) {
|
|
|
106
158
|
resolve();
|
|
107
159
|
return;
|
|
108
160
|
}
|
|
109
|
-
reject(new Error(
|
|
161
|
+
reject(new Error(formatUploadError(request, `${xhr.status} ${xhr.statusText} ${xhr.responseText}`)));
|
|
110
162
|
};
|
|
111
163
|
xhr.onerror = () => {
|
|
112
164
|
cleanup();
|
|
113
|
-
reject(new Error("
|
|
165
|
+
reject(new Error(formatUploadError(request, "network error")));
|
|
114
166
|
};
|
|
115
167
|
xhr.onabort = () => {
|
|
116
168
|
cleanup();
|
|
@@ -122,6 +174,9 @@ function uploadWithXhr(target, file, fieldKey, options) {
|
|
|
122
174
|
xhr.send(body);
|
|
123
175
|
});
|
|
124
176
|
}
|
|
177
|
+
function formatUploadError(request, detail) {
|
|
178
|
+
return `Talizen file upload failed: ${request.method} ${request.url} ${detail}`.replace(/\s+/g, " ").trim();
|
|
179
|
+
}
|
|
125
180
|
async function sha256(file) {
|
|
126
181
|
const subtle = globalThis.crypto?.subtle;
|
|
127
182
|
if (subtle == null) {
|
package/i18n.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* 当前语言运行时信息。由 Talizen 渲染引擎注入到 `globalThis.__TALIZEN_I18N__`
|
|
4
|
+
* (SSR 与浏览器端注入同一份,避免 hydration 不一致)。站点未开启多语言时为空。
|
|
5
|
+
*/
|
|
6
|
+
export interface LocaleRuntime {
|
|
7
|
+
/** 当前生效语言;站点未配置多语言时为 ""。 */
|
|
8
|
+
locale: string;
|
|
9
|
+
/** 站点配置的全部语言。 */
|
|
10
|
+
locales: string[];
|
|
11
|
+
/** 默认语言(其 URL 无前缀)。 */
|
|
12
|
+
defaultLocale: string;
|
|
13
|
+
}
|
|
14
|
+
declare global {
|
|
15
|
+
var __TALIZEN_I18N__: Partial<LocaleRuntime> | undefined;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 读取当前语言运行时信息(当前语言 / 全部语言 / 默认语言)。SSR 与客户端返回一致。
|
|
19
|
+
* 站点未开启多语言时 `locale === ""`。
|
|
20
|
+
*
|
|
21
|
+
* ```tsx
|
|
22
|
+
* const { locale, locales, defaultLocale } = useLocale()
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function useLocale(): LocaleRuntime;
|
|
26
|
+
/**
|
|
27
|
+
* 给站内路径加语言前缀(默认语言不加前缀)。不传 `locale` 时使用当前语言。
|
|
28
|
+
* 站外链接(协议 / 协议相对 URL)与页内锚点(`#...`)原样返回。
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* localizedPath("/blog") // zh 页面 → "/zh/blog";默认语言 → "/blog"
|
|
32
|
+
* localizedPath("/about", "ja") // → "/ja/about"
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function localizedPath(path: string, locale?: string): string;
|
|
36
|
+
/** 记住用户显式语言选择的 Cookie 名(检测优先级:Cookie > Accept-Language > 默认)。 */
|
|
37
|
+
export declare const LOCALE_COOKIE = "CREGHT_LOCALE";
|
|
38
|
+
export type LinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
39
|
+
href: string;
|
|
40
|
+
/**
|
|
41
|
+
* 指定则表示「切换到该语言」:`href` 用该语言前缀,并在点击时写 `CREGHT_LOCALE`
|
|
42
|
+
* Cookie 记住选择。不传则沿用当前语言(普通站内链接)。
|
|
43
|
+
*/
|
|
44
|
+
locale?: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* 语言感知的站内链接,替代裸 `<a>`:自动给 `href` 补当前语言前缀(默认语言不补)。
|
|
48
|
+
* 传 `locale` 表示语言切换(用该语言前缀,且点击时写 `CREGHT_LOCALE` 记住选择)。
|
|
49
|
+
*
|
|
50
|
+
* ```tsx
|
|
51
|
+
* <Link href="/blog">Blog</Link> // 跟随当前语言:zh 页面 → /zh/blog
|
|
52
|
+
* <Link href="/" locale="ja">日本語</Link> // 切到日文并记住
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export declare function Link(props: LinkProps): React.ReactElement;
|
package/i18n.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
function readLocaleRuntime() {
|
|
3
|
+
const d = (typeof globalThis !== "undefined" ? globalThis.__TALIZEN_I18N__ : undefined) ?? {};
|
|
4
|
+
const locales = Array.isArray(d.locales) ? d.locales.filter((l) => typeof l === "string") : [];
|
|
5
|
+
return {
|
|
6
|
+
locale: typeof d.locale === "string" ? d.locale : "",
|
|
7
|
+
locales,
|
|
8
|
+
defaultLocale: typeof d.defaultLocale === "string" ? d.defaultLocale : (locales[0] ?? ""),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* 读取当前语言运行时信息(当前语言 / 全部语言 / 默认语言)。SSR 与客户端返回一致。
|
|
13
|
+
* 站点未开启多语言时 `locale === ""`。
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const { locale, locales, defaultLocale } = useLocale()
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function useLocale() {
|
|
20
|
+
return readLocaleRuntime();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 给站内路径加语言前缀(默认语言不加前缀)。不传 `locale` 时使用当前语言。
|
|
24
|
+
* 站外链接(协议 / 协议相对 URL)与页内锚点(`#...`)原样返回。
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* localizedPath("/blog") // zh 页面 → "/zh/blog";默认语言 → "/blog"
|
|
28
|
+
* localizedPath("/about", "ja") // → "/ja/about"
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function localizedPath(path, locale) {
|
|
32
|
+
if (/^([a-z][a-z0-9+.-]*:)?\/\//i.test(path) || path.startsWith("#"))
|
|
33
|
+
return path;
|
|
34
|
+
const rt = readLocaleRuntime();
|
|
35
|
+
const target = locale ?? rt.locale;
|
|
36
|
+
if (!target || target === rt.defaultLocale)
|
|
37
|
+
return path;
|
|
38
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
39
|
+
return p === "/" ? `/${target}` : `/${target}${p}`;
|
|
40
|
+
}
|
|
41
|
+
/** 记住用户显式语言选择的 Cookie 名(检测优先级:Cookie > Accept-Language > 默认)。 */
|
|
42
|
+
export const LOCALE_COOKIE = "CREGHT_LOCALE";
|
|
43
|
+
/**
|
|
44
|
+
* 语言感知的站内链接,替代裸 `<a>`:自动给 `href` 补当前语言前缀(默认语言不补)。
|
|
45
|
+
* 传 `locale` 表示语言切换(用该语言前缀,且点击时写 `CREGHT_LOCALE` 记住选择)。
|
|
46
|
+
*
|
|
47
|
+
* ```tsx
|
|
48
|
+
* <Link href="/blog">Blog</Link> // 跟随当前语言:zh 页面 → /zh/blog
|
|
49
|
+
* <Link href="/" locale="ja">日本語</Link> // 切到日文并记住
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function Link(props) {
|
|
53
|
+
const { href, locale, onClick, ...rest } = props;
|
|
54
|
+
const finalHref = localizedPath(href, locale);
|
|
55
|
+
const handleClick = (event) => {
|
|
56
|
+
if (locale && typeof document !== "undefined") {
|
|
57
|
+
document.cookie = `${LOCALE_COOKIE}=${encodeURIComponent(locale)}; path=/; max-age=31536000; samesite=lax`;
|
|
58
|
+
}
|
|
59
|
+
onClick?.(event);
|
|
60
|
+
};
|
|
61
|
+
return React.createElement("a", { ...rest, href: finalHref, onClick: handleClick });
|
|
62
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from "./core.js";
|
|
2
2
|
export * from "./cms.js";
|
|
3
|
+
export * from "./captcha-ui.js";
|
|
3
4
|
export * from "./form.js";
|
|
5
|
+
export * from "./i18n.js";
|
|
4
6
|
type OneOrMany<T> = T | Array<T>;
|
|
5
7
|
export interface MetadataTitle {
|
|
6
8
|
default?: string;
|
|
@@ -71,3 +73,29 @@ export interface Metadata {
|
|
|
71
73
|
openGraph?: OpenGraphMetadata | null;
|
|
72
74
|
icons?: MetadataIcons | null;
|
|
73
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* A single site-level redirect rule, aligned with Next.js `redirects()` semantics.
|
|
78
|
+
*
|
|
79
|
+
* Declare rules in the `redirects` array of `talizen.config.ts`. A matching
|
|
80
|
+
* request is redirected before the page renders, so redirects take precedence
|
|
81
|
+
* over pages and `/public` files.
|
|
82
|
+
*/
|
|
83
|
+
export interface Redirect {
|
|
84
|
+
/**
|
|
85
|
+
* Source path to match. Supports exact matches (`/old-page`) and a trailing
|
|
86
|
+
* wildcard segment (`/blog/*`).
|
|
87
|
+
*/
|
|
88
|
+
source: string;
|
|
89
|
+
/**
|
|
90
|
+
* Redirect target. May be an internal path (`/new-page`), a wildcard
|
|
91
|
+
* backreference (`/posts/*`), an absolute URL (`https://example.com/x`), or a
|
|
92
|
+
* protocol-relative URL (`//example.com/x`). Internal paths keep the original
|
|
93
|
+
* query string.
|
|
94
|
+
*/
|
|
95
|
+
destination: string;
|
|
96
|
+
/**
|
|
97
|
+
* `true` issues a 308 permanent redirect (best for SEO); `false` issues a 307
|
|
98
|
+
* temporary redirect.
|
|
99
|
+
*/
|
|
100
|
+
permanent: boolean;
|
|
101
|
+
}
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talizen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Talizen frontend SDK types for cms, form and core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/talizen/talizen.git"
|
|
10
|
+
},
|
|
7
11
|
"sideEffects": false,
|
|
8
12
|
"main": "./index.js",
|
|
9
13
|
"types": "./index.d.ts",
|
|
@@ -20,9 +24,17 @@
|
|
|
20
24
|
"types": "./cms.d.ts",
|
|
21
25
|
"import": "./cms.js"
|
|
22
26
|
},
|
|
27
|
+
"./captcha-ui": {
|
|
28
|
+
"types": "./captcha-ui.d.ts",
|
|
29
|
+
"import": "./captcha-ui.js"
|
|
30
|
+
},
|
|
23
31
|
"./form": {
|
|
24
32
|
"types": "./form.d.ts",
|
|
25
33
|
"import": "./form.js"
|
|
34
|
+
},
|
|
35
|
+
"./i18n": {
|
|
36
|
+
"types": "./i18n.d.ts",
|
|
37
|
+
"import": "./i18n.js"
|
|
26
38
|
}
|
|
27
39
|
}
|
|
28
40
|
}
|