/* * Copyright (c) 2017-2019 Digital Bazaar, Inc. All rights reserved. */ 'use strict'; const util = require('./util'); const JsonLdError = require('./JsonLdError'); const { isArray: _isArray, isObject: _isObject, isString: _isString, isUndefined: _isUndefined } = require('./types'); const { isAbsolute: _isAbsoluteIri, isRelative: _isRelativeIri, prependBase } = require('./url'); const { handleEvent: _handleEvent } = require('./events'); const { REGEX_BCP47, REGEX_KEYWORD, asArray: _asArray, compareShortestLeast: _compareShortestLeast } = require('./util'); const INITIAL_CONTEXT_CACHE = new Map(); const INITIAL_CONTEXT_CACHE_MAX_SIZE = 10000; const api = {}; module.exports = api; /** * Processes a local context and returns a new active context. * * @param activeCtx the current active context. * @param localCtx the local context to process. * @param options the context processing options. * @param propagate `true` if `false`, retains any previously defined term, * which can be rolled back when the descending into a new node object. * @param overrideProtected `false` allows protected terms to be modified. * * @return a Promise that resolves to the new active context. */ api.process = async ({ activeCtx, localCtx, options, propagate = true, overrideProtected = false, cycles = new Set() }) => { // normalize local context to an array of @context objects if(_isObject(localCtx) && '@context' in localCtx && _isArray(localCtx['@context'])) { localCtx = localCtx['@context']; } const ctxs = _asArray(localCtx); // no contexts in array, return current active context w/o changes if(ctxs.length === 0) { return activeCtx; } // event handler for capturing events to replay when using a cached context const events = []; const eventCaptureHandler = [ ({event, next}) => { events.push(event); next(); } ]; // chain to original handler if(options.eventHandler) { eventCaptureHandler.push(options.eventHandler); } // store original options to use when replaying events const originalOptions = options; // shallow clone options with event capture handler options = {...options, eventHandler: eventCaptureHandler}; // resolve contexts const resolved = await options.contextResolver.resolve({ activeCtx, context: localCtx, documentLoader: options.documentLoader, base: options.base }); // override propagate if first resolved context has `@propagate` if(_isObject(resolved[0].document) && typeof resolved[0].document['@propagate'] === 'boolean') { // retrieve early, error checking done later propagate = resolved[0].document['@propagate']; } // process each context in order, update active context // on each iteration to ensure proper caching let rval = activeCtx; // track the previous context // if not propagating, make sure rval has a previous context if(!propagate && !rval.previousContext) { // clone `rval` context before updating rval = rval.clone(); rval.previousContext = activeCtx; } for(const resolvedContext of resolved) { let {document: ctx} = resolvedContext; // update active context to one computed from last iteration activeCtx = rval; // reset to initial context if(ctx === null) { // We can't nullify if there are protected terms and we're // not allowing overrides (e.g. processing a property term scoped context) if(!overrideProtected && Object.keys(activeCtx.protected).length !== 0) { throw new JsonLdError( 'Tried to nullify a context with protected terms outside of ' + 'a term definition.', 'jsonld.SyntaxError', {code: 'invalid context nullification'}); } rval = activeCtx = api.getInitialContext(options).clone(); continue; } // get processed context from cache if available const processed = resolvedContext.getProcessed(activeCtx); if(processed) { if(originalOptions.eventHandler) { // replay events with original non-capturing options for(const event of processed.events) { _handleEvent({event, options: originalOptions}); } } rval = activeCtx = processed.context; continue; } // dereference @context key if present if(_isObject(ctx) && '@context' in ctx) { ctx = ctx['@context']; } // context must be an object by now, all URLs retrieved before this call if(!_isObject(ctx)) { throw new JsonLdError( 'Invalid JSON-LD syntax; @context must be an object.', 'jsonld.SyntaxError', {code: 'invalid local context', context: ctx}); } // TODO: there is likely a `previousContext` cloning optimization that // could be applied here (no need to copy it under certain conditions) // clone context before updating it rval = rval.clone(); // define context mappings for keys in local context const defined = new Map(); // handle @version if('@version' in ctx) { if(ctx['@version'] !== 1.1) { throw new JsonLdError( 'Unsupported JSON-LD version: ' + ctx['@version'], 'jsonld.UnsupportedVersion', {code: 'invalid @version value', context: ctx}); } if(activeCtx.processingMode && activeCtx.processingMode === 'json-ld-1.0') { throw new JsonLdError( '@version: ' + ctx['@version'] + ' not compatible with ' + activeCtx.processingMode, 'jsonld.ProcessingModeConflict', {code: 'processing mode conflict', context: ctx}); } rval.processingMode = 'json-ld-1.1'; rval['@version'] = ctx['@version']; defined.set('@version', true); } // if not set explicitly, set processingMode to "json-ld-1.1" rval.processingMode = rval.processingMode || activeCtx.processingMode; // handle @base if('@base' in ctx) { let base = ctx['@base']; if(base === null || _isAbsoluteIri(base)) { // no action } else if(_isRelativeIri(base)) { base = prependBase(rval['@base'], base); } else { throw new JsonLdError( 'Invalid JSON-LD syntax; the value of "@base" in a ' + '@context must be an absolute IRI, a relative IRI, or null.', 'jsonld.SyntaxError', {code: 'invalid base IRI', context: ctx}); } rval['@base'] = base; defined.set('@base', true); } // handle @vocab if('@vocab' in ctx) { const value = ctx['@vocab']; if(value === null) { delete rval['@vocab']; } else if(!_isString(value)) { throw new JsonLdError( 'Invalid JSON-LD syntax; the value of "@vocab" in a ' + '@context must be a string or null.', 'jsonld.SyntaxError', {code: 'invalid vocab mapping', context: ctx}); } else if(!_isAbsoluteIri(value) && api.processingMode(rval, 1.0)) { throw new JsonLdError( 'Invalid JSON-LD syntax; the value of "@vocab" in a ' + '@context must be an absolute IRI.', 'jsonld.SyntaxError', {code: 'invalid vocab mapping', context: ctx}); } else { const vocab = _expandIri(rval, value, {vocab: true, base: true}, undefined, undefined, options); if(!_isAbsoluteIri(vocab)) { if(options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'relative @vocab reference', level: 'warning', message: 'Relative @vocab reference found.', details: { vocab } }, options }); } } rval['@vocab'] = vocab; } defined.set('@vocab', true); } // handle @language if('@language' in ctx) { const value = ctx['@language']; if(value === null) { delete rval['@language']; } else if(!_isString(value)) { throw new JsonLdError( 'Invalid JSON-LD syntax; the value of "@language" in a ' + '@context must be a string or null.', 'jsonld.SyntaxError', {code: 'invalid default language', context: ctx}); } else { if(!value.match(REGEX_BCP47)) { if(options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'invalid @language value', level: 'warning', message: '@language value must be valid BCP47.', details: { language: value } }, options }); } } rval['@language'] = value.toLowerCase(); } defined.set('@language', true); } // handle @direction if('@direction' in ctx) { const value = ctx['@direction']; if(activeCtx.processingMode === 'json-ld-1.0') { throw new JsonLdError( 'Invalid JSON-LD syntax; @direction not compatible with ' + activeCtx.processingMode, 'jsonld.SyntaxError', {code: 'invalid context member', context: ctx}); } if(value === null) { delete rval['@direction']; } else if(value !== 'ltr' && value !== 'rtl') { throw new JsonLdError( 'Invalid JSON-LD syntax; the value of "@direction" in a ' + '@context must be null, "ltr", or "rtl".', 'jsonld.SyntaxError', {code: 'invalid base direction', context: ctx}); } else { rval['@direction'] = value; } defined.set('@direction', true); } // handle @propagate // note: we've already extracted it, here we just do error checking if('@propagate' in ctx) { const value = ctx['@propagate']; if(activeCtx.processingMode === 'json-ld-1.0') { throw new JsonLdError( 'Invalid JSON-LD syntax; @propagate not compatible with ' + activeCtx.processingMode, 'jsonld.SyntaxError', {code: 'invalid context entry', context: ctx}); } if(typeof value !== 'boolean') { throw new JsonLdError( 'Invalid JSON-LD syntax; @propagate value must be a boolean.', 'jsonld.SyntaxError', {code: 'invalid @propagate value', context: localCtx}); } defined.set('@propagate', true); } // handle @import if('@import' in ctx) { const value = ctx['@import']; if(activeCtx.processingMode === 'json-ld-1.0') { throw new JsonLdError( 'Invalid JSON-LD syntax; @import not compatible with ' + activeCtx.processingMode, 'jsonld.SyntaxError', {code: 'invalid context entry', context: ctx}); } if(!_isString(value)) { throw new JsonLdError( 'Invalid JSON-LD syntax; @import must be a string.', 'jsonld.SyntaxError', {code: 'invalid @import value', context: localCtx}); } // resolve contexts const resolvedImport = await options.contextResolver.resolve({ activeCtx, context: value, documentLoader: options.documentLoader, base: options.base }); if(resolvedImport.length !== 1) { throw new JsonLdError( 'Invalid JSON-LD syntax; @import must reference a single context.', 'jsonld.SyntaxError', {code: 'invalid remote context', context: localCtx}); } const processedImport = resolvedImport[0].getProcessed(activeCtx); if(processedImport) { // Note: if the same context were used in this active context // as a reference context, then processed_input might not // be a dict. ctx = processedImport; } else { const importCtx = resolvedImport[0].document; if('@import' in importCtx) { throw new JsonLdError( 'Invalid JSON-LD syntax: ' + 'imported context must not include @import.', 'jsonld.SyntaxError', {code: 'invalid context entry', context: localCtx}); } // merge ctx into importCtx and replace rval with the result for(const key in importCtx) { if(!ctx.hasOwnProperty(key)) { ctx[key] = importCtx[key]; } } // Note: this could potenially conflict if the import // were used in the same active context as a referenced // context and an import. In this case, we // could override the cached result, but seems unlikely. resolvedImport[0].setProcessed(activeCtx, ctx); } defined.set('@import', true); } // handle @protected; determine whether this sub-context is declaring // all its terms to be "protected" (exceptions can be made on a // per-definition basis) defined.set('@protected', ctx['@protected'] || false); // process all other keys for(const key in ctx) { api.createTermDefinition({ activeCtx: rval, localCtx: ctx, term: key, defined, options, overrideProtected }); if(_isObject(ctx[key]) && '@context' in ctx[key]) { const keyCtx = ctx[key]['@context']; let process = true; if(_isString(keyCtx)) { const url = prependBase(options.base, keyCtx); // track processed contexts to avoid scoped context recursion if(cycles.has(url)) { process = false; } else { cycles.add(url); } } // parse context to validate if(process) { try { await api.process({ activeCtx: rval.clone(), localCtx: ctx[key]['@context'], overrideProtected: true, options, cycles }); } catch(e) { throw new JsonLdError( 'Invalid JSON-LD syntax; invalid scoped context.', 'jsonld.SyntaxError', { code: 'invalid scoped context', context: ctx[key]['@context'], term: key }); } } } } // cache processed result resolvedContext.setProcessed(activeCtx, { context: rval, events }); } return rval; }; /** * Creates a term definition during context processing. * * @param activeCtx the current active context. * @param localCtx the local context being processed. * @param term the term in the local context to define the mapping for. * @param defined a map of defining/defined keys to detect cycles and prevent * double definitions. * @param {Object} [options] - creation options. * @param overrideProtected `false` allows protected terms to be modified. */ api.createTermDefinition = ({ activeCtx, localCtx, term, defined, options, overrideProtected = false, }) => { if(defined.has(term)) { // term already defined if(defined.get(term)) { return; } // cycle detected throw new JsonLdError( 'Cyclical context definition detected.', 'jsonld.CyclicalContext', {code: 'cyclic IRI mapping', context: localCtx, term}); } // now defining term defined.set(term, false); // get context term value let value; if(localCtx.hasOwnProperty(term)) { value = localCtx[term]; } if(term === '@type' && _isObject(value) && (value['@container'] || '@set') === '@set' && api.processingMode(activeCtx, 1.1)) { const validKeys = ['@container', '@id', '@protected']; const keys = Object.keys(value); if(keys.length === 0 || keys.some(k => !validKeys.includes(k))) { throw new JsonLdError( 'Invalid JSON-LD syntax; keywords cannot be overridden.', 'jsonld.SyntaxError', {code: 'keyword redefinition', context: localCtx, term}); } } else if(api.isKeyword(term)) { throw new JsonLdError( 'Invalid JSON-LD syntax; keywords cannot be overridden.', 'jsonld.SyntaxError', {code: 'keyword redefinition', context: localCtx, term}); } else if(term.match(REGEX_KEYWORD)) { if(options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'reserved term', level: 'warning', message: 'Terms beginning with "@" are ' + 'reserved for future use and dropped.', details: { term } }, options }); } return; } else if(term === '') { throw new JsonLdError( 'Invalid JSON-LD syntax; a term cannot be an empty string.', 'jsonld.SyntaxError', {code: 'invalid term definition', context: localCtx}); } // keep reference to previous mapping for potential `@protected` check const previousMapping = activeCtx.mappings.get(term); // remove old mapping if(activeCtx.mappings.has(term)) { activeCtx.mappings.delete(term); } // convert short-hand value to object w/@id let simpleTerm = false; if(_isString(value) || value === null) { simpleTerm = true; value = {'@id': value}; } if(!_isObject(value)) { throw new JsonLdError( 'Invalid JSON-LD syntax; @context term values must be ' + 'strings or objects.', 'jsonld.SyntaxError', {code: 'invalid term definition', context: localCtx}); } // create new mapping const mapping = {}; activeCtx.mappings.set(term, mapping); mapping.reverse = false; // make sure term definition only has expected keywords const validKeys = ['@container', '@id', '@language', '@reverse', '@type']; // JSON-LD 1.1 support if(api.processingMode(activeCtx, 1.1)) { validKeys.push( '@context', '@direction', '@index', '@nest', '@prefix', '@protected'); } for(const kw in value) { if(!validKeys.includes(kw)) { throw new JsonLdError( 'Invalid JSON-LD syntax; a term definition must not contain ' + kw, 'jsonld.SyntaxError', {code: 'invalid term definition', context: localCtx}); } } // always compute whether term has a colon as an optimization for // _compactIri const colon = term.indexOf(':'); mapping._termHasColon = (colon > 0); if('@reverse' in value) { if('@id' in value) { throw new JsonLdError( 'Invalid JSON-LD syntax; a @reverse term definition must not ' + 'contain @id.', 'jsonld.SyntaxError', {code: 'invalid reverse property', context: localCtx}); } if('@nest' in value) { throw new JsonLdError( 'Invalid JSON-LD syntax; a @reverse term definition must not ' + 'contain @nest.', 'jsonld.SyntaxError', {code: 'invalid reverse property', context: localCtx}); } const reverse = value['@reverse']; if(!_isString(reverse)) { throw new JsonLdError( 'Invalid JSON-LD syntax; a @context @reverse value must be a string.', 'jsonld.SyntaxError', {code: 'invalid IRI mapping', context: localCtx}); } if(reverse.match(REGEX_KEYWORD)) { if(options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'reserved @reverse value', level: 'warning', message: '@reverse values beginning with "@" are ' + 'reserved for future use and dropped.', details: { reverse } }, options }); } if(previousMapping) { activeCtx.mappings.set(term, previousMapping); } else { activeCtx.mappings.delete(term); } return; } // expand and add @id mapping const id = _expandIri( activeCtx, reverse, {vocab: true, base: false}, localCtx, defined, options); if(!_isAbsoluteIri(id)) { throw new JsonLdError( 'Invalid JSON-LD syntax; a @context @reverse value must be an ' + 'absolute IRI or a blank node identifier.', 'jsonld.SyntaxError', {code: 'invalid IRI mapping', context: localCtx}); } mapping['@id'] = id; mapping.reverse = true; } else if('@id' in value) { let id = value['@id']; if(id && !_isString(id)) { throw new JsonLdError( 'Invalid JSON-LD syntax; a @context @id value must be an array ' + 'of strings or a string.', 'jsonld.SyntaxError', {code: 'invalid IRI mapping', context: localCtx}); } if(id === null) { // reserve a null term, which may be protected mapping['@id'] = null; } else if(!api.isKeyword(id) && id.match(REGEX_KEYWORD)) { if(options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'reserved @id value', level: 'warning', message: '@id values beginning with "@" are ' + 'reserved for future use and dropped.', details: { id } }, options }); } if(previousMapping) { activeCtx.mappings.set(term, previousMapping); } else { activeCtx.mappings.delete(term); } return; } else if(id !== term) { // expand and add @id mapping id = _expandIri( activeCtx, id, {vocab: true, base: false}, localCtx, defined, options); if(!_isAbsoluteIri(id) && !api.isKeyword(id)) { throw new JsonLdError( 'Invalid JSON-LD syntax; a @context @id value must be an ' + 'absolute IRI, a blank node identifier, or a keyword.', 'jsonld.SyntaxError', {code: 'invalid IRI mapping', context: localCtx}); } // if term has the form of an IRI it must map the same if(term.match(/(?::[^:])|\//)) { const termDefined = new Map(defined).set(term, true); const termIri = _expandIri( activeCtx, term, {vocab: true, base: false}, localCtx, termDefined, options); if(termIri !== id) { throw new JsonLdError( 'Invalid JSON-LD syntax; term in form of IRI must ' + 'expand to definition.', 'jsonld.SyntaxError', {code: 'invalid IRI mapping', context: localCtx}); } } mapping['@id'] = id; // indicate if this term may be used as a compact IRI prefix mapping._prefix = (simpleTerm && !mapping._termHasColon && id.match(/[:\/\?#\[\]@]$/) !== null); } } if(!('@id' in mapping)) { // see if the term has a prefix if(mapping._termHasColon) { const prefix = term.substr(0, colon); if(localCtx.hasOwnProperty(prefix)) { // define parent prefix api.createTermDefinition({ activeCtx, localCtx, term: prefix, defined, options }); } if(activeCtx.mappings.has(prefix)) { // set @id based on prefix parent const suffix = term.substr(colon + 1); mapping['@id'] = activeCtx.mappings.get(prefix)['@id'] + suffix; } else { // term is an absolute IRI mapping['@id'] = term; } } else if(term === '@type') { // Special case, were we've previously determined that container is @set mapping['@id'] = term; } else { // non-IRIs *must* define @ids if @vocab is not available if(!('@vocab' in activeCtx)) { throw new JsonLdError( 'Invalid JSON-LD syntax; @context terms must define an @id.', 'jsonld.SyntaxError', {code: 'invalid IRI mapping', context: localCtx, term}); } // prepend vocab to term mapping['@id'] = activeCtx['@vocab'] + term; } } // Handle term protection if(value['@protected'] === true || (defined.get('@protected') === true && value['@protected'] !== false)) { activeCtx.protected[term] = true; mapping.protected = true; } // IRI mapping now defined defined.set(term, true); if('@type' in value) { let type = value['@type']; if(!_isString(type)) { throw new JsonLdError( 'Invalid JSON-LD syntax; an @context @type value must be a string.', 'jsonld.SyntaxError', {code: 'invalid type mapping', context: localCtx}); } if((type === '@json' || type === '@none')) { if(api.processingMode(activeCtx, 1.0)) { throw new JsonLdError( 'Invalid JSON-LD syntax; an @context @type value must not be ' + `"${type}" in JSON-LD 1.0 mode.`, 'jsonld.SyntaxError', {code: 'invalid type mapping', context: localCtx}); } } else if(type !== '@id' && type !== '@vocab') { // expand @type to full IRI type = _expandIri( activeCtx, type, {vocab: true, base: false}, localCtx, defined, options); if(!_isAbsoluteIri(type)) { throw new JsonLdError( 'Invalid JSON-LD syntax; an @context @type value must be an ' + 'absolute IRI.', 'jsonld.SyntaxError', {code: 'invalid type mapping', context: localCtx}); } if(type.indexOf('_:') === 0) { throw new JsonLdError( 'Invalid JSON-LD syntax; an @context @type value must be an IRI, ' + 'not a blank node identifier.', 'jsonld.SyntaxError', {code: 'invalid type mapping', context: localCtx}); } } // add @type to mapping mapping['@type'] = type; } if('@container' in value) { // normalize container to an array form const container = _isString(value['@container']) ? [value['@container']] : (value['@container'] || []); const validContainers = ['@list', '@set', '@index', '@language']; let isValid = true; const hasSet = container.includes('@set'); // JSON-LD 1.1 support if(api.processingMode(activeCtx, 1.1)) { validContainers.push('@graph', '@id', '@type'); // check container length if(container.includes('@list')) { if(container.length !== 1) { throw new JsonLdError( 'Invalid JSON-LD syntax; @context @container with @list must ' + 'have no other values', 'jsonld.SyntaxError', {code: 'invalid container mapping', context: localCtx}); } } else if(container.includes('@graph')) { if(container.some(key => key !== '@graph' && key !== '@id' && key !== '@index' && key !== '@set')) { throw new JsonLdError( 'Invalid JSON-LD syntax; @context @container with @graph must ' + 'have no other values other than @id, @index, and @set', 'jsonld.SyntaxError', {code: 'invalid container mapping', context: localCtx}); } } else { // otherwise, container may also include @set isValid &= container.length <= (hasSet ? 2 : 1); } if(container.includes('@type')) { // If mapping does not have an @type, // set it to @id mapping['@type'] = mapping['@type'] || '@id'; // type mapping must be either @id or @vocab if(!['@id', '@vocab'].includes(mapping['@type'])) { throw new JsonLdError( 'Invalid JSON-LD syntax; container: @type requires @type to be ' + '@id or @vocab.', 'jsonld.SyntaxError', {code: 'invalid type mapping', context: localCtx}); } } } else { // in JSON-LD 1.0, container must not be an array (it must be a string, // which is one of the validContainers) isValid &= !_isArray(value['@container']); // check container length isValid &= container.length <= 1; } // check against valid containers isValid &= container.every(c => validContainers.includes(c)); // @set not allowed with @list isValid &= !(hasSet && container.includes('@list')); if(!isValid) { throw new JsonLdError( 'Invalid JSON-LD syntax; @context @container value must be ' + 'one of the following: ' + validContainers.join(', '), 'jsonld.SyntaxError', {code: 'invalid container mapping', context: localCtx}); } if(mapping.reverse && !container.every(c => ['@index', '@set'].includes(c))) { throw new JsonLdError( 'Invalid JSON-LD syntax; @context @container value for a @reverse ' + 'type definition must be @index or @set.', 'jsonld.SyntaxError', {code: 'invalid reverse property', context: localCtx}); } // add @container to mapping mapping['@container'] = container; } // property indexing if('@index' in value) { if(!('@container' in value) || !mapping['@container'].includes('@index')) { throw new JsonLdError( 'Invalid JSON-LD syntax; @index without @index in @container: ' + `"${value['@index']}" on term "${term}".`, 'jsonld.SyntaxError', {code: 'invalid term definition', context: localCtx}); } if(!_isString(value['@index']) || value['@index'].indexOf('@') === 0) { throw new JsonLdError( 'Invalid JSON-LD syntax; @index must expand to an IRI: ' + `"${value['@index']}" on term "${term}".`, 'jsonld.SyntaxError', {code: 'invalid term definition', context: localCtx}); } mapping['@index'] = value['@index']; } // scoped contexts if('@context' in value) { mapping['@context'] = value['@context']; } if('@language' in value && !('@type' in value)) { let language = value['@language']; if(language !== null && !_isString(language)) { throw new JsonLdError( 'Invalid JSON-LD syntax; @context @language value must be ' + 'a string or null.', 'jsonld.SyntaxError', {code: 'invalid language mapping', context: localCtx}); } // add @language to mapping if(language !== null) { language = language.toLowerCase(); } mapping['@language'] = language; } // term may be used as a prefix if('@prefix' in value) { if(term.match(/:|\//)) { throw new JsonLdError( 'Invalid JSON-LD syntax; @context @prefix used on a compact IRI term', 'jsonld.SyntaxError', {code: 'invalid term definition', context: localCtx}); } if(api.isKeyword(mapping['@id'])) { throw new JsonLdError( 'Invalid JSON-LD syntax; keywords may not be used as prefixes', 'jsonld.SyntaxError', {code: 'invalid term definition', context: localCtx}); } if(typeof value['@prefix'] === 'boolean') { mapping._prefix = value['@prefix'] === true; } else { throw new JsonLdError( 'Invalid JSON-LD syntax; @context value for @prefix must be boolean', 'jsonld.SyntaxError', {code: 'invalid @prefix value', context: localCtx}); } } if('@direction' in value) { const direction = value['@direction']; if(direction !== null && direction !== 'ltr' && direction !== 'rtl') { throw new JsonLdError( 'Invalid JSON-LD syntax; @direction value must be ' + 'null, "ltr", or "rtl".', 'jsonld.SyntaxError', {code: 'invalid base direction', context: localCtx}); } mapping['@direction'] = direction; } if('@nest' in value) { const nest = value['@nest']; if(!_isString(nest) || (nest !== '@nest' && nest.indexOf('@') === 0)) { throw new JsonLdError( 'Invalid JSON-LD syntax; @context @nest value must be ' + 'a string which is not a keyword other than @nest.', 'jsonld.SyntaxError', {code: 'invalid @nest value', context: localCtx}); } mapping['@nest'] = nest; } // disallow aliasing @context and @preserve const id = mapping['@id']; if(id === '@context' || id === '@preserve') { throw new JsonLdError( 'Invalid JSON-LD syntax; @context and @preserve cannot be aliased.', 'jsonld.SyntaxError', {code: 'invalid keyword alias', context: localCtx}); } // Check for overriding protected terms if(previousMapping && previousMapping.protected && !overrideProtected) { // force new term to continue to be protected and see if the mappings would // be equal activeCtx.protected[term] = true; mapping.protected = true; if(!_deepCompare(previousMapping, mapping)) { throw new JsonLdError( 'Invalid JSON-LD syntax; tried to redefine a protected term.', 'jsonld.SyntaxError', {code: 'protected term redefinition', context: localCtx, term}); } } }; /** * Expands a string to a full IRI. The string may be a term, a prefix, a * relative IRI, or an absolute IRI. The associated absolute IRI will be * returned. * * @param activeCtx the current active context. * @param value the string to expand. * @param relativeTo options for how to resolve relative IRIs: * base: true to resolve against the base IRI, false not to. * vocab: true to concatenate after @vocab, false not to. * @param {Object} [options] - processing options. * * @return the expanded value. */ api.expandIri = (activeCtx, value, relativeTo, options) => { return _expandIri(activeCtx, value, relativeTo, undefined, undefined, options); }; /** * Expands a string to a full IRI. The string may be a term, a prefix, a * relative IRI, or an absolute IRI. The associated absolute IRI will be * returned. * * @param activeCtx the current active context. * @param value the string to expand. * @param relativeTo options for how to resolve relative IRIs: * base: true to resolve against the base IRI, false not to. * vocab: true to concatenate after @vocab, false not to. * @param localCtx the local context being processed (only given if called * during context processing). * @param defined a map for tracking cycles in context definitions (only given * if called during context processing). * @param {Object} [options] - processing options. * * @return the expanded value. */ function _expandIri(activeCtx, value, relativeTo, localCtx, defined, options) { // already expanded if(value === null || !_isString(value) || api.isKeyword(value)) { return value; } // ignore non-keyword things that look like a keyword if(value.match(REGEX_KEYWORD)) { return null; } // define term dependency if not defined if(localCtx && localCtx.hasOwnProperty(value) && defined.get(value) !== true) { api.createTermDefinition({ activeCtx, localCtx, term: value, defined, options }); } relativeTo = relativeTo || {}; if(relativeTo.vocab) { const mapping = activeCtx.mappings.get(value); // value is explicitly ignored with a null mapping if(mapping === null) { return null; } if(_isObject(mapping) && '@id' in mapping) { // value is a term return mapping['@id']; } } // split value into prefix:suffix const colon = value.indexOf(':'); if(colon > 0) { const prefix = value.substr(0, colon); const suffix = value.substr(colon + 1); // do not expand blank nodes (prefix of '_') or already-absolute // IRIs (suffix of '//') if(prefix === '_' || suffix.indexOf('//') === 0) { return value; } // prefix dependency not defined, define it if(localCtx && localCtx.hasOwnProperty(prefix)) { api.createTermDefinition({ activeCtx, localCtx, term: prefix, defined, options }); } // use mapping if prefix is defined const mapping = activeCtx.mappings.get(prefix); if(mapping && mapping._prefix) { return mapping['@id'] + suffix; } // already absolute IRI if(_isAbsoluteIri(value)) { return value; } } // A flag that captures whether the iri being expanded is // the value for an @type //let typeExpansion = false; //if(options !== undefined && options.typeExpansion !== undefined) { // typeExpansion = options.typeExpansion; //} if(relativeTo.vocab && '@vocab' in activeCtx) { // prepend vocab const prependedResult = activeCtx['@vocab'] + value; // FIXME: needed? may be better as debug event. /* if(options && options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'prepending @vocab during expansion', level: 'info', message: 'Prepending @vocab during expansion.', details: { type: '@vocab', vocab: activeCtx['@vocab'], value, result: prependedResult, typeExpansion } }, options }); } */ // the null case preserves value as potentially relative value = prependedResult; } else if(relativeTo.base) { // prepend base let prependedResult; let base; if('@base' in activeCtx) { if(activeCtx['@base']) { base = prependBase(options.base, activeCtx['@base']); prependedResult = prependBase(base, value); } else { base = activeCtx['@base']; prependedResult = value; } } else { base = options.base; prependedResult = prependBase(options.base, value); } // FIXME: needed? may be better as debug event. /* if(options && options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'prepending @base during expansion', level: 'info', message: 'Prepending @base during expansion.', details: { type: '@base', base, value, result: prependedResult, typeExpansion } }, options }); } */ // the null case preserves value as potentially relative value = prependedResult; } // FIXME: duplicate? needed? maybe just enable in a verbose debug mode /* if(!_isAbsoluteIri(value) && options && options.eventHandler) { // emit event indicating a relative IRI was found, which can result in it // being dropped when converting to other RDF representations _handleEvent({ event: { type: ['JsonLdEvent'], code: 'relative IRI after expansion', // FIXME: what level? level: 'warning', message: 'Relative IRI after expansion.', details: { relativeIri: value, typeExpansion } }, options }); // NOTE: relative reference events emitted at calling sites as needed } */ return value; } /** * Gets the initial context. * * @param options the options to use: * [base] the document base IRI. * * @return the initial context. */ api.getInitialContext = options => { const key = JSON.stringify({processingMode: options.processingMode}); const cached = INITIAL_CONTEXT_CACHE.get(key); if(cached) { return cached; } const initialContext = { processingMode: options.processingMode, mappings: new Map(), inverse: null, getInverse: _createInverseContext, clone: _cloneActiveContext, revertToPreviousContext: _revertToPreviousContext, protected: {} }; // TODO: consider using LRU cache instead if(INITIAL_CONTEXT_CACHE.size === INITIAL_CONTEXT_CACHE_MAX_SIZE) { // clear whole cache -- assumes scenario where the cache fills means // the cache isn't being used very efficiently anyway INITIAL_CONTEXT_CACHE.clear(); } INITIAL_CONTEXT_CACHE.set(key, initialContext); return initialContext; /** * Generates an inverse context for use in the compaction algorithm, if * not already generated for the given active context. * * @return the inverse context. */ function _createInverseContext() { const activeCtx = this; // lazily create inverse if(activeCtx.inverse) { return activeCtx.inverse; } const inverse = activeCtx.inverse = {}; // variables for building fast CURIE map const fastCurieMap = activeCtx.fastCurieMap = {}; const irisToTerms = {}; // handle default language const defaultLanguage = (activeCtx['@language'] || '@none').toLowerCase(); // handle default direction const defaultDirection = activeCtx['@direction']; // create term selections for each mapping in the context, ordered by // shortest and then lexicographically least const mappings = activeCtx.mappings; const terms = [...mappings.keys()].sort(_compareShortestLeast); for(const term of terms) { const mapping = mappings.get(term); if(mapping === null) { continue; } let container = mapping['@container'] || '@none'; container = [].concat(container).sort().join(''); if(mapping['@id'] === null) { continue; } // iterate over every IRI in the mapping const ids = _asArray(mapping['@id']); for(const iri of ids) { let entry = inverse[iri]; const isKeyword = api.isKeyword(iri); if(!entry) { // initialize entry inverse[iri] = entry = {}; if(!isKeyword && !mapping._termHasColon) { // init IRI to term map and fast CURIE prefixes irisToTerms[iri] = [term]; const fastCurieEntry = {iri, terms: irisToTerms[iri]}; if(iri[0] in fastCurieMap) { fastCurieMap[iri[0]].push(fastCurieEntry); } else { fastCurieMap[iri[0]] = [fastCurieEntry]; } } } else if(!isKeyword && !mapping._termHasColon) { // add IRI to term match irisToTerms[iri].push(term); } // add new entry if(!entry[container]) { entry[container] = { '@language': {}, '@type': {}, '@any': {} }; } entry = entry[container]; _addPreferredTerm(term, entry['@any'], '@none'); if(mapping.reverse) { // term is preferred for values using @reverse _addPreferredTerm(term, entry['@type'], '@reverse'); } else if(mapping['@type'] === '@none') { _addPreferredTerm(term, entry['@any'], '@none'); _addPreferredTerm(term, entry['@language'], '@none'); _addPreferredTerm(term, entry['@type'], '@none'); } else if('@type' in mapping) { // term is preferred for values using specific type _addPreferredTerm(term, entry['@type'], mapping['@type']); } else if('@language' in mapping && '@direction' in mapping) { // term is preferred for values using specific language and direction const language = mapping['@language']; const direction = mapping['@direction']; if(language && direction) { _addPreferredTerm(term, entry['@language'], `${language}_${direction}`.toLowerCase()); } else if(language) { _addPreferredTerm(term, entry['@language'], language.toLowerCase()); } else if(direction) { _addPreferredTerm(term, entry['@language'], `_${direction}`); } else { _addPreferredTerm(term, entry['@language'], '@null'); } } else if('@language' in mapping) { _addPreferredTerm(term, entry['@language'], (mapping['@language'] || '@null').toLowerCase()); } else if('@direction' in mapping) { if(mapping['@direction']) { _addPreferredTerm(term, entry['@language'], `_${mapping['@direction']}`); } else { _addPreferredTerm(term, entry['@language'], '@none'); } } else if(defaultDirection) { _addPreferredTerm(term, entry['@language'], `_${defaultDirection}`); _addPreferredTerm(term, entry['@language'], '@none'); _addPreferredTerm(term, entry['@type'], '@none'); } else { // add entries for no type and no language _addPreferredTerm(term, entry['@language'], defaultLanguage); _addPreferredTerm(term, entry['@language'], '@none'); _addPreferredTerm(term, entry['@type'], '@none'); } } } // build fast CURIE map for(const key in fastCurieMap) { _buildIriMap(fastCurieMap, key, 1); } return inverse; } /** * Runs a recursive algorithm to build a lookup map for quickly finding * potential CURIEs. * * @param iriMap the map to build. * @param key the current key in the map to work on. * @param idx the index into the IRI to compare. */ function _buildIriMap(iriMap, key, idx) { const entries = iriMap[key]; const next = iriMap[key] = {}; let iri; let letter; for(const entry of entries) { iri = entry.iri; if(idx >= iri.length) { letter = ''; } else { letter = iri[idx]; } if(letter in next) { next[letter].push(entry); } else { next[letter] = [entry]; } } for(const key in next) { if(key === '') { continue; } _buildIriMap(next, key, idx + 1); } } /** * Adds the term for the given entry if not already added. * * @param term the term to add. * @param entry the inverse context typeOrLanguage entry to add to. * @param typeOrLanguageValue the key in the entry to add to. */ function _addPreferredTerm(term, entry, typeOrLanguageValue) { if(!entry.hasOwnProperty(typeOrLanguageValue)) { entry[typeOrLanguageValue] = term; } } /** * Clones an active context, creating a child active context. * * @return a clone (child) of the active context. */ function _cloneActiveContext() { const child = {}; child.mappings = util.clone(this.mappings); child.clone = this.clone; child.inverse = null; child.getInverse = this.getInverse; child.protected = util.clone(this.protected); if(this.previousContext) { child.previousContext = this.previousContext.clone(); } child.revertToPreviousContext = this.revertToPreviousContext; if('@base' in this) { child['@base'] = this['@base']; } if('@language' in this) { child['@language'] = this['@language']; } if('@vocab' in this) { child['@vocab'] = this['@vocab']; } return child; } /** * Reverts any type-scoped context in this active context to the previous * context. */ function _revertToPreviousContext() { if(!this.previousContext) { return this; } return this.previousContext.clone(); } }; /** * Gets the value for the given active context key and type, null if none is * set or undefined if none is set and type is '@context'. * * @param ctx the active context. * @param key the context key. * @param [type] the type of value to get (eg: '@id', '@type'), if not * specified gets the entire entry for a key, null if not found. * * @return the value, null, or undefined. */ api.getContextValue = (ctx, key, type) => { // invalid key if(key === null) { if(type === '@context') { return undefined; } return null; } // get specific entry information if(ctx.mappings.has(key)) { const entry = ctx.mappings.get(key); if(_isUndefined(type)) { // return whole entry return entry; } if(entry.hasOwnProperty(type)) { // return entry value for type return entry[type]; } } // get default language if(type === '@language' && type in ctx) { return ctx[type]; } // get default direction if(type === '@direction' && type in ctx) { return ctx[type]; } if(type === '@context') { return undefined; } return null; }; /** * Processing Mode check. * * @param activeCtx the current active context. * @param version the string or numeric version to check. * * @return boolean. */ api.processingMode = (activeCtx, version) => { if(version.toString() >= '1.1') { return !activeCtx.processingMode || activeCtx.processingMode >= 'json-ld-' + version.toString(); } else { return activeCtx.processingMode === 'json-ld-1.0'; } }; /** * Returns whether or not the given value is a keyword. * * @param v the value to check. * * @return true if the value is a keyword, false if not. */ api.isKeyword = v => { if(!_isString(v) || v[0] !== '@') { return false; } switch(v) { case '@base': case '@container': case '@context': case '@default': case '@direction': case '@embed': case '@explicit': case '@graph': case '@id': case '@included': case '@index': case '@json': case '@language': case '@list': case '@nest': case '@none': case '@omitDefault': case '@prefix': case '@preserve': case '@protected': case '@requireAll': case '@reverse': case '@set': case '@type': case '@value': case '@version': case '@vocab': return true; } return false; }; function _deepCompare(x1, x2) { // compare `null` or primitive types directly if((!(x1 && typeof x1 === 'object')) || (!(x2 && typeof x2 === 'object'))) { return x1 === x2; } // x1 and x2 are objects (also potentially arrays) const x1Array = Array.isArray(x1); if(x1Array !== Array.isArray(x2)) { return false; } if(x1Array) { if(x1.length !== x2.length) { return false; } for(let i = 0; i < x1.length; ++i) { if(!_deepCompare(x1[i], x2[i])) { return false; } } return true; } // x1 and x2 are non-array objects const k1s = Object.keys(x1); const k2s = Object.keys(x2); if(k1s.length !== k2s.length) { return false; } for(const k1 in x1) { let v1 = x1[k1]; let v2 = x2[k1]; // special case: `@container` can be in any order if(k1 === '@container') { if(Array.isArray(v1) && Array.isArray(v2)) { v1 = v1.slice().sort(); v2 = v2.slice().sort(); } } if(!_deepCompare(v1, v2)) { return false; } } return true; }