/* * Copyright (c) 2017 Digital Bazaar, Inc. All rights reserved. */ 'use strict'; const JsonLdError = require('./JsonLdError'); const { isArray: _isArray, isObject: _isObject, isEmptyObject: _isEmptyObject, isString: _isString, isUndefined: _isUndefined } = require('./types'); const { isList: _isList, isValue: _isValue, isGraph: _isGraph, isSubject: _isSubject } = require('./graphTypes'); const { expandIri: _expandIri, getContextValue: _getContextValue, isKeyword: _isKeyword, process: _processContext, processingMode: _processingMode } = require('./context'); const { isAbsolute: _isAbsoluteIri } = require('./url'); const { REGEX_BCP47, REGEX_KEYWORD, addValue: _addValue, asArray: _asArray, getValues: _getValues, validateTypeValue: _validateTypeValue } = require('./util'); const { handleEvent: _handleEvent } = require('./events'); const api = {}; module.exports = api; /** * Recursively expands an element using the given context. Any context in * the element will be removed. All context URLs must have been retrieved * before calling this method. * * @param activeCtx the context to use. * @param activeProperty the property for the element, null for none. * @param element the element to expand. * @param options the expansion options. * @param insideList true if the element is a list, false if not. * @param insideIndex true if the element is inside an index container, * false if not. * @param typeScopedContext an optional type-scoped active context for * expanding values of nodes that were expressed according to * a type-scoped context. * * @return a Promise that resolves to the expanded value. */ api.expand = async ({ activeCtx, activeProperty = null, element, options = {}, insideList = false, insideIndex = false, typeScopedContext = null }) => { // nothing to expand if(element === null || element === undefined) { return null; } // disable framing if activeProperty is @default if(activeProperty === '@default') { options = Object.assign({}, options, {isFrame: false}); } if(!_isArray(element) && !_isObject(element)) { // drop free-floating scalars that are not in lists if(!insideList && (activeProperty === null || _expandIri(activeCtx, activeProperty, {vocab: true}, options) === '@graph')) { // FIXME if(options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'free-floating scalar', level: 'warning', message: 'Dropping free-floating scalar not in a list.', details: { value: element //activeProperty //insideList } }, options }); } return null; } // expand element according to value expansion rules return _expandValue({activeCtx, activeProperty, value: element, options}); } // recursively expand array if(_isArray(element)) { let rval = []; const container = _getContextValue( activeCtx, activeProperty, '@container') || []; insideList = insideList || container.includes('@list'); for(let i = 0; i < element.length; ++i) { // expand element let e = await api.expand({ activeCtx, activeProperty, element: element[i], options, insideIndex, typeScopedContext }); if(insideList && _isArray(e)) { e = {'@list': e}; } if(e === null) { // FIXME: add debug event? //unmappedValue: element[i], //activeProperty, //parent: element, //index: i, //expandedParent: rval, //insideList // NOTE: no-value events emitted at calling sites as needed continue; } if(_isArray(e)) { rval = rval.concat(e); } else { rval.push(e); } } return rval; } // recursively expand object: // first, expand the active property const expandedActiveProperty = _expandIri( activeCtx, activeProperty, {vocab: true}, options); // Get any property-scoped context for activeProperty const propertyScopedCtx = _getContextValue(activeCtx, activeProperty, '@context'); // second, determine if any type-scoped context should be reverted; it // should only be reverted when the following are all true: // 1. `element` is not a value or subject reference // 2. `insideIndex` is false typeScopedContext = typeScopedContext || (activeCtx.previousContext ? activeCtx : null); let keys = Object.keys(element).sort(); let mustRevert = !insideIndex; if(mustRevert && typeScopedContext && keys.length <= 2 && !keys.includes('@context')) { for(const key of keys) { const expandedProperty = _expandIri( typeScopedContext, key, {vocab: true}, options); if(expandedProperty === '@value') { // value found, ensure type-scoped context is used to expand it mustRevert = false; activeCtx = typeScopedContext; break; } if(expandedProperty === '@id' && keys.length === 1) { // subject reference found, do not revert mustRevert = false; break; } } } if(mustRevert) { // revert type scoped context activeCtx = activeCtx.revertToPreviousContext(); } // apply property-scoped context after reverting term-scoped context if(!_isUndefined(propertyScopedCtx)) { activeCtx = await _processContext({ activeCtx, localCtx: propertyScopedCtx, propagate: true, overrideProtected: true, options }); } // if element has a context, process it if('@context' in element) { activeCtx = await _processContext( {activeCtx, localCtx: element['@context'], options}); } // set the type-scoped context to the context on input, for use later typeScopedContext = activeCtx; // Remember the first key found expanding to @type let typeKey = null; // look for scoped contexts on `@type` for(const key of keys) { const expandedProperty = _expandIri(activeCtx, key, {vocab: true}, options); if(expandedProperty === '@type') { // set scoped contexts from @type // avoid sorting if possible typeKey = typeKey || key; const value = element[key]; const types = Array.isArray(value) ? (value.length > 1 ? value.slice().sort() : value) : [value]; for(const type of types) { const ctx = _getContextValue(typeScopedContext, type, '@context'); if(!_isUndefined(ctx)) { activeCtx = await _processContext({ activeCtx, localCtx: ctx, options, propagate: false }); } } } } // process each key and value in element, ignoring @nest content let rval = {}; await _expandObject({ activeCtx, activeProperty, expandedActiveProperty, element, expandedParent: rval, options, insideList, typeKey, typeScopedContext }); // get property count on expanded output keys = Object.keys(rval); let count = keys.length; if('@value' in rval) { // @value must only have @language or @type if('@type' in rval && ('@language' in rval || '@direction' in rval)) { throw new JsonLdError( 'Invalid JSON-LD syntax; an element containing "@value" may not ' + 'contain both "@type" and either "@language" or "@direction".', 'jsonld.SyntaxError', {code: 'invalid value object', element: rval}); } let validCount = count - 1; if('@type' in rval) { validCount -= 1; } if('@index' in rval) { validCount -= 1; } if('@language' in rval) { validCount -= 1; } if('@direction' in rval) { validCount -= 1; } if(validCount !== 0) { throw new JsonLdError( 'Invalid JSON-LD syntax; an element containing "@value" may only ' + 'have an "@index" property and either "@type" ' + 'or either or both "@language" or "@direction".', 'jsonld.SyntaxError', {code: 'invalid value object', element: rval}); } const values = rval['@value'] === null ? [] : _asArray(rval['@value']); const types = _getValues(rval, '@type'); // drop null @values if(_processingMode(activeCtx, 1.1) && types.includes('@json') && types.length === 1) { // Any value of @value is okay if @type: @json } else if(values.length === 0) { // FIXME if(options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'null @value value', level: 'warning', message: 'Dropping null @value value.', details: { value: rval } }, options }); } rval = null; } else if(!values.every(v => (_isString(v) || _isEmptyObject(v))) && '@language' in rval) { // if @language is present, @value must be a string throw new JsonLdError( 'Invalid JSON-LD syntax; only strings may be language-tagged.', 'jsonld.SyntaxError', {code: 'invalid language-tagged value', element: rval}); } else if(!types.every(t => (_isAbsoluteIri(t) && !(_isString(t) && t.indexOf('_:') === 0) || _isEmptyObject(t)))) { throw new JsonLdError( 'Invalid JSON-LD syntax; an element containing "@value" and "@type" ' + 'must have an absolute IRI for the value of "@type".', 'jsonld.SyntaxError', {code: 'invalid typed value', element: rval}); } } else if('@type' in rval && !_isArray(rval['@type'])) { // convert @type to an array rval['@type'] = [rval['@type']]; } else if('@set' in rval || '@list' in rval) { // handle @set and @list if(count > 1 && !(count === 2 && '@index' in rval)) { throw new JsonLdError( 'Invalid JSON-LD syntax; if an element has the property "@set" ' + 'or "@list", then it can have at most one other property that is ' + '"@index".', 'jsonld.SyntaxError', {code: 'invalid set or list object', element: rval}); } // optimize away @set if('@set' in rval) { rval = rval['@set']; keys = Object.keys(rval); count = keys.length; } } else if(count === 1 && '@language' in rval) { // drop objects with only @language // FIXME if(options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'object with only @language', level: 'warning', message: 'Dropping object with only @language.', details: { value: rval } }, options }); } rval = null; } // drop certain top-level objects that do not occur in lists if(_isObject(rval) && !options.keepFreeFloatingNodes && !insideList && (activeProperty === null || expandedActiveProperty === '@graph' || (_getContextValue(activeCtx, activeProperty, '@container') || []) .includes('@graph') )) { // drop empty object, top-level @value/@list, or object with only @id rval = _dropUnsafeObject({value: rval, count, options}); } return rval; }; /** * Drop empty object, top-level @value/@list, or object with only @id * * @param value Value to check. * @param count Number of properties in object. * @param options The expansion options. * * @return null if dropped, value otherwise. */ function _dropUnsafeObject({ value, count, options }) { if(count === 0 || '@value' in value || '@list' in value || (count === 1 && '@id' in value)) { // FIXME if(options.eventHandler) { // FIXME: one event or diff event for empty, @v/@l, {@id}? let code; let message; if(count === 0) { code = 'empty object'; message = 'Dropping empty object.'; } else if('@value' in value) { code = 'object with only @value'; message = 'Dropping object with only @value.'; } else if('@list' in value) { code = 'object with only @list'; message = 'Dropping object with only @list.'; } else if(count === 1 && '@id' in value) { code = 'object with only @id'; message = 'Dropping object with only @id.'; } _handleEvent({ event: { type: ['JsonLdEvent'], code, level: 'warning', message, details: { value } }, options }); } return null; } return value; } /** * Expand each key and value of element adding to result * * @param activeCtx the context to use. * @param activeProperty the property for the element. * @param expandedActiveProperty the expansion of activeProperty * @param element the element to expand. * @param expandedParent the expanded result into which to add values. * @param options the expansion options. * @param insideList true if the element is a list, false if not. * @param typeKey first key found expanding to @type. * @param typeScopedContext the context before reverting. */ async function _expandObject({ activeCtx, activeProperty, expandedActiveProperty, element, expandedParent, options = {}, insideList, typeKey, typeScopedContext }) { const keys = Object.keys(element).sort(); const nests = []; let unexpandedValue; // Figure out if this is the type for a JSON literal const isJsonType = element[typeKey] && _expandIri(activeCtx, (_isArray(element[typeKey]) ? element[typeKey][0] : element[typeKey]), {vocab: true}, { ...options, typeExpansion: true }) === '@json'; for(const key of keys) { let value = element[key]; let expandedValue; // skip @context if(key === '@context') { continue; } // expand property const expandedProperty = _expandIri(activeCtx, key, {vocab: true}, options); // drop non-absolute IRI keys that aren't keywords if(expandedProperty === null || !(_isAbsoluteIri(expandedProperty) || _isKeyword(expandedProperty))) { if(options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'invalid property', level: 'warning', message: 'Dropping property that did not expand into an ' + 'absolute IRI or keyword.', details: { property: key, expandedProperty } }, options }); } continue; } if(_isKeyword(expandedProperty)) { if(expandedActiveProperty === '@reverse') { throw new JsonLdError( 'Invalid JSON-LD syntax; a keyword cannot be used as a @reverse ' + 'property.', 'jsonld.SyntaxError', {code: 'invalid reverse property map', value}); } if(expandedProperty in expandedParent && expandedProperty !== '@included' && expandedProperty !== '@type') { throw new JsonLdError( 'Invalid JSON-LD syntax; colliding keywords detected.', 'jsonld.SyntaxError', {code: 'colliding keywords', keyword: expandedProperty}); } } // syntax error if @id is not a string if(expandedProperty === '@id') { if(!_isString(value)) { if(!options.isFrame) { throw new JsonLdError( 'Invalid JSON-LD syntax; "@id" value must a string.', 'jsonld.SyntaxError', {code: 'invalid @id value', value}); } if(_isObject(value)) { // empty object is a wildcard if(!_isEmptyObject(value)) { throw new JsonLdError( 'Invalid JSON-LD syntax; "@id" value an empty object or array ' + 'of strings, if framing', 'jsonld.SyntaxError', {code: 'invalid @id value', value}); } } else if(_isArray(value)) { if(!value.every(v => _isString(v))) { throw new JsonLdError( 'Invalid JSON-LD syntax; "@id" value an empty object or array ' + 'of strings, if framing', 'jsonld.SyntaxError', {code: 'invalid @id value', value}); } } else { throw new JsonLdError( 'Invalid JSON-LD syntax; "@id" value an empty object or array ' + 'of strings, if framing', 'jsonld.SyntaxError', {code: 'invalid @id value', value}); } } _addValue( expandedParent, '@id', _asArray(value).map(v => { if(_isString(v)) { const ve = _expandIri(activeCtx, v, {base: true}, options); if(options.eventHandler) { if(ve === null) { // NOTE: spec edge case // See https://github.com/w3c/json-ld-api/issues/480 if(v === null) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'null @id value', level: 'warning', message: 'Null @id found.', details: { id: v } }, options }); } else { // matched KEYWORD regex _handleEvent({ event: { type: ['JsonLdEvent'], code: 'reserved @id value', level: 'warning', message: 'Reserved @id found.', details: { id: v } }, options }); } } else if(!_isAbsoluteIri(ve)) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'relative @id reference', level: 'warning', message: 'Relative @id reference found.', details: { id: v, expandedId: ve } }, options }); } } return ve; } return v; }), {propertyIsArray: options.isFrame}); continue; } if(expandedProperty === '@type') { // if framing, can be a default object, but need to expand // key to determine that if(_isObject(value)) { value = Object.fromEntries(Object.entries(value).map(([k, v]) => [ _expandIri(typeScopedContext, k, {vocab: true}), _asArray(v).map(vv => _expandIri(typeScopedContext, vv, {base: true, vocab: true}, {...options, typeExpansion: true}) ) ])); } _validateTypeValue(value, options.isFrame); _addValue( expandedParent, '@type', _asArray(value).map(v => { if(_isString(v)) { const ve = _expandIri(typeScopedContext, v, {base: true, vocab: true}, {...options, typeExpansion: true}); if(ve !== '@json' && !_isAbsoluteIri(ve)) { if(options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'relative @type reference', level: 'warning', message: 'Relative @type reference found.', details: { type: v } }, options }); } } return ve; } return v; }), {propertyIsArray: !!options.isFrame}); continue; } // Included blocks are treated as an array of separate object nodes sharing // the same referencing active_property. // For 1.0, it is skipped as are other unknown keywords if(expandedProperty === '@included' && _processingMode(activeCtx, 1.1)) { const includedResult = _asArray(await api.expand({ activeCtx, activeProperty, element: value, options })); // Expanded values must be node objects if(!includedResult.every(v => _isSubject(v))) { throw new JsonLdError( 'Invalid JSON-LD syntax; ' + 'values of @included must expand to node objects.', 'jsonld.SyntaxError', {code: 'invalid @included value', value}); } _addValue( expandedParent, '@included', includedResult, {propertyIsArray: true}); continue; } // @graph must be an array or an object if(expandedProperty === '@graph' && !(_isObject(value) || _isArray(value))) { throw new JsonLdError( 'Invalid JSON-LD syntax; "@graph" value must not be an ' + 'object or an array.', 'jsonld.SyntaxError', {code: 'invalid @graph value', value}); } if(expandedProperty === '@value') { // capture value for later // "colliding keywords" check prevents this from being set twice unexpandedValue = value; if(isJsonType && _processingMode(activeCtx, 1.1)) { // no coercion to array, and retain all values expandedParent['@value'] = value; } else { _addValue( expandedParent, '@value', value, {propertyIsArray: options.isFrame}); } continue; } // @language must be a string // it should match BCP47 if(expandedProperty === '@language') { if(value === null) { // drop null @language values, they expand as if they didn't exist continue; } if(!_isString(value) && !options.isFrame) { throw new JsonLdError( 'Invalid JSON-LD syntax; "@language" value must be a string.', 'jsonld.SyntaxError', {code: 'invalid language-tagged string', value}); } // ensure language value is lowercase value = _asArray(value).map(v => _isString(v) ? v.toLowerCase() : v); // ensure language tag matches BCP47 for(const language of value) { if(_isString(language) && !language.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 } }, options }); } } } _addValue( expandedParent, '@language', value, {propertyIsArray: options.isFrame}); continue; } // @direction must be "ltr" or "rtl" if(expandedProperty === '@direction') { if(!_isString(value) && !options.isFrame) { throw new JsonLdError( 'Invalid JSON-LD syntax; "@direction" value must be a string.', 'jsonld.SyntaxError', {code: 'invalid base direction', value}); } value = _asArray(value); // ensure direction is "ltr" or "rtl" for(const dir of value) { if(_isString(dir) && dir !== 'ltr' && dir !== 'rtl') { throw new JsonLdError( 'Invalid JSON-LD syntax; "@direction" must be "ltr" or "rtl".', 'jsonld.SyntaxError', {code: 'invalid base direction', value}); } } _addValue( expandedParent, '@direction', value, {propertyIsArray: options.isFrame}); continue; } // @index must be a string if(expandedProperty === '@index') { if(!_isString(value)) { throw new JsonLdError( 'Invalid JSON-LD syntax; "@index" value must be a string.', 'jsonld.SyntaxError', {code: 'invalid @index value', value}); } _addValue(expandedParent, '@index', value); continue; } // @reverse must be an object if(expandedProperty === '@reverse') { if(!_isObject(value)) { throw new JsonLdError( 'Invalid JSON-LD syntax; "@reverse" value must be an object.', 'jsonld.SyntaxError', {code: 'invalid @reverse value', value}); } expandedValue = await api.expand({ activeCtx, activeProperty: '@reverse', element: value, options }); // properties double-reversed if('@reverse' in expandedValue) { for(const property in expandedValue['@reverse']) { _addValue( expandedParent, property, expandedValue['@reverse'][property], {propertyIsArray: true}); } } // FIXME: can this be merged with code below to simplify? // merge in all reversed properties let reverseMap = expandedParent['@reverse'] || null; for(const property in expandedValue) { if(property === '@reverse') { continue; } if(reverseMap === null) { reverseMap = expandedParent['@reverse'] = {}; } _addValue(reverseMap, property, [], {propertyIsArray: true}); const items = expandedValue[property]; for(let ii = 0; ii < items.length; ++ii) { const item = items[ii]; if(_isValue(item) || _isList(item)) { throw new JsonLdError( 'Invalid JSON-LD syntax; "@reverse" value must not be a ' + '@value or an @list.', 'jsonld.SyntaxError', {code: 'invalid reverse property value', value: expandedValue}); } _addValue(reverseMap, property, item, {propertyIsArray: true}); } } continue; } // nested keys if(expandedProperty === '@nest') { nests.push(key); continue; } // use potential scoped context for key let termCtx = activeCtx; const ctx = _getContextValue(activeCtx, key, '@context'); if(!_isUndefined(ctx)) { termCtx = await _processContext({ activeCtx, localCtx: ctx, propagate: true, overrideProtected: true, options }); } const container = _getContextValue(activeCtx, key, '@container') || []; if(container.includes('@language') && _isObject(value)) { const direction = _getContextValue(termCtx, key, '@direction'); // handle language map container (skip if value is not an object) expandedValue = _expandLanguageMap(termCtx, value, direction, options); } else if(container.includes('@index') && _isObject(value)) { // handle index container (skip if value is not an object) const asGraph = container.includes('@graph'); const indexKey = _getContextValue(termCtx, key, '@index') || '@index'; const propertyIndex = indexKey !== '@index' && _expandIri(activeCtx, indexKey, {vocab: true}, options); expandedValue = await _expandIndexMap({ activeCtx: termCtx, options, activeProperty: key, value, asGraph, indexKey, propertyIndex }); } else if(container.includes('@id') && _isObject(value)) { // handle id container (skip if value is not an object) const asGraph = container.includes('@graph'); expandedValue = await _expandIndexMap({ activeCtx: termCtx, options, activeProperty: key, value, asGraph, indexKey: '@id' }); } else if(container.includes('@type') && _isObject(value)) { // handle type container (skip if value is not an object) expandedValue = await _expandIndexMap({ // since container is `@type`, revert type scoped context when expanding activeCtx: termCtx.revertToPreviousContext(), options, activeProperty: key, value, asGraph: false, indexKey: '@type' }); } else { // recurse into @list or @set const isList = expandedProperty === '@list'; if(isList || expandedProperty === '@set') { let nextActiveProperty = activeProperty; if(isList && expandedActiveProperty === '@graph') { nextActiveProperty = null; } expandedValue = await api.expand({ activeCtx: termCtx, activeProperty: nextActiveProperty, element: value, options, insideList: isList }); } else if( _getContextValue(activeCtx, key, '@type') === '@json') { expandedValue = { '@type': '@json', '@value': value }; } else { // recursively expand value with key as new active property expandedValue = await api.expand({ activeCtx: termCtx, activeProperty: key, element: value, options, insideList: false }); } } // drop null values if property is not @value if(expandedValue === null && expandedProperty !== '@value') { // FIXME: event? //unmappedValue: value, //expandedProperty, //key, continue; } // convert expanded value to @list if container specifies it if(expandedProperty !== '@list' && !_isList(expandedValue) && container.includes('@list')) { // ensure expanded value in @list is an array expandedValue = {'@list': _asArray(expandedValue)}; } // convert expanded value to @graph if container specifies it // and value is not, itself, a graph // index cases handled above if(container.includes('@graph') && !container.some(key => key === '@id' || key === '@index')) { // ensure expanded values are in an array expandedValue = _asArray(expandedValue); if(!options.isFrame) { // drop items if needed expandedValue = expandedValue.filter(v => { const count = Object.keys(v).length; return _dropUnsafeObject({value: v, count, options}) !== null; }); } if(expandedValue.length === 0) { // all items dropped, skip adding and continue continue; } // convert to graph expandedValue = expandedValue.map(v => ({'@graph': _asArray(v)})); } // FIXME: can this be merged with code above to simplify? // merge in reverse properties if(termCtx.mappings.has(key) && termCtx.mappings.get(key).reverse) { const reverseMap = expandedParent['@reverse'] = expandedParent['@reverse'] || {}; expandedValue = _asArray(expandedValue); for(let ii = 0; ii < expandedValue.length; ++ii) { const item = expandedValue[ii]; if(_isValue(item) || _isList(item)) { throw new JsonLdError( 'Invalid JSON-LD syntax; "@reverse" value must not be a ' + '@value or an @list.', 'jsonld.SyntaxError', {code: 'invalid reverse property value', value: expandedValue}); } _addValue(reverseMap, expandedProperty, item, {propertyIsArray: true}); } continue; } // add value for property // special keywords handled above _addValue(expandedParent, expandedProperty, expandedValue, { propertyIsArray: true }); } // @value must not be an object or an array (unless framing) or if @type is // @json if('@value' in expandedParent) { if(expandedParent['@type'] === '@json' && _processingMode(activeCtx, 1.1)) { // allow any value, to be verified when the object is fully expanded and // the @type is @json. } else if((_isObject(unexpandedValue) || _isArray(unexpandedValue)) && !options.isFrame) { throw new JsonLdError( 'Invalid JSON-LD syntax; "@value" value must not be an ' + 'object or an array.', 'jsonld.SyntaxError', {code: 'invalid value object value', value: unexpandedValue}); } } // expand each nested key for(const key of nests) { const nestedValues = _isArray(element[key]) ? element[key] : [element[key]]; for(const nv of nestedValues) { if(!_isObject(nv) || Object.keys(nv).some(k => _expandIri(activeCtx, k, {vocab: true}, options) === '@value')) { throw new JsonLdError( 'Invalid JSON-LD syntax; nested value must be a node object.', 'jsonld.SyntaxError', {code: 'invalid @nest value', value: nv}); } await _expandObject({ activeCtx, activeProperty, expandedActiveProperty, element: nv, expandedParent, options, insideList, typeScopedContext, typeKey }); } } } /** * Expands the given value by using the coercion and keyword rules in the * given context. * * @param activeCtx the active context to use. * @param activeProperty the active property the value is associated with. * @param value the value to expand. * @param {Object} [options] - processing options. * * @return the expanded value. */ function _expandValue({activeCtx, activeProperty, value, options}) { // nothing to expand if(value === null || value === undefined) { return null; } // special-case expand @id and @type (skips '@id' expansion) const expandedProperty = _expandIri( activeCtx, activeProperty, {vocab: true}, options); if(expandedProperty === '@id') { return _expandIri(activeCtx, value, {base: true}, options); } else if(expandedProperty === '@type') { return _expandIri(activeCtx, value, {vocab: true, base: true}, {...options, typeExpansion: true}); } // get type definition from context const type = _getContextValue(activeCtx, activeProperty, '@type'); // do @id expansion (automatic for @graph) if((type === '@id' || expandedProperty === '@graph') && _isString(value)) { const expandedValue = _expandIri(activeCtx, value, {base: true}, options); // NOTE: handle spec edge case and avoid invalid {"@id": null} if(expandedValue === null && value.match(REGEX_KEYWORD)) { if(options.eventHandler) { _handleEvent({ event: { type: ['JsonLdEvent'], code: 'reserved @id value', level: 'warning', message: 'Reserved @id found.', details: { id: activeProperty } }, options }); } } return {'@id': expandedValue}; } // do @id expansion w/vocab if(type === '@vocab' && _isString(value)) { return { '@id': _expandIri(activeCtx, value, {vocab: true, base: true}, options) }; } // do not expand keyword values if(_isKeyword(expandedProperty)) { return value; } const rval = {}; if(type && !['@id', '@vocab', '@none'].includes(type)) { // other type rval['@type'] = type; } else if(_isString(value)) { // check for language tagging for strings const language = _getContextValue(activeCtx, activeProperty, '@language'); if(language !== null) { rval['@language'] = language; } const direction = _getContextValue(activeCtx, activeProperty, '@direction'); if(direction !== null) { rval['@direction'] = direction; } } // do conversion of values that aren't basic JSON types to strings if(!['boolean', 'number', 'string'].includes(typeof value)) { value = value.toString(); } rval['@value'] = value; return rval; } /** * Expands a language map. * * @param activeCtx the active context to use. * @param languageMap the language map to expand. * @param direction the direction to apply to values. * @param {Object} [options] - processing options. * * @return the expanded language map. */ function _expandLanguageMap(activeCtx, languageMap, direction, options) { const rval = []; const keys = Object.keys(languageMap).sort(); for(const key of keys) { const expandedKey = _expandIri(activeCtx, key, {vocab: true}, options); let val = languageMap[key]; if(!_isArray(val)) { val = [val]; } for(const item of val) { if(item === null) { // null values are allowed (8.5) but ignored (3.1) continue; } if(!_isString(item)) { throw new JsonLdError( 'Invalid JSON-LD syntax; language map values must be strings.', 'jsonld.SyntaxError', {code: 'invalid language map value', languageMap}); } const val = {'@value': item}; if(expandedKey !== '@none') { if(!key.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: key } }, options }); } } val['@language'] = key.toLowerCase(); } if(direction) { val['@direction'] = direction; } rval.push(val); } } return rval; } async function _expandIndexMap({ activeCtx, options, activeProperty, value, asGraph, indexKey, propertyIndex }) { const rval = []; const keys = Object.keys(value).sort(); const isTypeIndex = indexKey === '@type'; for(let key of keys) { // if indexKey is @type, there may be a context defined for it if(isTypeIndex) { const ctx = _getContextValue(activeCtx, key, '@context'); if(!_isUndefined(ctx)) { activeCtx = await _processContext({ activeCtx, localCtx: ctx, propagate: false, options }); } } let val = value[key]; if(!_isArray(val)) { val = [val]; } val = await api.expand({ activeCtx, activeProperty, element: val, options, insideList: false, insideIndex: true }); // expand for @type, but also for @none let expandedKey; if(propertyIndex) { if(key === '@none') { expandedKey = '@none'; } else { expandedKey = _expandValue( {activeCtx, activeProperty: indexKey, value: key, options}); } } else { expandedKey = _expandIri(activeCtx, key, {vocab: true}, options); } if(indexKey === '@id') { // expand document relative key = _expandIri(activeCtx, key, {base: true}, options); } else if(isTypeIndex) { key = expandedKey; } for(let item of val) { // If this is also a @graph container, turn items into graphs if(asGraph && !_isGraph(item)) { item = {'@graph': [item]}; } if(indexKey === '@type') { if(expandedKey === '@none') { // ignore @none } else if(item['@type']) { item['@type'] = [key].concat(item['@type']); } else { item['@type'] = [key]; } } else if(_isValue(item) && !['@language', '@type', '@index'].includes(indexKey)) { throw new JsonLdError( 'Invalid JSON-LD syntax; Attempt to add illegal key to value ' + `object: "${indexKey}".`, 'jsonld.SyntaxError', {code: 'invalid value object', value: item}); } else if(propertyIndex) { // index is a property to be expanded, and values interpreted for that // property if(expandedKey !== '@none') { // expand key as a value _addValue(item, propertyIndex, expandedKey, { propertyIsArray: true, prependValue: true }); } } else if(expandedKey !== '@none' && !(indexKey in item)) { item[indexKey] = key; } rval.push(item); } } return rval; }