Skip to content

Commit 2d31337

Browse files
refactor(scripts): inline footer sites snapshot validation logic
主要变更: - 将快照数据校验逻辑内联到 fetch-sites-snapshot.mjs 脚本中 - 添加 normalizeFooterSitesSnapshotPayload 函数,包含完整的 payload 校验 - 新增 URL 规范化、字符串校验等工具函数 - 移除对 footer-sites-snapshot-workflow.mjs 的外部依赖 - 改进错误处理,支持 HTTP 状态码和 Content-Type 验证 Co-Authored-By: Hagicode <noreply@hagicode.com> Signed-off-by: newbe36524 <newbe36524@qq.com>
1 parent 35344f6 commit 2d31337

1 file changed

Lines changed: 116 additions & 1 deletion

File tree

scripts/fetch-sites-snapshot.mjs

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,122 @@
1+
import { mkdir, writeFile } from 'node:fs/promises';
12
import path from 'node:path';
23
import { fileURLToPath } from 'node:url';
34

4-
import { updateFooterSitesSnapshot } from '../../../scripts/footer-sites-snapshot-workflow.mjs';
5+
const FOOTER_SITES_SNAPSHOT_URL = 'https://index.hagicode.com/sites.json';
6+
7+
function assert(condition, message) {
8+
if (!condition) {
9+
throw new Error(message);
10+
}
11+
}
12+
13+
function isRecord(value) {
14+
return typeof value === 'object' && value !== null && !Array.isArray(value);
15+
}
16+
17+
function assertNonEmptyString(value, fieldName) {
18+
assert(typeof value === 'string' && value.trim().length > 0, `Invalid footer sites snapshot payload: ${fieldName} must be a non-empty string`);
19+
return value.trim();
20+
}
21+
22+
function normalizeHttpsUrl(value, fieldName) {
23+
const raw = assertNonEmptyString(value, fieldName);
24+
let parsed;
25+
26+
try {
27+
parsed = new URL(raw);
28+
} catch {
29+
throw new Error(`Invalid footer sites snapshot payload: ${fieldName} must be a valid URL`);
30+
}
31+
32+
assert(parsed.protocol === 'https:', `Invalid footer sites snapshot payload: ${fieldName} must use https`);
33+
parsed.hash = '';
34+
return parsed.toString();
35+
}
36+
37+
function normalizeFooterSitesSnapshotPayload(payload) {
38+
assert(isRecord(payload), 'Invalid footer sites snapshot payload: root must be an object');
39+
40+
const version = assertNonEmptyString(payload.version, 'version');
41+
const generatedAt = assertNonEmptyString(payload.generatedAt, 'generatedAt');
42+
const groups = payload.groups;
43+
const entries = payload.entries;
44+
45+
assert(Array.isArray(groups) && groups.length > 0, 'Invalid footer sites snapshot payload: groups must be a non-empty array');
46+
assert(Array.isArray(entries) && entries.length > 0, 'Invalid footer sites snapshot payload: entries must be a non-empty array');
47+
48+
const normalizedGroups = groups.map((group, index) => {
49+
assert(isRecord(group), `Invalid footer sites snapshot payload: groups[${index}] must be an object`);
50+
return {
51+
id: assertNonEmptyString(group.id, `groups[${index}].id`),
52+
label: assertNonEmptyString(group.label, `groups[${index}].label`),
53+
description: assertNonEmptyString(group.description, `groups[${index}].description`),
54+
};
55+
});
56+
57+
const knownGroupIds = new Set();
58+
for (const group of normalizedGroups) {
59+
assert(!knownGroupIds.has(group.id), `Invalid footer sites snapshot payload: duplicate group id "${group.id}"`);
60+
knownGroupIds.add(group.id);
61+
}
62+
63+
const seenEntryIds = new Set();
64+
const normalizedEntries = entries.map((entry, index) => {
65+
assert(isRecord(entry), `Invalid footer sites snapshot payload: entries[${index}] must be an object`);
66+
67+
const id = assertNonEmptyString(entry.id, `entries[${index}].id`);
68+
const groupId = assertNonEmptyString(entry.groupId, `entries[${index}].groupId`);
69+
70+
assert(!seenEntryIds.has(id), `Invalid footer sites snapshot payload: duplicate entry id "${id}"`);
71+
assert(knownGroupIds.has(groupId), `Invalid footer sites snapshot payload: entries[${index}].groupId references unknown group "${groupId}"`);
72+
seenEntryIds.add(id);
73+
74+
return {
75+
id,
76+
title: assertNonEmptyString(entry.title, `entries[${index}].title`),
77+
label: assertNonEmptyString(entry.label, `entries[${index}].label`),
78+
description: assertNonEmptyString(entry.description, `entries[${index}].description`),
79+
groupId,
80+
url: normalizeHttpsUrl(entry.url, `entries[${index}].url`),
81+
actionLabel: assertNonEmptyString(entry.actionLabel, `entries[${index}].actionLabel`),
82+
};
83+
});
84+
85+
return {
86+
version,
87+
generatedAt,
88+
groups: normalizedGroups,
89+
entries: normalizedEntries,
90+
};
91+
}
92+
93+
async function updateFooterSitesSnapshot({
94+
fetchImpl = globalThis.fetch,
95+
outputPath,
96+
url = FOOTER_SITES_SNAPSHOT_URL,
97+
} = {}) {
98+
assert(typeof fetchImpl === 'function', 'Footer sites snapshot fetch requires a fetch implementation');
99+
assertNonEmptyString(outputPath, 'outputPath');
100+
101+
const response = await fetchImpl(url, {
102+
headers: {
103+
accept: 'application/json',
104+
},
105+
});
106+
107+
if (!response?.ok) {
108+
throw new Error(`Failed to fetch footer sites snapshot: ${response?.status ?? 'unknown status'}`);
109+
}
110+
111+
const contentType = response.headers?.get?.('content-type') ?? '';
112+
if (!contentType.toLowerCase().includes('application/json')) {
113+
throw new Error(`Failed to fetch footer sites snapshot: expected application/json from ${url} but received ${contentType || 'unknown content-type'}`);
114+
}
115+
116+
const payload = normalizeFooterSitesSnapshotPayload(await response.json());
117+
await mkdir(path.dirname(outputPath), { recursive: true });
118+
await writeFile(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
119+
}
5120

6121
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7122
const outputPath = path.join(repoRoot, 'src', 'data', 'footer-sites.snapshot.json');

0 commit comments

Comments
 (0)