fuzzyworld

when-am-i / files / src / data / index.ts

index.ts

4.4 KB · history · blame

1// Loads every calendar region file in ./calendars/ at build time.
2//
3// Files are plain JSON (one CalendarRegion each); see SCHEMA.md. We use Vite's
4// import.meta.glob so the data is bundled and type-checked alongside the app.
5// A runtime shape-guard makes a malformed file fail loudly with its path,
6// complementing the build-time `validate:data` step (see plan.md).
7
8import type {
9 AnchorEvent,
10 CalendarRegion,
11 Era,
12 Geometry,
13 Region,
14} from '../types';
15
16// Eagerly import all calendar JSON, excluding the schema doc.
17const modules = import.meta.glob<{ default: unknown }>(
18 ['./calendars/*.json', '!./calendars/SCHEMA.json'],
19 { eager: true },
20);
21
22function fail(path: string, msg: string): never {
23 throw new Error(`Invalid calendar data in ${path}: ${msg}`);
24}
25
26function isGeometry(value: unknown): value is Geometry {
27 if (typeof value !== 'object' || value === null) return false;
28 const g = value as { type?: unknown; coordinates?: unknown };
29 return (
30 (g.type === 'Polygon' || g.type === 'MultiPolygon') &&
31 Array.isArray(g.coordinates)
32 );
33}
34
35function assertRegion(value: unknown, path: string): Region {
36 if (typeof value !== 'object' || value === null) fail(path, 'region missing');
37 const r = value as { name?: unknown; geo?: unknown };
38 if (typeof r.name !== 'string') fail(path, 'region.name must be a string');
39 if (!isGeometry(r.geo)) fail(path, 'region.geo must be a Polygon or MultiPolygon');
40 return { name: r.name, geo: r.geo };
41}
42
43function assertAnchorEvents(value: unknown, path: string, i: number): AnchorEvent[] | undefined {
44 if (value === undefined) return undefined;
45 if (!Array.isArray(value)) fail(path, `eras[${i}].anchorEvents must be an array`);
46 return value.map((ev, j) => {
47 if (typeof ev !== 'object' || ev === null) fail(path, `eras[${i}].anchorEvents[${j}] not an object`);
48 const a = ev as Record<string, unknown>;
49 if (typeof a.name !== 'string') fail(path, `eras[${i}].anchorEvents[${j}].name must be a string`);
50 if (typeof a.date !== 'string') fail(path, `eras[${i}].anchorEvents[${j}].date must be YYYY-MM-DD`);
51 if (!Array.isArray(a.sources) || a.sources.length === 0) {
52 fail(path, `eras[${i}].anchorEvents[${j}].sources must be a non-empty array`);
53 }
54 return { name: a.name, date: a.date, sources: a.sources as string[] };
55 });
56}
57
58function assertEra(value: unknown, path: string, i: number): Era {
59 if (typeof value !== 'object' || value === null) fail(path, `eras[${i}] not an object`);
60 const e = value as Record<string, unknown>;
61 if (typeof e.name !== 'string') fail(path, `eras[${i}].name must be a string`);
62 if (typeof e.calendar !== 'string') fail(path, `eras[${i}].calendar must be a string`);
63 if (e.start !== null && typeof e.start !== 'string') fail(path, `eras[${i}].start must be YYYY-MM-DD or null`);
64 if (e.end !== null && typeof e.end !== 'string') fail(path, `eras[${i}].end must be YYYY-MM-DD or null`);
65 return {
66 name: e.name,
67 start: e.start as string | null,
68 end: e.end as string | null,
69 calendar: e.calendar,
70 usedBy: typeof e.usedBy === 'string' ? e.usedBy : undefined,
71 description: typeof e.description === 'string' ? e.description : undefined,
72 sources: Array.isArray(e.sources) ? (e.sources as string[]) : undefined,
73 anchorEvents: assertAnchorEvents(e.anchorEvents, path, i),
74 suppressModernDefault: typeof e.suppressModernDefault === 'boolean' ? e.suppressModernDefault : undefined,
75 };
76}
77
78function assertCalendarRegion(value: unknown, path: string): CalendarRegion {
79 if (typeof value !== 'object' || value === null) fail(path, 'file is not an object');
80 const v = value as { region?: unknown; eras?: unknown };
81 const region = assertRegion(v.region, path);
82 if (!Array.isArray(v.eras)) fail(path, 'eras must be an array');
83 const eras = v.eras.map((era, i) => assertEra(era, path, i));
84 // glob keys look like "./calendars/massachusett.json"; store the repo-relative
85 // path so the UI can link to the source file on GitLab.
86 const sourceFile = `src/data/${path.replace(/^\.\//, '')}`;
87 return { region, eras, sourceFile };
88}
89
90/** All calendar regions, validated. Throws on the first malformed file. */
91export const regions: CalendarRegion[] = Object.entries(modules).map(
92 ([path, mod]) => assertCalendarRegion(mod.default, path),
93);