|
| 1 | +import { mkdir, writeFile } from 'node:fs/promises'; |
1 | 2 | import path from 'node:path'; |
2 | 3 | import { fileURLToPath } from 'node:url'; |
3 | 4 |
|
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 | +} |
5 | 120 |
|
6 | 121 | const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); |
7 | 122 | const outputPath = path.join(repoRoot, 'src', 'data', 'footer-sites.snapshot.json'); |
|
0 commit comments