@bitbaum/geo-kit 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,375 @@
1
+ import { GEOGRAPHY_MANIFEST_VERSION } from "./types.js";
2
+ import { isWgs84Bounds } from "./spatial.js";
3
+ const keyPattern = /^[a-z0-9][a-z0-9._:-]{0,127}$/i;
4
+ const shaPattern = /^[a-f0-9]{64}$/i;
5
+ const validFormats = new Set(["geojson", "topojson"]);
6
+ const validAssertionKinds = new Set(["claims", "administers", "proposes"]);
7
+ export function isStableKey(value) {
8
+ return typeof value === "string" && keyPattern.test(value);
9
+ }
10
+ export function isISODate(value) {
11
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
12
+ return false;
13
+ const [year, month, day] = value.split("-").map(Number);
14
+ const date = new Date(0);
15
+ date.setUTCHours(0, 0, 0, 0);
16
+ date.setUTCFullYear(year, month - 1, day);
17
+ return date.toISOString().slice(0, 10) === value;
18
+ }
19
+ function isDateTime(value) {
20
+ return (typeof value === "string" &&
21
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) &&
22
+ isISODate(value.slice(0, 10)) &&
23
+ Number.isFinite(Date.parse(value)));
24
+ }
25
+ export function isPlainObject(value) {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+ export function validPeriod(from, to) {
29
+ return ((from === null || isISODate(from)) &&
30
+ (to === null || isISODate(to)) &&
31
+ !(typeof from === "string" && typeof to === "string" && to <= from));
32
+ }
33
+ export function validUrl(value, protocols) {
34
+ if (typeof value !== "string")
35
+ return false;
36
+ try {
37
+ const url = new URL(value);
38
+ return protocols.includes(url.protocol) && !url.username && !url.password;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ export function safeResourceHref(value) {
45
+ if (typeof value !== "string" ||
46
+ !value.startsWith("/") ||
47
+ value.startsWith("//") ||
48
+ value.includes("\\") ||
49
+ value.includes("?") ||
50
+ value.includes("#")
51
+ || /[\u0000-\u0020\u007f]/.test(value)) {
52
+ return false;
53
+ }
54
+ try {
55
+ const decoded = decodeURIComponent(value);
56
+ return !decoded.startsWith("//") && !decoded.includes("\\") &&
57
+ !/[\u0000-\u001f\u007f]/.test(decoded) &&
58
+ !decoded.split("/").some((segment) => segment === "." || segment === "..");
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /** Validate a manifest without fetching anything. Licenses and their legal policy are supplied by the owner. */
65
+ export function validateGeographyManifest(value, options = {}) {
66
+ const problems = [];
67
+ if (!isPlainObject(value))
68
+ return ["manifest must be an object"];
69
+ if (value.schemaVersion !== GEOGRAPHY_MANIFEST_VERSION) {
70
+ problems.push(`schemaVersion must be ${GEOGRAPHY_MANIFEST_VERSION}`);
71
+ }
72
+ if (typeof value.datasetVersion !== "string" || !value.datasetVersion.trim()) {
73
+ problems.push("datasetVersion must be a non-empty string");
74
+ }
75
+ if (!isDateTime(value.generatedAt))
76
+ problems.push("generatedAt must be an ISO date-time");
77
+ if (!Array.isArray(value.sources))
78
+ problems.push("sources must be an array");
79
+ if (!Array.isArray(value.viewpoints))
80
+ problems.push("viewpoints must be an array");
81
+ if (!Array.isArray(value.resources))
82
+ problems.push("resources must be an array");
83
+ if (!Array.isArray(value.sources) || !Array.isArray(value.viewpoints) || !Array.isArray(value.resources))
84
+ return problems;
85
+ const maxResources = options.maxResources ?? 100_000;
86
+ const maxByteSize = options.maxByteSize ?? Number.MAX_SAFE_INTEGER;
87
+ if (value.resources.length > maxResources)
88
+ problems.push(`resources exceeds limit ${maxResources}`);
89
+ const sourceIds = new Set();
90
+ const resourceIds = new Set();
91
+ const sources = new Map();
92
+ for (const [i, raw] of value.sources.entries()) {
93
+ const where = `sources[${i}]`;
94
+ if (!isPlainObject(raw)) {
95
+ problems.push(`${where} must be an object`);
96
+ continue;
97
+ }
98
+ if (typeof raw.id !== "string" || !isStableKey(raw.id))
99
+ problems.push(`${where}.id is invalid`);
100
+ else if (sourceIds.has(raw.id))
101
+ problems.push(`${where}.id duplicates ${raw.id}`);
102
+ else {
103
+ sourceIds.add(raw.id);
104
+ sources.set(raw.id, raw);
105
+ }
106
+ for (const field of ["publisher", "dataset"]) {
107
+ if (typeof raw[field] !== "string" || !raw[field].trim())
108
+ problems.push(`${where}.${field} is required`);
109
+ }
110
+ if (!validUrl(raw.url, ["https:", "http:"]))
111
+ problems.push(`${where}.url must be an HTTP(S) URL`);
112
+ if (typeof raw.licenseSPDX !== "string" || !raw.licenseSPDX.trim()) {
113
+ problems.push(`${where}.licenseSPDX is required`);
114
+ }
115
+ if (!isDateTime(raw.retrievedAt))
116
+ problems.push(`${where}.retrievedAt must be an ISO date-time`);
117
+ }
118
+ const viewpoints = new Map();
119
+ for (const [i, raw] of value.viewpoints.entries()) {
120
+ const where = `viewpoints[${i}]`;
121
+ if (!isPlainObject(raw)) {
122
+ problems.push(`${where} must be an object`);
123
+ continue;
124
+ }
125
+ if (!isStableKey(raw.key))
126
+ problems.push(`${where}.key is invalid`);
127
+ else if (viewpoints.has(raw.key))
128
+ problems.push(`${where}.key duplicates ${raw.key}`);
129
+ else
130
+ viewpoints.set(raw.key, raw);
131
+ for (const field of ["label", "description"]) {
132
+ if (typeof raw[field] !== "string" || !raw[field].trim())
133
+ problems.push(`${where}.${field} is required`);
134
+ }
135
+ if (!Array.isArray(raw.sourceIds) || raw.sourceIds.length === 0) {
136
+ problems.push(`${where}.sourceIds must name at least one declared source`);
137
+ }
138
+ else {
139
+ const used = new Set();
140
+ for (const sourceId of raw.sourceIds) {
141
+ if (typeof sourceId !== "string" || !sourceIds.has(sourceId)) {
142
+ problems.push(`${where}.sourceIds contains an undeclared source`);
143
+ }
144
+ else if (used.has(sourceId)) {
145
+ problems.push(`${where}.sourceIds duplicates ${sourceId}`);
146
+ }
147
+ if (typeof sourceId === "string")
148
+ used.add(sourceId);
149
+ }
150
+ }
151
+ }
152
+ const policy = new Map((options.licensePolicy ?? []).map((rule) => [rule.spdx, rule]));
153
+ for (const [i, raw] of value.resources.entries()) {
154
+ const where = `resources[${i}]`;
155
+ if (!isPlainObject(raw)) {
156
+ problems.push(`${where} must be an object`);
157
+ continue;
158
+ }
159
+ for (const field of ["id", "kindKey", "geographyId", "levelKey"]) {
160
+ if (typeof raw[field] !== "string" || !isStableKey(raw[field])) {
161
+ problems.push(`${where}.${field} is invalid`);
162
+ }
163
+ }
164
+ if (typeof raw.id === "string") {
165
+ if (resourceIds.has(raw.id))
166
+ problems.push(`${where}.id duplicates ${raw.id}`);
167
+ resourceIds.add(raw.id);
168
+ }
169
+ const resourceSourceIds = new Set();
170
+ if (!Array.isArray(raw.sourceIds) || raw.sourceIds.length === 0) {
171
+ problems.push(`${where}.sourceIds must name at least one declared source`);
172
+ }
173
+ else {
174
+ for (const sourceId of raw.sourceIds) {
175
+ if (typeof sourceId !== "string" || !sourceIds.has(sourceId)) {
176
+ problems.push(`${where}.sourceIds contains an undeclared source`);
177
+ continue;
178
+ }
179
+ if (resourceSourceIds.has(sourceId))
180
+ problems.push(`${where}.sourceIds duplicates ${sourceId}`);
181
+ resourceSourceIds.add(sourceId);
182
+ const source = sources.get(sourceId);
183
+ const licenseRule = policy.get(String(source.licenseSPDX));
184
+ if (options.licensePolicy && !licenseRule) {
185
+ problems.push(`${where} uses license ${String(source.licenseSPDX)} outside the supplied policy`);
186
+ }
187
+ if (licenseRule?.requiresAttribution && (typeof source.attribution !== "string" || !source.attribution.trim())) {
188
+ problems.push(`${where} requires source attribution for ${String(source.licenseSPDX)}`);
189
+ }
190
+ }
191
+ }
192
+ if (!validFormats.has(raw.format))
193
+ problems.push(`${where}.format is unsupported`);
194
+ if (!safeResourceHref(raw.href))
195
+ problems.push(`${where}.href must be a safe root-relative path`);
196
+ if (typeof raw.sha256 !== "string" || !shaPattern.test(raw.sha256))
197
+ problems.push(`${where}.sha256 must be a SHA-256 hex digest`);
198
+ if (!Number.isSafeInteger(raw.byteSize) || Number(raw.byteSize) < 1 || Number(raw.byteSize) > maxByteSize) {
199
+ problems.push(`${where}.byteSize is outside the allowed range`);
200
+ }
201
+ if (!Number.isSafeInteger(raw.featureCount) || Number(raw.featureCount) < 1) {
202
+ problems.push(`${where}.featureCount must be a positive integer`);
203
+ }
204
+ if (!validPeriod(raw.validFrom, raw.validTo))
205
+ problems.push(`${where} has an invalid validity period`);
206
+ if (!(raw.viewpointKey === null || (typeof raw.viewpointKey === "string" && isStableKey(raw.viewpointKey)))) {
207
+ problems.push(`${where}.viewpointKey must be null or a stable key`);
208
+ }
209
+ else if (typeof raw.viewpointKey === "string") {
210
+ const viewpoint = viewpoints.get(raw.viewpointKey);
211
+ if (!viewpoint) {
212
+ problems.push(`${where}.viewpointKey does not reference a declared viewpoint`);
213
+ }
214
+ else if (!Array.isArray(viewpoint.sourceIds) ||
215
+ !viewpoint.sourceIds.every((sourceId) => resourceSourceIds.has(sourceId))) {
216
+ problems.push(`${where}.sourceIds do not include the sources listed for viewpoint ${raw.viewpointKey}`);
217
+ }
218
+ }
219
+ if (raw.bbox !== undefined && !isWgs84Bounds(raw.bbox))
220
+ problems.push(`${where}.bbox is invalid`);
221
+ for (const field of ["minZoom", "maxZoom"]) {
222
+ if (raw[field] !== undefined && (!Number.isFinite(raw[field]) || Number(raw[field]) < 0 || Number(raw[field]) > 30)) {
223
+ problems.push(`${where}.${field} must be between 0 and 30`);
224
+ }
225
+ }
226
+ if (typeof raw.minZoom === "number" &&
227
+ typeof raw.maxZoom === "number" &&
228
+ raw.maxZoom <= raw.minZoom) {
229
+ problems.push(`${where}.maxZoom must be greater than minZoom`);
230
+ }
231
+ }
232
+ return problems;
233
+ }
234
+ export function periodContains(from, to, date) {
235
+ return (from === null || from <= date) && (to === null || date < to);
236
+ }
237
+ /** Resolve current/historical claims as recorded. No default viewpoint or legal status is inferred. */
238
+ /** Prevent malformed API records or unsourced claims from silently changing a map's dispute styling. */
239
+ export function validateAreaAssertions(value) {
240
+ if (!Array.isArray(value))
241
+ return ["area assertions must be an array"];
242
+ const problems = [];
243
+ for (const [i, raw] of value.entries()) {
244
+ const where = `areaAssertions[${i}]`;
245
+ if (!isPlainObject(raw)) {
246
+ problems.push(`${where} must be an object`);
247
+ continue;
248
+ }
249
+ for (const field of ["areaId", "jurisdictionId", "sourceId"]) {
250
+ if (typeof raw[field] !== "string" || !raw[field].trim())
251
+ problems.push(`${where}.${field} is required`);
252
+ }
253
+ if (typeof raw.assertion !== "string" || !validAssertionKinds.has(raw.assertion)) {
254
+ problems.push(`${where}.assertion is unsupported`);
255
+ }
256
+ if (!(raw.assertedById === null || (typeof raw.assertedById === "string" && raw.assertedById.trim()))) {
257
+ problems.push(`${where}.assertedById must be a non-empty id or null`);
258
+ }
259
+ if (!validPeriod(raw.validFrom, raw.validTo))
260
+ problems.push(`${where} has an invalid validity period`);
261
+ if (problems.length >= 40)
262
+ break;
263
+ }
264
+ return problems;
265
+ }
266
+ /** Validate a GeoJSON geometry's coordinates and structural rules. */
267
+ export function validateGeometry(value, maxPositions = 1_000_000) {
268
+ const problems = [];
269
+ let count = 0;
270
+ const checkPosition = (position, path) => {
271
+ count += 1;
272
+ if (count > maxPositions) {
273
+ if (count === maxPositions + 1)
274
+ problems.push(`geometry exceeds ${maxPositions} positions`);
275
+ return;
276
+ }
277
+ problems.push(...positionProblems(position, path));
278
+ };
279
+ const checkLine = (positions, path, minimum) => {
280
+ if (!Array.isArray(positions) || positions.length < minimum) {
281
+ problems.push(`${path} needs at least ${minimum} positions`);
282
+ return;
283
+ }
284
+ positions.forEach((position, i) => checkPosition(position, `${path}[${i}]`));
285
+ };
286
+ const checkRing = (ring, path) => {
287
+ if (!Array.isArray(ring) || ring.length < 4) {
288
+ problems.push(`${path} needs at least four positions`);
289
+ return;
290
+ }
291
+ const validPositions = ring.filter((p) => Array.isArray(p) && p.length >= 2);
292
+ if (validPositions.length > 0 && JSON.stringify(validPositions[0]) !== JSON.stringify(validPositions[validPositions.length - 1])) {
293
+ problems.push(`${path} must be closed`);
294
+ }
295
+ ring.forEach((position, i) => checkPosition(position, `${path}[${i}]`));
296
+ };
297
+ const visit = (geometry, path, depth) => {
298
+ if (!isPlainObject(geometry) || depth > 16) {
299
+ problems.push(`${path} must be a supported geometry`);
300
+ return;
301
+ }
302
+ switch (geometry.type) {
303
+ case "Point":
304
+ checkPosition(geometry.coordinates, `${path}.coordinates`);
305
+ break;
306
+ case "MultiPoint":
307
+ checkLine(geometry.coordinates, `${path}.coordinates`, 1);
308
+ break;
309
+ case "LineString":
310
+ checkLine(geometry.coordinates, `${path}.coordinates`, 2);
311
+ break;
312
+ case "MultiLineString":
313
+ if (!Array.isArray(geometry.coordinates) || geometry.coordinates.length === 0) {
314
+ problems.push(`${path}.coordinates must contain at least one line`);
315
+ }
316
+ else {
317
+ geometry.coordinates.forEach((line, i) => checkLine(line, `${path}.coordinates[${i}]`, 2));
318
+ }
319
+ break;
320
+ case "Polygon":
321
+ if (!Array.isArray(geometry.coordinates) || geometry.coordinates.length === 0) {
322
+ problems.push(`${path}.coordinates must contain an exterior ring`);
323
+ }
324
+ else {
325
+ geometry.coordinates.forEach((ring, i) => checkRing(ring, `${path}.coordinates[${i}]`));
326
+ }
327
+ break;
328
+ case "MultiPolygon":
329
+ if (!Array.isArray(geometry.coordinates) || geometry.coordinates.length === 0) {
330
+ problems.push(`${path}.coordinates must contain at least one polygon`);
331
+ }
332
+ else {
333
+ geometry.coordinates.forEach((polygon, i) => {
334
+ if (!Array.isArray(polygon) || polygon.length === 0) {
335
+ problems.push(`${path}.coordinates[${i}] must contain an exterior ring`);
336
+ }
337
+ else {
338
+ polygon.forEach((ring, j) => checkRing(ring, `${path}.coordinates[${i}][${j}]`));
339
+ }
340
+ });
341
+ }
342
+ break;
343
+ case "GeometryCollection":
344
+ if (!Array.isArray(geometry.geometries)) {
345
+ problems.push(`${path}.geometries must be an array`);
346
+ }
347
+ else {
348
+ geometry.geometries.forEach((child, i) => visit(child, `${path}.geometries[${i}]`, depth + 1));
349
+ }
350
+ break;
351
+ default:
352
+ problems.push(`${path}.type is unsupported`);
353
+ }
354
+ };
355
+ visit(value, "geometry", 0);
356
+ return problems;
357
+ }
358
+ /** True when a coordinate tuple is a finite WGS84 position (lon, lat, optional altitude). */
359
+ export function isWgs84Position(value) {
360
+ return positionProblems(value, "position").length === 0;
361
+ }
362
+ function positionProblems(value, path) {
363
+ const problems = [];
364
+ if (!Array.isArray(value) || value.length < 2 || !value.every(Number.isFinite)) {
365
+ return [`${path} must contain finite longitude and latitude coordinates`];
366
+ }
367
+ if (value.length > 4)
368
+ problems.push(`${path} has more than four coordinate dimensions`);
369
+ const [longitude, latitude] = value;
370
+ if (longitude < -180 || longitude > 180)
371
+ problems.push(`${path} longitude is outside WGS84 bounds`);
372
+ if (latitude < -90 || latitude > 90)
373
+ problems.push(`${path} latitude is outside WGS84 bounds`);
374
+ return problems;
375
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@bitbaum/geo-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Versioned, on-demand geography resources and sourced boundary assertions for web applications.",
5
+ "author": "BitBaum",
6
+ "homepage": "https://github.com/bitbaum/geo-kit#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/bitbaum/geo-kit.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/bitbaum/geo-kit/issues"
13
+ },
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "packageManager": "pnpm@11.25.0",
17
+ "engines": { "node": ">=20" },
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "files": ["dist", "README.md", "LICENSE"],
28
+ "publishConfig": { "access": "public", "provenance": true },
29
+ "sideEffects": false,
30
+ "scripts": {
31
+ "typecheck": "tsc --noEmit -p tsconfig.json",
32
+ "build": "tsc -p tsconfig.build.json",
33
+ "test": "node --experimental-strip-types --test test/*.test.ts",
34
+ "pack:check": "node scripts/check-pack.mjs",
35
+ "verify": "pnpm run typecheck && pnpm run test && pnpm run build && pnpm run pack:check"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.0.0",
39
+ "typescript": "^5.8.0"
40
+ }
41
+ }