Skip to content

Commit f67bc88

Browse files
committed
fix: 投稿目录与文件名校验统一
1 parent 75cdadf commit f67bc88

File tree

4 files changed

+107
-15
lines changed

4 files changed

+107
-15
lines changed

app/components/Contribute.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ import { useRouter } from "next/navigation";
2020
import { TreeSelect } from "antd";
2121
import { DataNode } from "antd/es/tree";
2222
import { buildDocsNewUrl } from "@/lib/github";
23-
import { type DirNode, FILENAME_PATTERN } from "@/lib/submission";
23+
import {
24+
MAX_SLUG_LENGTH,
25+
sanitizeSlug,
26+
type DirNode,
27+
validateSlug,
28+
} from "@/lib/submission";
2429
import {
2530
CREATE_SUBDIR_SUFFIX,
2631
toTreeSelectData,
@@ -60,21 +65,31 @@ export function Contribute() {
6065
const [articleFileTouched, setArticleFileTouched] = useState(false);
6166

6267
const trimmedArticleFile = useMemo(() => articleFile.trim(), [articleFile]);
68+
const sanitizedArticleFile = useMemo(
69+
() => sanitizeSlug(trimmedArticleFile),
70+
[trimmedArticleFile],
71+
);
6372
const { isFileNameValid, fileNameError } = useMemo(() => {
6473
if (!trimmedArticleFile) {
6574
return {
6675
isFileNameValid: false,
6776
fileNameError: "请填写文件名。",
6877
};
6978
}
70-
if (!FILENAME_PATTERN.test(trimmedArticleFile)) {
79+
if (!validateSlug(sanitizedArticleFile)) {
80+
return {
81+
isFileNameValid: false,
82+
fileNameError: `文件名仅支持英文、数字、连字符或下划线(最长 ${MAX_SLUG_LENGTH} 个字符)。`,
83+
};
84+
}
85+
if (sanitizedArticleFile !== trimmedArticleFile) {
7186
return {
7287
isFileNameValid: false,
73-
fileNameError: "文件名仅支持英文、数字、连字符或下划线。",
88+
fileNameError: `请使用规范化后的文件名:${sanitizedArticleFile}`,
7489
};
7590
}
7691
return { isFileNameValid: true, fileNameError: "" };
77-
}, [trimmedArticleFile]);
92+
}, [sanitizedArticleFile, trimmedArticleFile]);
7893

7994
useEffect(() => {
8095
let mounted = true;
@@ -98,22 +113,23 @@ export function Contribute() {
98113
}, []);
99114

100115
const options = useMemo(() => toTreeSelectData(tree), [tree]);
116+
const sanitizedSubdir = useMemo(() => sanitizeSlug(newSub), [newSub]);
101117

102118
const finalDirPath = useMemo(() => {
103119
if (!selectedKey) return "";
104120
if (selectedKey.endsWith(CREATE_SUBDIR_SUFFIX)) {
105121
const l1 = selectedKey.split("/")[0];
106-
if (!newSub.trim()) return "";
107-
return `${l1}/${newSub.trim().replace(/\s+/g, "-")}`;
122+
if (!sanitizedSubdir) return "";
123+
return `${l1}/${sanitizedSubdir}`;
108124
}
109125
return selectedKey;
110-
}, [selectedKey, newSub]);
126+
}, [sanitizedSubdir, selectedKey]);
111127

112128
const canProceed = !!finalDirPath && isFileNameValid;
113129

114130
const handleOpenGithub = () => {
115131
if (!canProceed) return;
116-
const filename = trimmedArticleFile.toLowerCase();
132+
const filename = sanitizedArticleFile;
117133
const title = articleTitle || filename;
118134
window.open(
119135
buildGithubNewUrl(finalDirPath, filename, title),

app/components/DocsDestinationForm.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { TreeSelect } from "antd";
55
import type { DataNode } from "antd/es/tree";
66
import { Input } from "@/components/ui/input";
77
import { Label } from "@/app/components/ui/label";
8-
import { type DirNode } from "@/lib/submission";
8+
import { sanitizeSlug, type DirNode } from "@/lib/submission";
99
import {
1010
CREATE_SUBDIR_SUFFIX,
1111
toTreeSelectData,
@@ -49,16 +49,16 @@ export function DocsDestinationForm({ onChange }: DocsDestinationFormProps) {
4949
}, []);
5050

5151
const options = useMemo(() => toTreeSelectData(tree), [tree]);
52+
const sanitizedSubdir = useMemo(() => sanitizeSlug(newSub), [newSub]);
5253

5354
const finalDirPath = useMemo(() => {
5455
if (!selectedKey) return "";
5556
if (!selectedKey.endsWith(CREATE_SUBDIR_SUFFIX)) return selectedKey;
5657
const [l1] = selectedKey.split("/");
5758
if (!l1) return "";
58-
const sanitized = newSub.trim().replace(/\s+/g, "-");
59-
if (!sanitized) return "";
60-
return `${l1}/${sanitized}`;
61-
}, [selectedKey, newSub]);
59+
if (!sanitizedSubdir) return "";
60+
return `${l1}/${sanitizedSubdir}`;
61+
}, [selectedKey, sanitizedSubdir]);
6262

6363
useEffect(() => {
6464
onChange?.(finalDirPath);
@@ -113,8 +113,14 @@ export function DocsDestinationForm({ onChange }: DocsDestinationFormProps) {
113113
onChange={(e) => setNewSub(e.target.value)}
114114
/>
115115
<p className="text-xs text-muted-foreground">
116-
将创建路径:{selectedKey.split("/")[0]} / {newSub || "<未填写>"}
116+
将创建路径:{selectedKey.split("/")[0]} /{" "}
117+
{sanitizedSubdir || "<未填写或格式不合法>"}
117118
</p>
119+
{newSub && !sanitizedSubdir && (
120+
<p className="text-xs text-destructive">
121+
仅支持英文、数字、连字符或下划线,且需以字母或数字开头。
122+
</p>
123+
)}
118124
</div>
119125
)}
120126

lib/submission.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ export type DirNode = {
44
children?: DirNode[];
55
};
66

7-
export const FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]+$/;
7+
export const FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
8+
export const MAX_SLUG_LENGTH = 100;
89

910
export function ensureMarkdownExtension(filename: string) {
1011
const trimmed = filename.trim();
@@ -17,3 +18,30 @@ export function ensureMarkdownExtension(filename: string) {
1718
export function stripMarkdownExtension(filename: string) {
1819
return filename.toLowerCase().replace(/\.md$/i, "");
1920
}
21+
22+
export function sanitizeSlug(input: string) {
23+
const normalized = input.normalize("NFKC").toLowerCase().trim();
24+
let slug = normalized.replace(/[^a-z0-9_-]+/g, "-");
25+
slug = slug.replace(/[-_]{2,}/g, (match) =>
26+
match.includes("-") ? "-" : "_",
27+
);
28+
slug = slug.replace(/^[-_]+|[-_]+$/g, "");
29+
30+
if (slug.length > MAX_SLUG_LENGTH) {
31+
slug = slug.slice(0, MAX_SLUG_LENGTH).replace(/^[-_]+|[-_]+$/g, "");
32+
}
33+
34+
return slug;
35+
}
36+
37+
export function validateSlug(slug: string) {
38+
if (typeof slug !== "string") return false;
39+
const sanitized = sanitizeSlug(slug);
40+
41+
return (
42+
sanitized.length > 0 &&
43+
sanitized === slug &&
44+
sanitized.length <= MAX_SLUG_LENGTH &&
45+
FILENAME_PATTERN.test(sanitized)
46+
);
47+
}

tests/submission.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it } from "vitest";
2+
import { MAX_SLUG_LENGTH, sanitizeSlug, validateSlug } from "@/lib/submission";
3+
4+
describe("sanitizeSlug", () => {
5+
it("normalizes case and whitespace", () => {
6+
expect(sanitizeSlug(" My Post ")).toBe("my-post");
7+
});
8+
9+
it("removes unsupported characters and emoji", () => {
10+
expect(sanitizeSlug("post🔥🚀")).toBe("post");
11+
});
12+
13+
it("converts full-width characters to half-width", () => {
14+
expect(sanitizeSlug("ABC-123")).toBe("abc-123");
15+
});
16+
17+
it("collapses repeated separators and trims edges", () => {
18+
expect(sanitizeSlug("__my---post__")).toBe("my-post");
19+
});
20+
21+
it("enforces length limits", () => {
22+
const long = "a".repeat(MAX_SLUG_LENGTH + 20);
23+
expect(sanitizeSlug(long)).toHaveLength(MAX_SLUG_LENGTH);
24+
});
25+
});
26+
27+
describe("validateSlug", () => {
28+
it("accepts already-sanitized slugs within length", () => {
29+
expect(validateSlug("a")).toBe(true);
30+
expect(validateSlug("my-post")).toBe(true);
31+
expect(validateSlug("a".repeat(MAX_SLUG_LENGTH))).toBe(true);
32+
});
33+
34+
it("rejects mixed-case or unsanitized slugs", () => {
35+
expect(validateSlug("My-Post")).toBe(false);
36+
expect(validateSlug("invalid slug")).toBe(false);
37+
});
38+
39+
it("rejects overly long slugs", () => {
40+
expect(validateSlug("a".repeat(MAX_SLUG_LENGTH + 1))).toBe(false);
41+
});
42+
});

0 commit comments

Comments
 (0)