styled-map-package-api 5.0.0-pre.2 → 5.0.0-pre.4

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.
Files changed (94) hide show
  1. package/README.md +94 -0
  2. package/dist/download.d.ts +11 -21
  3. package/dist/fallbacks.d.ts +32 -0
  4. package/dist/from-mbtiles.d.ts +1 -3
  5. package/dist/index.d.ts +11 -24
  6. package/dist/reader.d.ts +28 -12
  7. package/dist/server.d.ts +23 -14
  8. package/dist/style-downloader.d.ts +13 -19
  9. package/dist/tile-downloader.d.ts +13 -23
  10. package/dist/types.d.ts +61 -0
  11. package/dist/utils/errors.d.ts +2 -4
  12. package/dist/utils/fetch.d.ts +3 -8
  13. package/dist/utils/file-formats.d.ts +3 -10
  14. package/dist/utils/geo.d.ts +17 -9
  15. package/dist/utils/mapbox.d.ts +8 -10
  16. package/dist/utils/misc.d.ts +3 -5
  17. package/dist/utils/streams.d.ts +6 -10
  18. package/dist/utils/style.d.ts +27 -16
  19. package/dist/utils/templates.d.ts +30 -25
  20. package/dist/validator.d.ts +66 -0
  21. package/dist/writer.d.ts +157 -4
  22. package/lib/download.js +125 -0
  23. package/lib/fallbacks.js +157 -0
  24. package/lib/from-mbtiles.js +131 -0
  25. package/lib/index.js +12 -0
  26. package/lib/reader.js +360 -0
  27. package/lib/server.js +222 -0
  28. package/lib/style-downloader.js +369 -0
  29. package/lib/tile-downloader.js +189 -0
  30. package/lib/types.ts +99 -0
  31. package/lib/utils/errors.js +24 -0
  32. package/lib/utils/fetch.js +104 -0
  33. package/lib/utils/file-formats.js +92 -0
  34. package/lib/utils/geo.js +97 -0
  35. package/lib/utils/mapbox.js +155 -0
  36. package/{dist/utils/misc.d.cts → lib/utils/misc.js} +9 -5
  37. package/lib/utils/streams.js +101 -0
  38. package/lib/utils/style.js +206 -0
  39. package/lib/utils/templates.js +165 -0
  40. package/lib/validator.js +789 -0
  41. package/lib/writer.js +652 -0
  42. package/package.json +30 -78
  43. package/dist/download.cjs +0 -100
  44. package/dist/download.d.cts +0 -63
  45. package/dist/download.js +0 -76
  46. package/dist/from-mbtiles.cjs +0 -108
  47. package/dist/from-mbtiles.d.cts +0 -14
  48. package/dist/from-mbtiles.js +0 -84
  49. package/dist/index.cjs +0 -46
  50. package/dist/index.d.cts +0 -24
  51. package/dist/index.js +0 -16
  52. package/dist/reader.cjs +0 -287
  53. package/dist/reader.d.cts +0 -67
  54. package/dist/reader.js +0 -259
  55. package/dist/server.cjs +0 -73
  56. package/dist/server.d.cts +0 -45
  57. package/dist/server.js +0 -49
  58. package/dist/style-downloader.cjs +0 -314
  59. package/dist/style-downloader.d.cts +0 -118
  60. package/dist/style-downloader.js +0 -290
  61. package/dist/tile-downloader.cjs +0 -156
  62. package/dist/tile-downloader.d.cts +0 -82
  63. package/dist/tile-downloader.js +0 -124
  64. package/dist/types-qfyJk4ot.d.cts +0 -200
  65. package/dist/types-qfyJk4ot.d.ts +0 -200
  66. package/dist/utils/errors.cjs +0 -41
  67. package/dist/utils/errors.d.cts +0 -18
  68. package/dist/utils/errors.js +0 -16
  69. package/dist/utils/fetch.cjs +0 -97
  70. package/dist/utils/fetch.d.cts +0 -50
  71. package/dist/utils/fetch.js +0 -63
  72. package/dist/utils/file-formats.cjs +0 -96
  73. package/dist/utils/file-formats.d.cts +0 -32
  74. package/dist/utils/file-formats.js +0 -70
  75. package/dist/utils/geo.cjs +0 -84
  76. package/dist/utils/geo.d.cts +0 -46
  77. package/dist/utils/geo.js +0 -56
  78. package/dist/utils/mapbox.cjs +0 -121
  79. package/dist/utils/mapbox.d.cts +0 -43
  80. package/dist/utils/mapbox.js +0 -91
  81. package/dist/utils/misc.cjs +0 -39
  82. package/dist/utils/misc.js +0 -13
  83. package/dist/utils/streams.cjs +0 -99
  84. package/dist/utils/streams.d.cts +0 -49
  85. package/dist/utils/streams.js +0 -73
  86. package/dist/utils/style.cjs +0 -126
  87. package/dist/utils/style.d.cts +0 -66
  88. package/dist/utils/style.js +0 -98
  89. package/dist/utils/templates.cjs +0 -124
  90. package/dist/utils/templates.d.cts +0 -79
  91. package/dist/utils/templates.js +0 -85
  92. package/dist/writer.cjs +0 -539
  93. package/dist/writer.d.cts +0 -4
  94. package/dist/writer.js +0 -516
@@ -0,0 +1,789 @@
1
+ import { ZipReader } from '@gmaclennan/zip-reader'
2
+ import SphericalMercator from '@mapbox/sphericalmercator'
3
+ import { expressions, validateStyleMin } from '@maplibre/maplibre-gl-style-spec'
4
+
5
+ import { isLocallyRenderedRange } from './utils/style.js'
6
+ import { STYLE_FILE, URI_BASE, VERSION_FILE } from './utils/templates.js'
7
+
8
+ /** Major version(s) supported by this implementation */
9
+ const SUPPORTED_MAJOR_VERSIONS = [1]
10
+
11
+ const DEFAULT_MAX_ENTRIES = 500_000
12
+
13
+ const sm = new SphericalMercator({ size: 256 })
14
+
15
+ const textEncoder = new TextEncoder()
16
+
17
+ /**
18
+ * @typedef {object} ValidationIssue
19
+ * @property {'error' | 'warning'} kind - error = spec MUST violation; warning = SHOULD/RECOMMENDED
20
+ * @property {'fatal' | 'rendering' | 'spec'} severity - Practical impact:
21
+ * fatal = reader will fail to open; rendering = map renders with visible
22
+ * issues; spec = non-compliance that doesn't affect practical use
23
+ * @property {string} type - Stable identifier for programmatic matching
24
+ * @property {string} message - Human-readable description
25
+ * @property {string} [path] - Location context (e.g. 'sources.test.tiles', 'VERSION')
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} ValidationResult
30
+ * @property {boolean} valid - true when there are no errors (warnings are acceptable)
31
+ * @property {boolean} usable - true when there are no fatal issues (the file can be opened)
32
+ * @property {ValidationIssue[]} issues - all issues found
33
+ */
34
+
35
+ /**
36
+ * @typedef {object} ValidateOptions
37
+ * @property {number} [maxEntries=500_000] Maximum number of ZIP entries to
38
+ * process before aborting. Default matches the Reader default (~a global z9
39
+ * tileset).
40
+ */
41
+
42
+ /**
43
+ * Maps issue types to their practical severity. Types not listed default to 'spec'.
44
+ * @type {Record<string, 'fatal' | 'rendering'>}
45
+ */
46
+ const ISSUE_SEVERITY = {
47
+ // Fatal — reader will throw or fail to open the file
48
+ file_not_found: 'fatal',
49
+ invalid_zip: 'fatal',
50
+ unsafe_entry: 'fatal',
51
+ too_many_entries: 'fatal',
52
+ unsupported_version: 'fatal',
53
+ missing_style: 'fatal',
54
+ invalid_style_json: 'fatal',
55
+ // Rendering — file opens but map content will be visibly broken
56
+ invalid_style: 'rendering',
57
+ missing_tiles: 'rendering',
58
+ mixed_tile_formats: 'rendering',
59
+ invalid_tile_template: 'rendering',
60
+ invalid_tile_scheme: 'rendering',
61
+ missing_source_property: 'rendering',
62
+ missing_sprite: 'rendering',
63
+ missing_glyphs: 'rendering',
64
+ missing_font_glyphs: 'rendering',
65
+ incomplete_font_glyphs: 'rendering',
66
+ invalid_glyph_template: 'rendering',
67
+ missing_geojson_data: 'rendering',
68
+ external_resource: 'rendering',
69
+ }
70
+
71
+ /**
72
+ * @param {ValidationIssue[]} issues
73
+ * @param {'error' | 'warning'} kind
74
+ */
75
+ const createIssue =
76
+ (issues, kind) =>
77
+ /**
78
+ * @param {string} type
79
+ * @param {string} message
80
+ * @param {string} [path]
81
+ */
82
+ (type, message, path) =>
83
+ issues.push({
84
+ kind,
85
+ severity: ISSUE_SEVERITY[type] || 'spec',
86
+ type,
87
+ message,
88
+ ...(path != null && { path }),
89
+ })
90
+
91
+ /** @param {ValidationIssue[]} issues */
92
+ const result = (issues) => ({
93
+ valid: !issues.some((i) => i.kind === 'error'),
94
+ usable: !issues.some((i) => i.severity === 'fatal'),
95
+ issues,
96
+ })
97
+
98
+ /**
99
+ * Validate a Styled Map Package file against the SMP specification.
100
+ *
101
+ * Returns a list of issues, each with a `kind` ('error' or 'warning'), a
102
+ * `severity` ('fatal', 'rendering', or 'spec'), and a stable `type` string
103
+ * for programmatic filtering. Use `result.valid` to check spec compliance
104
+ * and `result.usable` to check whether the file can be opened by the reader.
105
+ *
106
+ * @param {string | import('@gmaclennan/zip-reader').ZipReader} source Path to the .smp file, or a ZipReader instance
107
+ * @param {ValidateOptions} [options]
108
+ * @returns {Promise<ValidationResult>}
109
+ */
110
+ export async function validate(source, options = {}) {
111
+ const { maxEntries = DEFAULT_MAX_ENTRIES } = options
112
+
113
+ /** @type {ValidationIssue[]} */
114
+ const issues = []
115
+ const error = createIssue(issues, 'error')
116
+ const warn = createIssue(issues, 'warning')
117
+
118
+ // §3: ZIP validity
119
+ /** @type {import('@gmaclennan/zip-reader').ZipReader} */
120
+ let zip
121
+ /** @type {import('@gmaclennan/zip-reader/file-source').FileSource | null} */
122
+ let fileSource = null
123
+ try {
124
+ if (typeof source === 'string') {
125
+ const { FileSource } = await import('@gmaclennan/zip-reader/file-source')
126
+ fileSource = await FileSource.open(source)
127
+ zip = await ZipReader.from(fileSource)
128
+ } else {
129
+ zip = source
130
+ }
131
+ } catch (err) {
132
+ const message = err instanceof Error ? err.message : String(err)
133
+ if (/** @type {any} */ (err)?.code === 'ENOENT') {
134
+ error('file_not_found', `File not found: ${source}`)
135
+ return result(issues)
136
+ }
137
+ error('invalid_zip', `Not a valid ZIP file: ${message}`)
138
+ return result(issues)
139
+ }
140
+
141
+ try {
142
+ const entries = await buildEntryMap(zip, maxEntries, error, warn)
143
+ if (!entries) return result(issues)
144
+
145
+ if (!(await validateVersion(entries, error, warn))) {
146
+ return result(issues)
147
+ }
148
+
149
+ const style = await parseStyle(entries, error)
150
+ if (!style) return result(issues)
151
+
152
+ validateMetadata(style, error, warn)
153
+ validateSources(style, entries, error, warn)
154
+ validateGlyphs(style, entries, error, warn)
155
+ validateSprites(style, entries, error, warn)
156
+ } finally {
157
+ if (fileSource) await fileSource.close()
158
+ }
159
+
160
+ return result(issues)
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Helpers
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /** @typedef {ReturnType<typeof createIssue>} IssueFn */
168
+
169
+ /**
170
+ * Iterate ZIP entries into a Map, checking entry name safety (§3.4, §11).
171
+ * Returns `null` if a fatal error prevents further validation.
172
+ *
173
+ * @param {import('@gmaclennan/zip-reader').ZipReader} zip
174
+ * @param {number} maxEntries
175
+ * @param {IssueFn} error
176
+ * @param {IssueFn} warn
177
+ * @returns {Promise<Map<string, import('@gmaclennan/zip-reader').ZipEntry> | null>}
178
+ */
179
+ async function buildEntryMap(zip, maxEntries, error, warn) {
180
+ /** @type {Map<string, import('@gmaclennan/zip-reader').ZipEntry>} */
181
+ const entries = new Map()
182
+ let count = 0
183
+ try {
184
+ for await (const entry of zip) {
185
+ if (++count > maxEntries) {
186
+ error(
187
+ 'too_many_entries',
188
+ `Archive exceeds maximum entry count of ${maxEntries}`,
189
+ )
190
+ return null
191
+ }
192
+ const name = entry.name
193
+
194
+ // §3.4: Path safety
195
+ if (
196
+ name.includes('..') ||
197
+ name.startsWith('/') ||
198
+ /^[A-Za-z]:/.test(name)
199
+ ) {
200
+ error('unsafe_entry', `Unsafe ZIP entry name: ${name}`, name)
201
+ }
202
+
203
+ // §3.4: Entry name length
204
+ if (textEncoder.encode(name).byteLength > 255) {
205
+ warn(
206
+ 'entry_name_too_long',
207
+ `ZIP entry name exceeds 255 bytes: ${name}`,
208
+ name,
209
+ )
210
+ }
211
+
212
+ entries.set(name, entry)
213
+ }
214
+ } catch (err) {
215
+ const message = err instanceof Error ? err.message : String(err)
216
+ if (/Relative path|Absolute path|Unsafe/i.test(message)) {
217
+ error('unsafe_entry', `ZIP contains unsafe entry: ${message}`)
218
+ return null
219
+ }
220
+ throw err
221
+ }
222
+ return entries
223
+ }
224
+
225
+ /**
226
+ * §3.1: Validate the VERSION file.
227
+ * Returns `false` if validation should stop (unsupported major version).
228
+ *
229
+ * @param {Map<string, import('@gmaclennan/zip-reader').ZipEntry>} entries
230
+ * @param {IssueFn} error
231
+ * @param {IssueFn} warn
232
+ * @returns {Promise<boolean>}
233
+ */
234
+ async function validateVersion(entries, error, warn) {
235
+ const versionEntry = entries.get(VERSION_FILE)
236
+ if (!versionEntry) {
237
+ warn(
238
+ 'missing_version',
239
+ 'Missing VERSION file (assuming version 1.0)',
240
+ 'VERSION',
241
+ )
242
+ return true
243
+ }
244
+
245
+ const version = (await new Response(versionEntry.readable()).text()).trim()
246
+ const majorMatch = version.match(/^(\d+)\.\d+$/)
247
+ if (!majorMatch) {
248
+ warn(
249
+ 'invalid_version_format',
250
+ `Invalid version format: "${version}" (expected MAJOR.MINOR)`,
251
+ 'VERSION',
252
+ )
253
+ return true
254
+ }
255
+
256
+ const major = parseInt(majorMatch[1], 10)
257
+ if (!SUPPORTED_MAJOR_VERSIONS.includes(major)) {
258
+ error(
259
+ 'unsupported_version',
260
+ `Unsupported major version: ${major} (supported: ${SUPPORTED_MAJOR_VERSIONS.join(', ')})`,
261
+ 'VERSION',
262
+ )
263
+ return false
264
+ }
265
+ return true
266
+ }
267
+
268
+ /**
269
+ * §4.1: Parse and validate style.json.
270
+ * Returns the parsed style object, or `null` on fatal error.
271
+ *
272
+ * @param {Map<string, import('@gmaclennan/zip-reader').ZipEntry>} entries
273
+ * @param {IssueFn} error
274
+ * @returns {Promise<any | null>}
275
+ */
276
+ async function parseStyle(entries, error) {
277
+ const styleEntry = entries.get(STYLE_FILE)
278
+ if (!styleEntry) {
279
+ error('missing_style', 'Missing style.json', 'style.json')
280
+ return null
281
+ }
282
+
283
+ /** @type {any} */
284
+ let style
285
+ try {
286
+ style = await new Response(styleEntry.readable()).json()
287
+ } catch (err) {
288
+ const message = err instanceof Error ? err.message : String(err)
289
+ error(
290
+ 'invalid_style_json',
291
+ `style.json is not valid JSON: ${message}`,
292
+ 'style.json',
293
+ )
294
+ return null
295
+ }
296
+
297
+ const styleErrors = validateStyleMin(
298
+ /** @type {import('@maplibre/maplibre-gl-style-spec').StyleSpecification} */ (
299
+ style
300
+ ),
301
+ )
302
+ for (const e of styleErrors) {
303
+ error('invalid_style', `style.json: ${e.message}`, 'style.json')
304
+ }
305
+
306
+ return style
307
+ }
308
+
309
+ /**
310
+ * §4.3: Validate SMP metadata fields.
311
+ *
312
+ * @param {any} style
313
+ * @param {IssueFn} error
314
+ * @param {IssueFn} warn
315
+ */
316
+ function validateMetadata(style, error, warn) {
317
+ const metadata = style.metadata || {}
318
+
319
+ // §4.3.1: smp:bounds
320
+ const bounds = metadata['smp:bounds']
321
+ if (!Array.isArray(bounds) || bounds.length !== 4) {
322
+ warn(
323
+ 'missing_smp_bounds',
324
+ 'Missing or invalid smp:bounds in metadata',
325
+ 'metadata.smp:bounds',
326
+ )
327
+ } else if (
328
+ !bounds.every((/** @type {unknown} */ v) => typeof v === 'number')
329
+ ) {
330
+ warn(
331
+ 'invalid_smp_bounds',
332
+ 'smp:bounds values must all be numbers',
333
+ 'metadata.smp:bounds',
334
+ )
335
+ } else {
336
+ const [w, s, e, n] = bounds
337
+ if (w < -180 || w > 180 || e < -180 || e > 180) {
338
+ error(
339
+ 'invalid_smp_bounds',
340
+ `smp:bounds longitude out of range [-180, 180]: [${w}, ${e}]`,
341
+ 'metadata.smp:bounds',
342
+ )
343
+ }
344
+ if (s < -90 || s > 90 || n < -90 || n > 90) {
345
+ error(
346
+ 'invalid_smp_bounds',
347
+ `smp:bounds latitude out of range [-90, 90]: [${s}, ${n}]`,
348
+ 'metadata.smp:bounds',
349
+ )
350
+ }
351
+ }
352
+
353
+ // §4.3.2: smp:maxzoom
354
+ const maxzoom = metadata['smp:maxzoom']
355
+ if (maxzoom == null) {
356
+ warn(
357
+ 'missing_smp_maxzoom',
358
+ 'Missing smp:maxzoom in metadata',
359
+ 'metadata.smp:maxzoom',
360
+ )
361
+ } else if (typeof maxzoom !== 'number' || !Number.isInteger(maxzoom)) {
362
+ warn(
363
+ 'invalid_smp_maxzoom',
364
+ 'smp:maxzoom must be an integer',
365
+ 'metadata.smp:maxzoom',
366
+ )
367
+ } else if (maxzoom < 0 || maxzoom > 30) {
368
+ error(
369
+ 'invalid_smp_maxzoom',
370
+ `smp:maxzoom must be between 0 and 30, got ${maxzoom}`,
371
+ 'metadata.smp:maxzoom',
372
+ )
373
+ }
374
+
375
+ // §4.3.3: smp:sourceFolders
376
+ const sourceFolders = metadata['smp:sourceFolders']
377
+ if (sourceFolders && typeof sourceFolders !== 'object') {
378
+ warn(
379
+ 'invalid_smp_source_folders',
380
+ 'Invalid smp:sourceFolders in metadata',
381
+ 'metadata.smp:sourceFolders',
382
+ )
383
+ }
384
+ }
385
+
386
+ /**
387
+ * §5, §8: Validate tile and GeoJSON sources.
388
+ *
389
+ * @param {any} style
390
+ * @param {Map<string, import('@gmaclennan/zip-reader').ZipEntry>} entries
391
+ * @param {IssueFn} error
392
+ * @param {IssueFn} warn
393
+ */
394
+ function validateSources(style, entries, error, warn) {
395
+ if (!style.sources) return
396
+
397
+ for (const [sourceId, source] of Object.entries(style.sources)) {
398
+ const src = /** @type {any} */ (source)
399
+ const srcPath = `sources.${sourceId}`
400
+
401
+ // §8/§9: GeoJSON source — check data file existence
402
+ if (src.type === 'geojson') {
403
+ if (typeof src.data === 'string' && src.data.startsWith(URI_BASE)) {
404
+ const dataPath = src.data.slice(URI_BASE.length)
405
+ if (!entries.has(dataPath)) {
406
+ error(
407
+ 'missing_geojson_data',
408
+ `GeoJSON source "${sourceId}" references missing file: ${dataPath}`,
409
+ `${srcPath}.data`,
410
+ )
411
+ }
412
+ }
413
+ continue
414
+ }
415
+
416
+ // §5.1: Unsupported source types
417
+ if (src.type !== 'vector' && src.type !== 'raster') {
418
+ warn(
419
+ 'unsupported_source_type',
420
+ `Source "${sourceId}" has unsupported type "${src.type}"`,
421
+ srcPath,
422
+ )
423
+ continue
424
+ }
425
+
426
+ // §5.6: Source url property must not exist
427
+ if ('url' in src) {
428
+ error(
429
+ 'source_has_url',
430
+ `Source "${sourceId}" has url property (must be inlined)`,
431
+ srcPath,
432
+ )
433
+ }
434
+
435
+ // §5.6: Required source properties
436
+ for (const prop of ['bounds', 'minzoom', 'maxzoom', 'tiles']) {
437
+ if (!(prop in src)) {
438
+ error(
439
+ 'missing_source_property',
440
+ `Source "${sourceId}" missing required property: ${prop}`,
441
+ `${srcPath}.${prop}`,
442
+ )
443
+ }
444
+ }
445
+
446
+ // §5.4: Tile coordinate scheme must be xyz or omitted
447
+ if ('scheme' in src && src.scheme !== 'xyz') {
448
+ error(
449
+ 'invalid_tile_scheme',
450
+ `Source "${sourceId}" has scheme "${src.scheme}" (must be "xyz" or omitted)`,
451
+ `${srcPath}.scheme`,
452
+ )
453
+ }
454
+
455
+ // §5.5: Tile URL template validation
456
+ if (Array.isArray(src.tiles)) {
457
+ if (src.tiles.length !== 1) {
458
+ error(
459
+ 'invalid_tile_template',
460
+ `Source "${sourceId}" tiles must contain exactly one URL template, found ${src.tiles.length}`,
461
+ `${srcPath}.tiles`,
462
+ )
463
+ }
464
+ const tileUrl = src.tiles[0]
465
+ if (typeof tileUrl === 'string') {
466
+ if (!tileUrl.startsWith(URI_BASE)) {
467
+ error(
468
+ 'invalid_tile_template',
469
+ `Source "${sourceId}" tile URL must use SMP URI scheme (smp://maps.v1/...)`,
470
+ `${srcPath}.tiles`,
471
+ )
472
+ } else if (
473
+ !tileUrl.includes('{z}') ||
474
+ !tileUrl.includes('{x}') ||
475
+ !tileUrl.includes('{y}')
476
+ ) {
477
+ error(
478
+ 'invalid_tile_template',
479
+ `Source "${sourceId}" tile URL template missing {z}, {x}, or {y} placeholders`,
480
+ `${srcPath}.tiles`,
481
+ )
482
+ }
483
+ }
484
+ }
485
+
486
+ if (!hasValidTileConfig(src)) continue
487
+
488
+ const template = src.tiles[0].slice(URI_BASE.length)
489
+ const prefix = template.slice(0, template.indexOf('{z}'))
490
+
491
+ // §5.3: Tile format consistency — only check entries matching tile paths
492
+ /** @type {Set<string>} */
493
+ const extensions = new Set()
494
+ const tilePathPattern = /\d+\/\d+\/\d+\.[a-z]+(?:\.gz)?$/
495
+ for (const name of entries.keys()) {
496
+ if (!name.startsWith(prefix)) continue
497
+ if (!tilePathPattern.test(name)) continue
498
+ const extMatch = name.match(/(\.[a-z]+(?:\.gz)?)$/)
499
+ if (extMatch) extensions.add(extMatch[1])
500
+ }
501
+ if (extensions.size > 1) {
502
+ error(
503
+ 'mixed_tile_formats',
504
+ `Source "${sourceId}" has mixed tile formats: ${[...extensions].join(', ')}`,
505
+ srcPath,
506
+ )
507
+ }
508
+
509
+ // §5.7: Tile completeness check
510
+ let missingCount = 0
511
+ /** @type {string[]} */
512
+ const missingExamples = []
513
+ for (const { x, y, z } of tileIterator({
514
+ bounds: src.bounds,
515
+ minzoom: src.minzoom,
516
+ maxzoom: src.maxzoom,
517
+ })) {
518
+ const tilePath = template
519
+ .replace('{z}', String(z))
520
+ .replace('{x}', String(x))
521
+ .replace('{y}', String(y))
522
+ if (!entries.has(tilePath)) {
523
+ missingCount++
524
+ if (missingExamples.length < 3) {
525
+ missingExamples.push(tilePath)
526
+ }
527
+ }
528
+ }
529
+ if (missingCount > 0) {
530
+ const examples = missingExamples.join(', ')
531
+ const suffix = missingCount > 3 ? ` and ${missingCount - 3} more` : ''
532
+ error(
533
+ 'missing_tiles',
534
+ `Source "${sourceId}" is missing ${missingCount} tile(s): ${examples}${suffix}`,
535
+ srcPath,
536
+ )
537
+ }
538
+ }
539
+ }
540
+
541
+ /** Total number of Unicode BMP glyph ranges (0-255 through 65280-65535) */
542
+ const TOTAL_GLYPH_RANGES = 256
543
+
544
+ /**
545
+ * §4.2.2, §6: Validate glyph references and files.
546
+ *
547
+ * @param {any} style
548
+ * @param {Map<string, import('@gmaclennan/zip-reader').ZipEntry>} entries
549
+ * @param {IssueFn} error
550
+ * @param {IssueFn} warn
551
+ */
552
+ function validateGlyphs(style, entries, error, warn) {
553
+ if (typeof style.glyphs !== 'string') return
554
+
555
+ if (!style.glyphs.startsWith(URI_BASE)) {
556
+ error(
557
+ 'external_resource',
558
+ `Glyphs URL must use SMP URI scheme, found external URL: ${style.glyphs}`,
559
+ 'glyphs',
560
+ )
561
+ return
562
+ }
563
+
564
+ const glyphTemplate = style.glyphs.slice(URI_BASE.length)
565
+
566
+ // §6.3: Must include {fontstack} and {range} placeholders
567
+ const hasPlaceholders =
568
+ glyphTemplate.includes('{fontstack}') && glyphTemplate.includes('{range}')
569
+ if (!hasPlaceholders) {
570
+ error(
571
+ 'invalid_glyph_template',
572
+ 'Glyph URL template must include {fontstack} and {range} placeholders',
573
+ 'glyphs',
574
+ )
575
+ }
576
+
577
+ // Check that at least some glyph files exist
578
+ const prefixEnd = glyphTemplate.indexOf('{fontstack}')
579
+ const glyphPrefix = prefixEnd > 0 ? glyphTemplate.slice(0, prefixEnd) : ''
580
+ let hasGlyphs = false
581
+ for (const filename of entries.keys()) {
582
+ if (
583
+ glyphPrefix
584
+ ? filename.startsWith(glyphPrefix)
585
+ : filename.endsWith('.pbf.gz')
586
+ ) {
587
+ hasGlyphs = true
588
+ break
589
+ }
590
+ }
591
+ if (!hasGlyphs) {
592
+ error(
593
+ 'missing_glyphs',
594
+ 'style.json references glyphs but no glyph files found',
595
+ 'glyphs',
596
+ )
597
+ return
598
+ }
599
+
600
+ // §6.6: Per-fontstack glyph range completeness
601
+ // Ranges rendered client-side by MapLibre's localIdeographFontFamily
602
+ // (CJK, Hangul, Kana, Yi, etc.) are excluded from the expected count.
603
+ if (!hasPlaceholders) return
604
+ const fontStacks = collectFontStacks(style.layers || [])
605
+ for (const fontStack of fontStacks) {
606
+ let presentCount = 0
607
+ let expectedCount = 0
608
+ for (let i = 0; i < TOTAL_GLYPH_RANGES; i++) {
609
+ const start = i * 256
610
+ if (isLocallyRenderedRange(start)) continue
611
+ expectedCount++
612
+ const range = `${start}-${start + 255}`
613
+ const path = glyphTemplate
614
+ .replace('{fontstack}', fontStack)
615
+ .replace('{range}', range)
616
+ if (entries.has(path)) presentCount++
617
+ }
618
+ if (presentCount === 0) {
619
+ error(
620
+ 'missing_font_glyphs',
621
+ `No glyph files found for font "${fontStack}"`,
622
+ 'glyphs',
623
+ )
624
+ } else if (presentCount < expectedCount) {
625
+ warn(
626
+ 'incomplete_font_glyphs',
627
+ `Font "${fontStack}" has ${presentCount} of ${expectedCount} required glyph ranges (${TOTAL_GLYPH_RANGES - expectedCount} CJK/Hangul/Kana ranges are rendered locally by MapLibre)`,
628
+ 'glyphs',
629
+ )
630
+ }
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Collect all unique fontstack strings referenced by style layers' `text-font`
636
+ * properties. Each fontstack is the comma-joined font array, matching how
637
+ * MapLibre requests glyphs via the `{fontstack}` placeholder.
638
+ *
639
+ * Handles both plain arrays (`["Font A", "Font B"]`) and expressions
640
+ * containing `["literal", ["Font A"]]` nodes.
641
+ *
642
+ * @param {any[]} layers
643
+ * @returns {Set<string>}
644
+ */
645
+ function collectFontStacks(layers) {
646
+ /** @type {Set<string>} */
647
+ const stacks = new Set()
648
+ for (const layer of layers) {
649
+ if (layer.type !== 'symbol' || !layer.layout?.['text-font']) continue
650
+ collectFontStacksFromValue(layer.layout['text-font'], stacks)
651
+ }
652
+ return stacks
653
+ }
654
+
655
+ /**
656
+ * Recursively extract fontstack strings from a `text-font` value, which may
657
+ * be a plain string array or an expression tree.
658
+ *
659
+ * @param {unknown} value
660
+ * @param {Set<string>} stacks
661
+ */
662
+ function collectFontStacksFromValue(value, stacks) {
663
+ if (!Array.isArray(value) || value.length === 0) return
664
+
665
+ // ["literal", ["Font A", "Font B"]]
666
+ if (value[0] === 'literal' && Array.isArray(value[1])) {
667
+ stacks.add(value[1].join(','))
668
+ return
669
+ }
670
+
671
+ // Expression: first element is a known MapLibre expression operator
672
+ if (typeof value[0] === 'string' && value[0] in expressions) {
673
+ for (let i = 1; i < value.length; i++) {
674
+ if (Array.isArray(value[i])) {
675
+ collectFontStacksFromValue(value[i], stacks)
676
+ }
677
+ }
678
+ return
679
+ }
680
+
681
+ // Plain fontstack: ["Font A", "Font B"]
682
+ if (value.every((/** @type {unknown} */ v) => typeof v === 'string')) {
683
+ stacks.add(value.join(','))
684
+ }
685
+ }
686
+
687
+ /**
688
+ * §4.2.2, §7: Validate sprite references and files.
689
+ *
690
+ * @param {any} style
691
+ * @param {Map<string, import('@gmaclennan/zip-reader').ZipEntry>} entries
692
+ * @param {IssueFn} error
693
+ * @param {IssueFn} warn
694
+ */
695
+ function validateSprites(style, entries, error, warn) {
696
+ if (typeof style.sprite === 'string') {
697
+ if (!style.sprite.startsWith(URI_BASE)) {
698
+ error(
699
+ 'external_resource',
700
+ `Sprite URL must use SMP URI scheme, found external URL: ${style.sprite}`,
701
+ 'sprite',
702
+ )
703
+ return
704
+ }
705
+ const basePath = style.sprite.slice(URI_BASE.length)
706
+ validateSpriteFiles(entries, basePath, error, warn)
707
+ } else if (Array.isArray(style.sprite)) {
708
+ for (const { url } of style.sprite) {
709
+ if (typeof url !== 'string') continue
710
+ if (!url.startsWith(URI_BASE)) {
711
+ error(
712
+ 'external_resource',
713
+ `Sprite URL must use SMP URI scheme, found external URL: ${url}`,
714
+ 'sprite',
715
+ )
716
+ continue
717
+ }
718
+ const basePath = url.slice(URI_BASE.length)
719
+ validateSpriteFiles(entries, basePath, error, warn)
720
+ }
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Check that the required sprite files exist for a given base path.
726
+ *
727
+ * @param {Map<string, any>} entries
728
+ * @param {string} basePath sprite base path (without extension)
729
+ * @param {IssueFn} error
730
+ * @param {IssueFn} warn
731
+ */
732
+ function validateSpriteFiles(entries, basePath, error, warn) {
733
+ const jsonPath = basePath + '.json'
734
+ const pngPath = basePath + '.png'
735
+ const json2xPath = basePath + '@2x.json'
736
+ const png2xPath = basePath + '@2x.png'
737
+
738
+ if (!entries.has(jsonPath)) {
739
+ error('missing_sprite', `Missing sprite file: ${jsonPath}`, jsonPath)
740
+ }
741
+ if (!entries.has(pngPath)) {
742
+ error('missing_sprite', `Missing sprite file: ${pngPath}`, pngPath)
743
+ }
744
+ if (!entries.has(json2xPath) || !entries.has(png2xPath)) {
745
+ warn(
746
+ 'missing_sprite_2x',
747
+ `Missing @2x sprite for "${basePath}" (recommended but not required)`,
748
+ basePath,
749
+ )
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Check whether a source has all the properties needed for tile-level checks
755
+ * (format consistency, completeness).
756
+ *
757
+ * @param {any} src
758
+ * @returns {boolean}
759
+ */
760
+ function hasValidTileConfig(src) {
761
+ return (
762
+ Array.isArray(src.tiles) &&
763
+ src.tiles.length > 0 &&
764
+ typeof src.tiles[0] === 'string' &&
765
+ src.tiles[0].startsWith(URI_BASE) &&
766
+ src.tiles[0].includes('{z}') &&
767
+ Array.isArray(src.bounds) &&
768
+ typeof src.minzoom === 'number' &&
769
+ typeof src.maxzoom === 'number'
770
+ )
771
+ }
772
+
773
+ /**
774
+ * Iterate tile coordinates for a bounding box and zoom range.
775
+ * @param {object} opts
776
+ * @param {[number, number, number, number]} opts.bounds [west, south, east, north]
777
+ * @param {number} opts.minzoom
778
+ * @param {number} opts.maxzoom
779
+ */
780
+ function* tileIterator({ bounds, minzoom, maxzoom }) {
781
+ for (let z = minzoom; z <= maxzoom; z++) {
782
+ const { minX, minY, maxX, maxY } = sm.xyz([...bounds], z)
783
+ for (let x = minX; x <= maxX; x++) {
784
+ for (let y = minY; y <= maxY; y++) {
785
+ yield { x, y, z }
786
+ }
787
+ }
788
+ }
789
+ }