@mapbox/mapbox-gl-style-spec
Version:
a specification for mapbox gl styles
250 lines (210 loc) • 8.7 kB
text/typescript
import {validateStyle} from './validate_style.min';
import {v8} from './style-spec';
import readStyle from './read_style';
import ValidationError from './error/validation_error';
import getType from './util/get_type';
import type {StyleReference} from './reference/latest';
import type {ValidationErrors} from './validate_style.min';
import type {
StyleSpecification,
SourceSpecification,
SourcesSpecification,
ImportSpecification
} from './types';
type MapboxStyleSpecification = StyleSpecification & {
visibility?: 'public' | 'private';
protected?: boolean;
};
const SUPPORTED_SPEC_VERSION = 8;
const MAX_SOURCES_IN_STYLE = 15;
function isValid(value: string | null | undefined, regex: RegExp): boolean {
if (!value || getType(value) !== 'string') return true;
return !!value.match(regex);
}
function getSourceCount(source: SourceSpecification): number {
if ('url' in source) {
return source.url.split(',').length;
} else {
return 0;
}
}
function getAllowedKeyErrors(obj: object, keys: string[], path?: string | null): Array<ValidationError> {
const allowed = new Set(keys);
const errors: ValidationError[] = [];
Object.keys(obj).forEach(k => {
if (!allowed.has(k)) {
const prop = path ? `${path}.${k}` : null;
errors.push(new ValidationError(prop, obj[k], `Unsupported property "${k}"`));
}
});
return errors;
}
const acceptedSourceTypes = new Set<SourceSpecification['type']>(['vector', 'raster', 'raster-dem', 'raster-array', 'model', 'batched-model']);
function getSourceErrors(source: SourceSpecification, i: number): Array<ValidationError> {
const errors: ValidationError[] = [];
/*
* Inlined sources are not supported by the Mapbox Styles API, so only
* "type", "url", and "tileSize", "promoteId" properties are valid
*/
const sourceKeys = ['type', 'url', 'tileSize', 'promoteId'];
errors.push(...getAllowedKeyErrors(source, sourceKeys, 'source'));
/*
* "type" is required and must be one of "vector", "raster", "raster-dem", "raster-array"
*/
if (!acceptedSourceTypes.has(String(source.type) as SourceSpecification['type'])) {
errors.push(new ValidationError(`sources[${i}].type`, source.type, `Expected one of [${Array.from(acceptedSourceTypes).join(", ")}]`));
}
/*
* "source" is required. Valid examples:
* mapbox://mapbox.abcd1234
* mapbox://penny.abcd1234
* mapbox://mapbox.abcd1234,penny.abcd1234
*/
const sourceUrlPattern = /^mapbox:\/\/([^/]*)$/;
if (!('url' in source) || !isValid(source.url, sourceUrlPattern)) {
errors.push(new ValidationError(`sources[${i}].url`, (source as {url?: string}).url, 'Expected a valid Mapbox tileset url'));
}
return errors;
}
function getMaxSourcesErrors(sourcesCount: number): Array<ValidationError> {
const errors: ValidationError[] = [];
if (sourcesCount > MAX_SOURCES_IN_STYLE) {
errors.push(new ValidationError('sources', null, `Styles must contain ${MAX_SOURCES_IN_STYLE} or fewer sources`));
}
return errors;
}
function getSourcesErrors(sources: SourcesSpecification): {
errors: Array<ValidationError>;
sourcesCount: number;
} {
const errors = [];
let sourcesCount = 0;
Object.keys(sources).forEach((s: string, i: number) => {
const sourceErrors = getSourceErrors(sources[s], i);
// If source has errors, skip counting
if (!sourceErrors.length) {
sourcesCount = sourcesCount + getSourceCount(sources[s]);
}
errors.push(...sourceErrors);
});
return {errors, sourcesCount};
}
function getImportErrors(imports: ImportSpecification[] = []): {errors: Array<ValidationError>; sourcesCount: number} {
let errors: Array<ValidationError> = [];
let sourcesCount = 0;
const validateImports = (imports: ImportSpecification[] = []) => {
for (const importSpec of imports) {
const style = importSpec.data;
if (!style) continue;
if (style.imports) {
validateImports(style.imports);
}
errors = errors.concat(getRootErrors(style, Object.keys(v8.$root)));
if (style.sources) {
const sourcesErrors = getSourcesErrors(style.sources);
sourcesCount += sourcesErrors.sourcesCount;
errors = errors.concat(sourcesErrors.errors);
}
}
};
validateImports(imports);
if (imports.length !== (new Set(imports.map(i => i.id))).size) {
errors.push(new ValidationError(null, null, 'Duplicate ids of imports'));
}
return {errors, sourcesCount};
}
function getRootErrors(style: MapboxStyleSpecification, specKeys: string[]): Array<ValidationError> {
const errors = [];
/*
* The following keys are optional but fully managed by the Mapbox Styles
* API. Values on stylesheet on POST or PATCH will be ignored: "owner",
* "id", "cacheControl", "draft", "created", "modified", "protected"
*
* The following keys are optional. The Mapbox Styles API respects value on
* stylesheet on PATCH, but ignores the value on POST: "visibility"
*/
const optionalRootProperties = [
'owner',
'id',
'cacheControl',
'draft',
'created',
'modified',
'visibility',
'protected',
'models',
'lights'
];
const allowedKeyErrors = getAllowedKeyErrors(style, [...specKeys, ...optionalRootProperties]);
errors.push(...allowedKeyErrors);
if (style.version > SUPPORTED_SPEC_VERSION || style.version < SUPPORTED_SPEC_VERSION) {
errors.push(new ValidationError('version', style.version, `Style version must be ${SUPPORTED_SPEC_VERSION}`));
}
/*
* "glyphs" is optional. If present, valid examples:
* mapbox://fonts/penny/{fontstack}/{range}.pbf
* mapbox://fonts/mapbox/{fontstack}/{range}.pbf
*/
const glyphUrlPattern = /^mapbox:\/\/fonts\/([^/]*)\/{fontstack}\/{range}.pbf$/;
if (!isValid(style.glyphs, glyphUrlPattern)) {
errors.push(new ValidationError('glyphs', style.glyphs, 'Styles must reference glyphs hosted by Mapbox'));
}
/*
* "sprite" is optional. If present, valid examples:
* mapbox://sprites/penny/abcd1234
* mapbox://sprites/mapbox/abcd1234/draft
* mapbox://sprites/cyrus/abcd1234/abcd1234
*/
const spriteUrlPattern = /^mapbox:\/\/sprites\/([^/]*)\/([^/]*)\/?([^/]*)?$/;
if (!isValid(style.sprite, spriteUrlPattern)) {
errors.push(new ValidationError('sprite', style.sprite, 'Styles must reference sprites hosted by Mapbox'));
}
/*
* "visibility" is optional. If present, valid examples:
* "private"
* "public"
*/
const visibilityPattern = /^(public|private)$/;
if (!isValid(style.visibility, visibilityPattern)) {
errors.push(new ValidationError('visibility', style.visibility, 'Style visibility must be public or private'));
}
if (style.protected !== undefined && getType(style.protected) !== 'boolean') {
errors.push(new ValidationError('protected', style.protected, 'Style protection must be true or false'));
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return errors;
}
/**
* Validate a Mapbox GL style against the style specification and check for
* compatibility with the Mapbox Styles API.
*
* @param {Object} style The style to be validated.
* @returns {Array<ValidationError>}
* @example
* var validateMapboxApiSupported = require('mapbox-gl-style-spec/lib/validate_style_mapbox_api_supported.js');
* var errors = validateMapboxApiSupported(style);
*/
export default function validateMapboxApiSupported(style: MapboxStyleSpecification, styleSpec: StyleReference = v8): ValidationErrors {
let s = style;
try {
s = readStyle(s);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return [e];
}
let errors = validateStyle(s, styleSpec)
.concat(getRootErrors(s, Object.keys(v8.$root)));
let sourcesCount = 0;
if (s.sources) {
const sourcesErrors = getSourcesErrors(s.sources);
sourcesCount += sourcesErrors.sourcesCount;
errors = errors.concat(sourcesErrors.errors);
}
if (s.imports) {
const importsErrors = getImportErrors(s.imports);
sourcesCount += importsErrors.sourcesCount;
errors = errors.concat(importsErrors.errors);
}
errors = errors.concat(getMaxSourcesErrors(sourcesCount));
return errors;
}