styled-map-package-api 5.0.0-pre.3 → 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.
- package/README.md +94 -0
- package/dist/download.d.ts +11 -21
- package/dist/fallbacks.d.ts +32 -0
- package/dist/from-mbtiles.d.ts +1 -3
- package/dist/index.d.ts +11 -24
- package/dist/reader.d.ts +28 -12
- package/dist/server.d.ts +23 -14
- package/dist/style-downloader.d.ts +13 -19
- package/dist/tile-downloader.d.ts +13 -23
- package/dist/types.d.ts +61 -0
- package/dist/utils/errors.d.ts +2 -4
- package/dist/utils/fetch.d.ts +3 -8
- package/dist/utils/file-formats.d.ts +3 -10
- package/dist/utils/geo.d.ts +17 -9
- package/dist/utils/mapbox.d.ts +8 -10
- package/dist/utils/misc.d.ts +3 -5
- package/dist/utils/streams.d.ts +6 -10
- package/dist/utils/style.d.ts +27 -16
- package/dist/utils/templates.d.ts +30 -25
- package/dist/validator.d.ts +66 -0
- package/dist/writer.d.ts +157 -4
- package/lib/download.js +125 -0
- package/lib/fallbacks.js +157 -0
- package/lib/from-mbtiles.js +131 -0
- package/lib/index.js +12 -0
- package/lib/reader.js +360 -0
- package/lib/server.js +222 -0
- package/lib/style-downloader.js +369 -0
- package/lib/tile-downloader.js +189 -0
- package/lib/types.ts +99 -0
- package/lib/utils/errors.js +24 -0
- package/lib/utils/fetch.js +104 -0
- package/lib/utils/file-formats.js +92 -0
- package/lib/utils/geo.js +97 -0
- package/lib/utils/mapbox.js +155 -0
- package/{dist/utils/misc.d.cts → lib/utils/misc.js} +9 -5
- package/lib/utils/streams.js +101 -0
- package/lib/utils/style.js +206 -0
- package/lib/utils/templates.js +165 -0
- package/lib/validator.js +789 -0
- package/lib/writer.js +652 -0
- package/package.json +30 -78
- package/dist/download.cjs +0 -100
- package/dist/download.d.cts +0 -63
- package/dist/download.js +0 -76
- package/dist/from-mbtiles.cjs +0 -127
- package/dist/from-mbtiles.d.cts +0 -14
- package/dist/from-mbtiles.js +0 -103
- package/dist/index.cjs +0 -46
- package/dist/index.d.cts +0 -24
- package/dist/index.js +0 -16
- package/dist/reader.cjs +0 -287
- package/dist/reader.d.cts +0 -67
- package/dist/reader.js +0 -259
- package/dist/server.cjs +0 -73
- package/dist/server.d.cts +0 -45
- package/dist/server.js +0 -49
- package/dist/style-downloader.cjs +0 -314
- package/dist/style-downloader.d.cts +0 -118
- package/dist/style-downloader.js +0 -290
- package/dist/tile-downloader.cjs +0 -156
- package/dist/tile-downloader.d.cts +0 -82
- package/dist/tile-downloader.js +0 -124
- package/dist/types-Bhn0-Ldk.d.cts +0 -201
- package/dist/types-Bhn0-Ldk.d.ts +0 -201
- package/dist/utils/errors.cjs +0 -41
- package/dist/utils/errors.d.cts +0 -18
- package/dist/utils/errors.js +0 -16
- package/dist/utils/fetch.cjs +0 -97
- package/dist/utils/fetch.d.cts +0 -50
- package/dist/utils/fetch.js +0 -63
- package/dist/utils/file-formats.cjs +0 -96
- package/dist/utils/file-formats.d.cts +0 -32
- package/dist/utils/file-formats.js +0 -70
- package/dist/utils/geo.cjs +0 -84
- package/dist/utils/geo.d.cts +0 -46
- package/dist/utils/geo.js +0 -56
- package/dist/utils/mapbox.cjs +0 -121
- package/dist/utils/mapbox.d.cts +0 -43
- package/dist/utils/mapbox.js +0 -91
- package/dist/utils/misc.cjs +0 -39
- package/dist/utils/misc.js +0 -13
- package/dist/utils/streams.cjs +0 -99
- package/dist/utils/streams.d.cts +0 -49
- package/dist/utils/streams.js +0 -73
- package/dist/utils/style.cjs +0 -126
- package/dist/utils/style.d.cts +0 -66
- package/dist/utils/style.js +0 -98
- package/dist/utils/templates.cjs +0 -124
- package/dist/utils/templates.d.cts +0 -79
- package/dist/utils/templates.js +0 -85
- package/dist/writer.cjs +0 -539
- package/dist/writer.d.cts +0 -4
- package/dist/writer.js +0 -516
package/lib/validator.js
ADDED
|
@@ -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
|
+
}
|