UNPKG

47.2 kBJavaScriptView Raw
1/*
2 * Copyright (c) 2017-2019 Digital Bazaar, Inc. All rights reserved.
3 */
4'use strict';
5
6const util = require('./util');
7const JsonLdError = require('./JsonLdError');
8
9const {
10 isArray: _isArray,
11 isObject: _isObject,
12 isString: _isString,
13 isUndefined: _isUndefined
14} = require('./types');
15
16const {
17 isAbsolute: _isAbsoluteIri,
18 isRelative: _isRelativeIri,
19 prependBase
20} = require('./url');
21
22const {
23 asArray: _asArray,
24 compareShortestLeast: _compareShortestLeast
25} = require('./util');
26
27const INITIAL_CONTEXT_CACHE = new Map();
28const INITIAL_CONTEXT_CACHE_MAX_SIZE = 10000;
29const KEYWORD_PATTERN = /^@[a-zA-Z]+$/;
30
31const api = {};
32module.exports = api;
33
34/**
35 * Processes a local context and returns a new active context.
36 *
37 * @param activeCtx the current active context.
38 * @param localCtx the local context to process.
39 * @param options the context processing options.
40 * @param propagate `true` if `false`, retains any previously defined term,
41 * which can be rolled back when the descending into a new node object.
42 * @param overrideProtected `false` allows protected terms to be modified.
43 *
44 * @return a Promise that resolves to the new active context.
45 */
46api.process = async ({
47 activeCtx, localCtx, options,
48 propagate = true,
49 overrideProtected = false,
50 cycles = new Set()
51}) => {
52 // normalize local context to an array of @context objects
53 if(_isObject(localCtx) && '@context' in localCtx &&
54 _isArray(localCtx['@context'])) {
55 localCtx = localCtx['@context'];
56 }
57 const ctxs = _asArray(localCtx);
58
59 // no contexts in array, return current active context w/o changes
60 if(ctxs.length === 0) {
61 return activeCtx;
62 }
63
64 // resolve contexts
65 const resolved = await options.contextResolver.resolve({
66 activeCtx,
67 context: localCtx,
68 documentLoader: options.documentLoader,
69 base: options.base
70 });
71
72 // override propagate if first resolved context has `@propagate`
73 if(_isObject(resolved[0].document) &&
74 typeof resolved[0].document['@propagate'] === 'boolean') {
75 // retrieve early, error checking done later
76 propagate = resolved[0].document['@propagate'];
77 }
78
79 // process each context in order, update active context
80 // on each iteration to ensure proper caching
81 let rval = activeCtx;
82
83 // track the previous context
84 // if not propagating, make sure rval has a previous context
85 if(!propagate && !rval.previousContext) {
86 // clone `rval` context before updating
87 rval = rval.clone();
88 rval.previousContext = activeCtx;
89 }
90
91 for(const resolvedContext of resolved) {
92 let {document: ctx} = resolvedContext;
93
94 // update active context to one computed from last iteration
95 activeCtx = rval;
96
97 // reset to initial context
98 if(ctx === null) {
99 // We can't nullify if there are protected terms and we're
100 // not allowing overrides (e.g. processing a property term scoped context)
101 if(!overrideProtected &&
102 Object.keys(activeCtx.protected).length !== 0) {
103 const protectedMode = (options && options.protectedMode) || 'error';
104 if(protectedMode === 'error') {
105 throw new JsonLdError(
106 'Tried to nullify a context with protected terms outside of ' +
107 'a term definition.',
108 'jsonld.SyntaxError',
109 {code: 'invalid context nullification'});
110 } else if(protectedMode === 'warn') {
111 // FIXME: remove logging and use a handler
112 console.warn('WARNING: invalid context nullification');
113
114 // get processed context from cache if available
115 const processed = resolvedContext.getProcessed(activeCtx);
116 if(processed) {
117 rval = activeCtx = processed;
118 continue;
119 }
120
121 const oldActiveCtx = activeCtx;
122 // copy all protected term definitions to fresh initial context
123 rval = activeCtx = api.getInitialContext(options).clone();
124 for(const [term, _protected] of
125 Object.entries(oldActiveCtx.protected)) {
126 if(_protected) {
127 activeCtx.mappings[term] =
128 util.clone(oldActiveCtx.mappings[term]);
129 }
130 }
131 activeCtx.protected = util.clone(oldActiveCtx.protected);
132
133 // cache processed result
134 resolvedContext.setProcessed(oldActiveCtx, rval);
135 continue;
136 }
137 throw new JsonLdError(
138 'Invalid protectedMode.',
139 'jsonld.SyntaxError',
140 {code: 'invalid protected mode', context: localCtx, protectedMode});
141 }
142 rval = activeCtx = api.getInitialContext(options).clone();
143 continue;
144 }
145
146 // get processed context from cache if available
147 const processed = resolvedContext.getProcessed(activeCtx);
148 if(processed) {
149 rval = activeCtx = processed;
150 continue;
151 }
152
153 // dereference @context key if present
154 if(_isObject(ctx) && '@context' in ctx) {
155 ctx = ctx['@context'];
156 }
157
158 // context must be an object by now, all URLs retrieved before this call
159 if(!_isObject(ctx)) {
160 throw new JsonLdError(
161 'Invalid JSON-LD syntax; @context must be an object.',
162 'jsonld.SyntaxError', {code: 'invalid local context', context: ctx});
163 }
164
165 // TODO: there is likely a `previousContext` cloning optimization that
166 // could be applied here (no need to copy it under certain conditions)
167
168 // clone context before updating it
169 rval = rval.clone();
170
171 // define context mappings for keys in local context
172 const defined = new Map();
173
174 // handle @version
175 if('@version' in ctx) {
176 if(ctx['@version'] !== 1.1) {
177 throw new JsonLdError(
178 'Unsupported JSON-LD version: ' + ctx['@version'],
179 'jsonld.UnsupportedVersion',
180 {code: 'invalid @version value', context: ctx});
181 }
182 if(activeCtx.processingMode &&
183 activeCtx.processingMode === 'json-ld-1.0') {
184 throw new JsonLdError(
185 '@version: ' + ctx['@version'] + ' not compatible with ' +
186 activeCtx.processingMode,
187 'jsonld.ProcessingModeConflict',
188 {code: 'processing mode conflict', context: ctx});
189 }
190 rval.processingMode = 'json-ld-1.1';
191 rval['@version'] = ctx['@version'];
192 defined.set('@version', true);
193 }
194
195 // if not set explicitly, set processingMode to "json-ld-1.1"
196 rval.processingMode =
197 rval.processingMode || activeCtx.processingMode;
198
199 // handle @base
200 if('@base' in ctx) {
201 let base = ctx['@base'];
202
203 if(base === null || _isAbsoluteIri(base)) {
204 // no action
205 } else if(_isRelativeIri(base)) {
206 base = prependBase(rval['@base'], base);
207 } else {
208 throw new JsonLdError(
209 'Invalid JSON-LD syntax; the value of "@base" in a ' +
210 '@context must be an absolute IRI, a relative IRI, or null.',
211 'jsonld.SyntaxError', {code: 'invalid base IRI', context: ctx});
212 }
213
214 rval['@base'] = base;
215 defined.set('@base', true);
216 }
217
218 // handle @vocab
219 if('@vocab' in ctx) {
220 const value = ctx['@vocab'];
221 if(value === null) {
222 delete rval['@vocab'];
223 } else if(!_isString(value)) {
224 throw new JsonLdError(
225 'Invalid JSON-LD syntax; the value of "@vocab" in a ' +
226 '@context must be a string or null.',
227 'jsonld.SyntaxError', {code: 'invalid vocab mapping', context: ctx});
228 } else if(!_isAbsoluteIri(value) && api.processingMode(rval, 1.0)) {
229 throw new JsonLdError(
230 'Invalid JSON-LD syntax; the value of "@vocab" in a ' +
231 '@context must be an absolute IRI.',
232 'jsonld.SyntaxError', {code: 'invalid vocab mapping', context: ctx});
233 } else {
234 rval['@vocab'] = _expandIri(rval, value, {vocab: true, base: true},
235 undefined, undefined, options);
236 }
237 defined.set('@vocab', true);
238 }
239
240 // handle @language
241 if('@language' in ctx) {
242 const value = ctx['@language'];
243 if(value === null) {
244 delete rval['@language'];
245 } else if(!_isString(value)) {
246 throw new JsonLdError(
247 'Invalid JSON-LD syntax; the value of "@language" in a ' +
248 '@context must be a string or null.',
249 'jsonld.SyntaxError',
250 {code: 'invalid default language', context: ctx});
251 } else {
252 rval['@language'] = value.toLowerCase();
253 }
254 defined.set('@language', true);
255 }
256
257 // handle @direction
258 if('@direction' in ctx) {
259 const value = ctx['@direction'];
260 if(activeCtx.processingMode === 'json-ld-1.0') {
261 throw new JsonLdError(
262 'Invalid JSON-LD syntax; @direction not compatible with ' +
263 activeCtx.processingMode,
264 'jsonld.SyntaxError',
265 {code: 'invalid context member', context: ctx});
266 }
267 if(value === null) {
268 delete rval['@direction'];
269 } else if(value !== 'ltr' && value !== 'rtl') {
270 throw new JsonLdError(
271 'Invalid JSON-LD syntax; the value of "@direction" in a ' +
272 '@context must be null, "ltr", or "rtl".',
273 'jsonld.SyntaxError',
274 {code: 'invalid base direction', context: ctx});
275 } else {
276 rval['@direction'] = value;
277 }
278 defined.set('@direction', true);
279 }
280
281 // handle @propagate
282 // note: we've already extracted it, here we just do error checking
283 if('@propagate' in ctx) {
284 const value = ctx['@propagate'];
285 if(activeCtx.processingMode === 'json-ld-1.0') {
286 throw new JsonLdError(
287 'Invalid JSON-LD syntax; @propagate not compatible with ' +
288 activeCtx.processingMode,
289 'jsonld.SyntaxError',
290 {code: 'invalid context entry', context: ctx});
291 }
292 if(typeof value !== 'boolean') {
293 throw new JsonLdError(
294 'Invalid JSON-LD syntax; @propagate value must be a boolean.',
295 'jsonld.SyntaxError',
296 {code: 'invalid @propagate value', context: localCtx});
297 }
298 defined.set('@propagate', true);
299 }
300
301 // handle @import
302 if('@import' in ctx) {
303 const value = ctx['@import'];
304 if(activeCtx.processingMode === 'json-ld-1.0') {
305 throw new JsonLdError(
306 'Invalid JSON-LD syntax; @import not compatible with ' +
307 activeCtx.processingMode,
308 'jsonld.SyntaxError',
309 {code: 'invalid context entry', context: ctx});
310 }
311 if(!_isString(value)) {
312 throw new JsonLdError(
313 'Invalid JSON-LD syntax; @import must be a string.',
314 'jsonld.SyntaxError',
315 {code: 'invalid @import value', context: localCtx});
316 }
317
318 // resolve contexts
319 const resolvedImport = await options.contextResolver.resolve({
320 activeCtx,
321 context: value,
322 documentLoader: options.documentLoader,
323 base: options.base
324 });
325 if(resolvedImport.length !== 1) {
326 throw new JsonLdError(
327 'Invalid JSON-LD syntax; @import must reference a single context.',
328 'jsonld.SyntaxError',
329 {code: 'invalid remote context', context: localCtx});
330 }
331 const processedImport = resolvedImport[0].getProcessed(activeCtx);
332 if(processedImport) {
333 // Note: if the same context were used in this active context
334 // as a reference context, then processed_input might not
335 // be a dict.
336 ctx = processedImport;
337 } else {
338 const importCtx = resolvedImport[0].document;
339 if('@import' in importCtx) {
340 throw new JsonLdError(
341 'Invalid JSON-LD syntax: ' +
342 'imported context must not include @import.',
343 'jsonld.SyntaxError',
344 {code: 'invalid context entry', context: localCtx});
345 }
346
347 // merge ctx into importCtx and replace rval with the result
348 for(const key in importCtx) {
349 if(!ctx.hasOwnProperty(key)) {
350 ctx[key] = importCtx[key];
351 }
352 }
353
354 // Note: this could potenially conflict if the import
355 // were used in the same active context as a referenced
356 // context and an import. In this case, we
357 // could override the cached result, but seems unlikely.
358 resolvedImport[0].setProcessed(activeCtx, ctx);
359 }
360
361 defined.set('@import', true);
362 }
363
364 // handle @protected; determine whether this sub-context is declaring
365 // all its terms to be "protected" (exceptions can be made on a
366 // per-definition basis)
367 defined.set('@protected', ctx['@protected'] || false);
368
369 // process all other keys
370 for(const key in ctx) {
371 api.createTermDefinition({
372 activeCtx: rval,
373 localCtx: ctx,
374 term: key,
375 defined,
376 options,
377 overrideProtected
378 });
379
380 if(_isObject(ctx[key]) && '@context' in ctx[key]) {
381 const keyCtx = ctx[key]['@context'];
382 let process = true;
383 if(_isString(keyCtx)) {
384 const url = prependBase(options.base, keyCtx);
385 // track processed contexts to avoid scoped context recursion
386 if(cycles.has(url)) {
387 process = false;
388 } else {
389 cycles.add(url);
390 }
391 }
392 // parse context to validate
393 if(process) {
394 try {
395 await api.process({
396 activeCtx: rval.clone(),
397 localCtx: ctx[key]['@context'],
398 overrideProtected: true,
399 options,
400 cycles
401 });
402 } catch(e) {
403 throw new JsonLdError(
404 'Invalid JSON-LD syntax; invalid scoped context.',
405 'jsonld.SyntaxError',
406 {
407 code: 'invalid scoped context',
408 context: ctx[key]['@context'],
409 term: key
410 });
411 }
412 }
413 }
414 }
415
416 // cache processed result
417 resolvedContext.setProcessed(activeCtx, rval);
418 }
419
420 return rval;
421};
422
423/**
424 * Creates a term definition during context processing.
425 *
426 * @param activeCtx the current active context.
427 * @param localCtx the local context being processed.
428 * @param term the term in the local context to define the mapping for.
429 * @param defined a map of defining/defined keys to detect cycles and prevent
430 * double definitions.
431 * @param {Object} [options] - creation options.
432 * @param {string} [options.protectedMode="error"] - "error" to throw error
433 * on `@protected` constraint violation, "warn" to allow violations and
434 * signal a warning.
435 * @param overrideProtected `false` allows protected terms to be modified.
436 */
437api.createTermDefinition = ({
438 activeCtx,
439 localCtx,
440 term,
441 defined,
442 options,
443 overrideProtected = false,
444}) => {
445 if(defined.has(term)) {
446 // term already defined
447 if(defined.get(term)) {
448 return;
449 }
450 // cycle detected
451 throw new JsonLdError(
452 'Cyclical context definition detected.',
453 'jsonld.CyclicalContext',
454 {code: 'cyclic IRI mapping', context: localCtx, term});
455 }
456
457 // now defining term
458 defined.set(term, false);
459
460 // get context term value
461 let value;
462 if(localCtx.hasOwnProperty(term)) {
463 value = localCtx[term];
464 }
465
466 if(term === '@type' &&
467 _isObject(value) &&
468 (value['@container'] || '@set') === '@set' &&
469 api.processingMode(activeCtx, 1.1)) {
470
471 const validKeys = ['@container', '@id', '@protected'];
472 const keys = Object.keys(value);
473 if(keys.length === 0 || keys.some(k => !validKeys.includes(k))) {
474 throw new JsonLdError(
475 'Invalid JSON-LD syntax; keywords cannot be overridden.',
476 'jsonld.SyntaxError',
477 {code: 'keyword redefinition', context: localCtx, term});
478 }
479 } else if(api.isKeyword(term)) {
480 throw new JsonLdError(
481 'Invalid JSON-LD syntax; keywords cannot be overridden.',
482 'jsonld.SyntaxError',
483 {code: 'keyword redefinition', context: localCtx, term});
484 } else if(term.match(KEYWORD_PATTERN)) {
485 // FIXME: remove logging and use a handler
486 console.warn('WARNING: terms beginning with "@" are reserved' +
487 ' for future use and ignored', {term});
488 return;
489 } else if(term === '') {
490 throw new JsonLdError(
491 'Invalid JSON-LD syntax; a term cannot be an empty string.',
492 'jsonld.SyntaxError',
493 {code: 'invalid term definition', context: localCtx});
494 }
495
496 // keep reference to previous mapping for potential `@protected` check
497 const previousMapping = activeCtx.mappings.get(term);
498
499 // remove old mapping
500 if(activeCtx.mappings.has(term)) {
501 activeCtx.mappings.delete(term);
502 }
503
504 // convert short-hand value to object w/@id
505 let simpleTerm = false;
506 if(_isString(value) || value === null) {
507 simpleTerm = true;
508 value = {'@id': value};
509 }
510
511 if(!_isObject(value)) {
512 throw new JsonLdError(
513 'Invalid JSON-LD syntax; @context term values must be ' +
514 'strings or objects.',
515 'jsonld.SyntaxError',
516 {code: 'invalid term definition', context: localCtx});
517 }
518
519 // create new mapping
520 const mapping = {};
521 activeCtx.mappings.set(term, mapping);
522 mapping.reverse = false;
523
524 // make sure term definition only has expected keywords
525 const validKeys = ['@container', '@id', '@language', '@reverse', '@type'];
526
527 // JSON-LD 1.1 support
528 if(api.processingMode(activeCtx, 1.1)) {
529 validKeys.push(
530 '@context', '@direction', '@index', '@nest', '@prefix', '@protected');
531 }
532
533 for(const kw in value) {
534 if(!validKeys.includes(kw)) {
535 throw new JsonLdError(
536 'Invalid JSON-LD syntax; a term definition must not contain ' + kw,
537 'jsonld.SyntaxError',
538 {code: 'invalid term definition', context: localCtx});
539 }
540 }
541
542 // always compute whether term has a colon as an optimization for
543 // _compactIri
544 const colon = term.indexOf(':');
545 mapping._termHasColon = (colon > 0);
546
547 if('@reverse' in value) {
548 if('@id' in value) {
549 throw new JsonLdError(
550 'Invalid JSON-LD syntax; a @reverse term definition must not ' +
551 'contain @id.', 'jsonld.SyntaxError',
552 {code: 'invalid reverse property', context: localCtx});
553 }
554 if('@nest' in value) {
555 throw new JsonLdError(
556 'Invalid JSON-LD syntax; a @reverse term definition must not ' +
557 'contain @nest.', 'jsonld.SyntaxError',
558 {code: 'invalid reverse property', context: localCtx});
559 }
560 const reverse = value['@reverse'];
561 if(!_isString(reverse)) {
562 throw new JsonLdError(
563 'Invalid JSON-LD syntax; a @context @reverse value must be a string.',
564 'jsonld.SyntaxError', {code: 'invalid IRI mapping', context: localCtx});
565 }
566
567 if(!api.isKeyword(reverse) && reverse.match(KEYWORD_PATTERN)) {
568 // FIXME: remove logging and use a handler
569 console.warn('WARNING: values beginning with "@" are reserved' +
570 ' for future use and ignored', {reverse});
571 if(previousMapping) {
572 activeCtx.mappings.set(term, previousMapping);
573 } else {
574 activeCtx.mappings.delete(term);
575 }
576 return;
577 }
578
579 // expand and add @id mapping
580 const id = _expandIri(
581 activeCtx, reverse, {vocab: true, base: false}, localCtx, defined,
582 options);
583 if(!_isAbsoluteIri(id)) {
584 throw new JsonLdError(
585 'Invalid JSON-LD syntax; a @context @reverse value must be an ' +
586 'absolute IRI or a blank node identifier.',
587 'jsonld.SyntaxError', {code: 'invalid IRI mapping', context: localCtx});
588 }
589
590 mapping['@id'] = id;
591 mapping.reverse = true;
592 } else if('@id' in value) {
593 let id = value['@id'];
594 if(id && !_isString(id)) {
595 throw new JsonLdError(
596 'Invalid JSON-LD syntax; a @context @id value must be an array ' +
597 'of strings or a string.',
598 'jsonld.SyntaxError', {code: 'invalid IRI mapping', context: localCtx});
599 }
600 if(id === null) {
601 // reserve a null term, which may be protected
602 mapping['@id'] = null;
603 } else if(!api.isKeyword(id) && id.match(KEYWORD_PATTERN)) {
604 // FIXME: remove logging and use a handler
605 console.warn('WARNING: values beginning with "@" are reserved' +
606 ' for future use and ignored', {id});
607 if(previousMapping) {
608 activeCtx.mappings.set(term, previousMapping);
609 } else {
610 activeCtx.mappings.delete(term);
611 }
612 return;
613 } else if(id !== term) {
614 // expand and add @id mapping
615 id = _expandIri(
616 activeCtx, id, {vocab: true, base: false}, localCtx, defined, options);
617 if(!_isAbsoluteIri(id) && !api.isKeyword(id)) {
618 throw new JsonLdError(
619 'Invalid JSON-LD syntax; a @context @id value must be an ' +
620 'absolute IRI, a blank node identifier, or a keyword.',
621 'jsonld.SyntaxError',
622 {code: 'invalid IRI mapping', context: localCtx});
623 }
624
625 // if term has the form of an IRI it must map the same
626 if(term.match(/(?::[^:])|\//)) {
627 const termDefined = new Map(defined).set(term, true);
628 const termIri = _expandIri(
629 activeCtx, term, {vocab: true, base: false},
630 localCtx, termDefined, options);
631 if(termIri !== id) {
632 throw new JsonLdError(
633 'Invalid JSON-LD syntax; term in form of IRI must ' +
634 'expand to definition.',
635 'jsonld.SyntaxError',
636 {code: 'invalid IRI mapping', context: localCtx});
637 }
638 }
639
640 mapping['@id'] = id;
641 // indicate if this term may be used as a compact IRI prefix
642 mapping._prefix = (simpleTerm &&
643 !mapping._termHasColon &&
644 id.match(/[:\/\?#\[\]@]$/));
645 }
646 }
647
648 if(!('@id' in mapping)) {
649 // see if the term has a prefix
650 if(mapping._termHasColon) {
651 const prefix = term.substr(0, colon);
652 if(localCtx.hasOwnProperty(prefix)) {
653 // define parent prefix
654 api.createTermDefinition({
655 activeCtx, localCtx, term: prefix, defined, options
656 });
657 }
658
659 if(activeCtx.mappings.has(prefix)) {
660 // set @id based on prefix parent
661 const suffix = term.substr(colon + 1);
662 mapping['@id'] = activeCtx.mappings.get(prefix)['@id'] + suffix;
663 } else {
664 // term is an absolute IRI
665 mapping['@id'] = term;
666 }
667 } else if(term === '@type') {
668 // Special case, were we've previously determined that container is @set
669 mapping['@id'] = term;
670 } else {
671 // non-IRIs *must* define @ids if @vocab is not available
672 if(!('@vocab' in activeCtx)) {
673 throw new JsonLdError(
674 'Invalid JSON-LD syntax; @context terms must define an @id.',
675 'jsonld.SyntaxError',
676 {code: 'invalid IRI mapping', context: localCtx, term});
677 }
678 // prepend vocab to term
679 mapping['@id'] = activeCtx['@vocab'] + term;
680 }
681 }
682
683 // Handle term protection
684 if(value['@protected'] === true ||
685 (defined.get('@protected') === true && value['@protected'] !== false)) {
686 activeCtx.protected[term] = true;
687 mapping.protected = true;
688 }
689
690 // IRI mapping now defined
691 defined.set(term, true);
692
693 if('@type' in value) {
694 let type = value['@type'];
695 if(!_isString(type)) {
696 throw new JsonLdError(
697 'Invalid JSON-LD syntax; an @context @type value must be a string.',
698 'jsonld.SyntaxError',
699 {code: 'invalid type mapping', context: localCtx});
700 }
701
702 if((type === '@json' || type === '@none')) {
703 if(api.processingMode(activeCtx, 1.0)) {
704 throw new JsonLdError(
705 'Invalid JSON-LD syntax; an @context @type value must not be ' +
706 `"${type}" in JSON-LD 1.0 mode.`,
707 'jsonld.SyntaxError',
708 {code: 'invalid type mapping', context: localCtx});
709 }
710 } else if(type !== '@id' && type !== '@vocab') {
711 // expand @type to full IRI
712 type = _expandIri(
713 activeCtx, type, {vocab: true, base: false}, localCtx, defined,
714 options);
715 if(!_isAbsoluteIri(type)) {
716 throw new JsonLdError(
717 'Invalid JSON-LD syntax; an @context @type value must be an ' +
718 'absolute IRI.',
719 'jsonld.SyntaxError',
720 {code: 'invalid type mapping', context: localCtx});
721 }
722 if(type.indexOf('_:') === 0) {
723 throw new JsonLdError(
724 'Invalid JSON-LD syntax; an @context @type value must be an IRI, ' +
725 'not a blank node identifier.',
726 'jsonld.SyntaxError',
727 {code: 'invalid type mapping', context: localCtx});
728 }
729 }
730
731 // add @type to mapping
732 mapping['@type'] = type;
733 }
734
735 if('@container' in value) {
736 // normalize container to an array form
737 const container = _isString(value['@container']) ?
738 [value['@container']] : (value['@container'] || []);
739 const validContainers = ['@list', '@set', '@index', '@language'];
740 let isValid = true;
741 const hasSet = container.includes('@set');
742
743 // JSON-LD 1.1 support
744 if(api.processingMode(activeCtx, 1.1)) {
745 validContainers.push('@graph', '@id', '@type');
746
747 // check container length
748 if(container.includes('@list')) {
749 if(container.length !== 1) {
750 throw new JsonLdError(
751 'Invalid JSON-LD syntax; @context @container with @list must ' +
752 'have no other values',
753 'jsonld.SyntaxError',
754 {code: 'invalid container mapping', context: localCtx});
755 }
756 } else if(container.includes('@graph')) {
757 if(container.some(key =>
758 key !== '@graph' && key !== '@id' && key !== '@index' &&
759 key !== '@set')) {
760 throw new JsonLdError(
761 'Invalid JSON-LD syntax; @context @container with @graph must ' +
762 'have no other values other than @id, @index, and @set',
763 'jsonld.SyntaxError',
764 {code: 'invalid container mapping', context: localCtx});
765 }
766 } else {
767 // otherwise, container may also include @set
768 isValid &= container.length <= (hasSet ? 2 : 1);
769 }
770
771 if(container.includes('@type')) {
772 // If mapping does not have an @type,
773 // set it to @id
774 mapping['@type'] = mapping['@type'] || '@id';
775
776 // type mapping must be either @id or @vocab
777 if(!['@id', '@vocab'].includes(mapping['@type'])) {
778 throw new JsonLdError(
779 'Invalid JSON-LD syntax; container: @type requires @type to be ' +
780 '@id or @vocab.',
781 'jsonld.SyntaxError',
782 {code: 'invalid type mapping', context: localCtx});
783 }
784 }
785 } else {
786 // in JSON-LD 1.0, container must not be an array (it must be a string,
787 // which is one of the validContainers)
788 isValid &= !_isArray(value['@container']);
789
790 // check container length
791 isValid &= container.length <= 1;
792 }
793
794 // check against valid containers
795 isValid &= container.every(c => validContainers.includes(c));
796
797 // @set not allowed with @list
798 isValid &= !(hasSet && container.includes('@list'));
799
800 if(!isValid) {
801 throw new JsonLdError(
802 'Invalid JSON-LD syntax; @context @container value must be ' +
803 'one of the following: ' + validContainers.join(', '),
804 'jsonld.SyntaxError',
805 {code: 'invalid container mapping', context: localCtx});
806 }
807
808 if(mapping.reverse &&
809 !container.every(c => ['@index', '@set'].includes(c))) {
810 throw new JsonLdError(
811 'Invalid JSON-LD syntax; @context @container value for a @reverse ' +
812 'type definition must be @index or @set.', 'jsonld.SyntaxError',
813 {code: 'invalid reverse property', context: localCtx});
814 }
815
816 // add @container to mapping
817 mapping['@container'] = container;
818 }
819
820 // property indexing
821 if('@index' in value) {
822 if(!('@container' in value) || !mapping['@container'].includes('@index')) {
823 throw new JsonLdError(
824 'Invalid JSON-LD syntax; @index without @index in @container: ' +
825 `"${value['@index']}" on term "${term}".`, 'jsonld.SyntaxError',
826 {code: 'invalid term definition', context: localCtx});
827 }
828 if(!_isString(value['@index']) || value['@index'].indexOf('@') === 0) {
829 throw new JsonLdError(
830 'Invalid JSON-LD syntax; @index must expand to an IRI: ' +
831 `"${value['@index']}" on term "${term}".`, 'jsonld.SyntaxError',
832 {code: 'invalid term definition', context: localCtx});
833 }
834 mapping['@index'] = value['@index'];
835 }
836
837 // scoped contexts
838 if('@context' in value) {
839 mapping['@context'] = value['@context'];
840 }
841
842 if('@language' in value && !('@type' in value)) {
843 let language = value['@language'];
844 if(language !== null && !_isString(language)) {
845 throw new JsonLdError(
846 'Invalid JSON-LD syntax; @context @language value must be ' +
847 'a string or null.', 'jsonld.SyntaxError',
848 {code: 'invalid language mapping', context: localCtx});
849 }
850
851 // add @language to mapping
852 if(language !== null) {
853 language = language.toLowerCase();
854 }
855 mapping['@language'] = language;
856 }
857
858 // term may be used as a prefix
859 if('@prefix' in value) {
860 if(term.match(/:|\//)) {
861 throw new JsonLdError(
862 'Invalid JSON-LD syntax; @context @prefix used on a compact IRI term',
863 'jsonld.SyntaxError',
864 {code: 'invalid term definition', context: localCtx});
865 }
866 if(api.isKeyword(mapping['@id'])) {
867 throw new JsonLdError(
868 'Invalid JSON-LD syntax; keywords may not be used as prefixes',
869 'jsonld.SyntaxError',
870 {code: 'invalid term definition', context: localCtx});
871 }
872 if(typeof value['@prefix'] === 'boolean') {
873 mapping._prefix = value['@prefix'] === true;
874 } else {
875 throw new JsonLdError(
876 'Invalid JSON-LD syntax; @context value for @prefix must be boolean',
877 'jsonld.SyntaxError',
878 {code: 'invalid @prefix value', context: localCtx});
879 }
880 }
881
882 if('@direction' in value) {
883 const direction = value['@direction'];
884 if(direction !== null && direction !== 'ltr' && direction !== 'rtl') {
885 throw new JsonLdError(
886 'Invalid JSON-LD syntax; @direction value must be ' +
887 'null, "ltr", or "rtl".',
888 'jsonld.SyntaxError',
889 {code: 'invalid base direction', context: localCtx});
890 }
891 mapping['@direction'] = direction;
892 }
893
894 if('@nest' in value) {
895 const nest = value['@nest'];
896 if(!_isString(nest) || (nest !== '@nest' && nest.indexOf('@') === 0)) {
897 throw new JsonLdError(
898 'Invalid JSON-LD syntax; @context @nest value must be ' +
899 'a string which is not a keyword other than @nest.',
900 'jsonld.SyntaxError',
901 {code: 'invalid @nest value', context: localCtx});
902 }
903 mapping['@nest'] = nest;
904 }
905
906 // disallow aliasing @context and @preserve
907 const id = mapping['@id'];
908 if(id === '@context' || id === '@preserve') {
909 throw new JsonLdError(
910 'Invalid JSON-LD syntax; @context and @preserve cannot be aliased.',
911 'jsonld.SyntaxError', {code: 'invalid keyword alias', context: localCtx});
912 }
913
914 // Check for overriding protected terms
915 if(previousMapping && previousMapping.protected && !overrideProtected) {
916 // force new term to continue to be protected and see if the mappings would
917 // be equal
918 activeCtx.protected[term] = true;
919 mapping.protected = true;
920 if(!_deepCompare(previousMapping, mapping)) {
921 const protectedMode = (options && options.protectedMode) || 'error';
922 if(protectedMode === 'error') {
923 throw new JsonLdError(
924 `Invalid JSON-LD syntax; tried to redefine "${term}" which is a ` +
925 'protected term.',
926 'jsonld.SyntaxError',
927 {code: 'protected term redefinition', context: localCtx, term});
928 } else if(protectedMode === 'warn') {
929 // FIXME: remove logging and use a handler
930 console.warn('WARNING: protected term redefinition', {term});
931 return;
932 }
933 throw new JsonLdError(
934 'Invalid protectedMode.',
935 'jsonld.SyntaxError',
936 {code: 'invalid protected mode', context: localCtx, term,
937 protectedMode});
938 }
939 }
940};
941
942/**
943 * Expands a string to a full IRI. The string may be a term, a prefix, a
944 * relative IRI, or an absolute IRI. The associated absolute IRI will be
945 * returned.
946 *
947 * @param activeCtx the current active context.
948 * @param value the string to expand.
949 * @param relativeTo options for how to resolve relative IRIs:
950 * base: true to resolve against the base IRI, false not to.
951 * vocab: true to concatenate after @vocab, false not to.
952 * @param {Object} [options] - processing options.
953 *
954 * @return the expanded value.
955 */
956api.expandIri = (activeCtx, value, relativeTo, options) => {
957 return _expandIri(activeCtx, value, relativeTo, undefined, undefined,
958 options);
959};
960
961/**
962 * Expands a string to a full IRI. The string may be a term, a prefix, a
963 * relative IRI, or an absolute IRI. The associated absolute IRI will be
964 * returned.
965 *
966 * @param activeCtx the current active context.
967 * @param value the string to expand.
968 * @param relativeTo options for how to resolve relative IRIs:
969 * base: true to resolve against the base IRI, false not to.
970 * vocab: true to concatenate after @vocab, false not to.
971 * @param localCtx the local context being processed (only given if called
972 * during context processing).
973 * @param defined a map for tracking cycles in context definitions (only given
974 * if called during context processing).
975 * @param {Object} [options] - processing options.
976 *
977 * @return the expanded value.
978 */
979function _expandIri(activeCtx, value, relativeTo, localCtx, defined, options) {
980 // already expanded
981 if(value === null || !_isString(value) || api.isKeyword(value)) {
982 return value;
983 }
984
985 // ignore non-keyword things that look like a keyword
986 if(value.match(KEYWORD_PATTERN)) {
987 return null;
988 }
989
990 // define term dependency if not defined
991 if(localCtx && localCtx.hasOwnProperty(value) &&
992 defined.get(value) !== true) {
993 api.createTermDefinition({
994 activeCtx, localCtx, term: value, defined, options
995 });
996 }
997
998 relativeTo = relativeTo || {};
999 if(relativeTo.vocab) {
1000 const mapping = activeCtx.mappings.get(value);
1001
1002 // value is explicitly ignored with a null mapping
1003 if(mapping === null) {
1004 return null;
1005 }
1006
1007 if(_isObject(mapping) && '@id' in mapping) {
1008 // value is a term
1009 return mapping['@id'];
1010 }
1011 }
1012
1013 // split value into prefix:suffix
1014 const colon = value.indexOf(':');
1015 if(colon > 0) {
1016 const prefix = value.substr(0, colon);
1017 const suffix = value.substr(colon + 1);
1018
1019 // do not expand blank nodes (prefix of '_') or already-absolute
1020 // IRIs (suffix of '//')
1021 if(prefix === '_' || suffix.indexOf('//') === 0) {
1022 return value;
1023 }
1024
1025 // prefix dependency not defined, define it
1026 if(localCtx && localCtx.hasOwnProperty(prefix)) {
1027 api.createTermDefinition({
1028 activeCtx, localCtx, term: prefix, defined, options
1029 });
1030 }
1031
1032 // use mapping if prefix is defined
1033 const mapping = activeCtx.mappings.get(prefix);
1034 if(mapping && mapping._prefix) {
1035 return mapping['@id'] + suffix;
1036 }
1037
1038 // already absolute IRI
1039 if(_isAbsoluteIri(value)) {
1040 return value;
1041 }
1042 }
1043
1044 // prepend vocab
1045 if(relativeTo.vocab && '@vocab' in activeCtx) {
1046 return activeCtx['@vocab'] + value;
1047 }
1048
1049 // prepend base
1050 if(relativeTo.base && '@base' in activeCtx) {
1051 if(activeCtx['@base']) {
1052 // The null case preserves value as potentially relative
1053 return prependBase(prependBase(options.base, activeCtx['@base']), value);
1054 }
1055 } else if(relativeTo.base) {
1056 return prependBase(options.base, value);
1057 }
1058
1059 return value;
1060}
1061
1062/**
1063 * Gets the initial context.
1064 *
1065 * @param options the options to use:
1066 * [base] the document base IRI.
1067 *
1068 * @return the initial context.
1069 */
1070api.getInitialContext = options => {
1071 const key = JSON.stringify({processingMode: options.processingMode});
1072 const cached = INITIAL_CONTEXT_CACHE.get(key);
1073 if(cached) {
1074 return cached;
1075 }
1076
1077 const initialContext = {
1078 processingMode: options.processingMode,
1079 mappings: new Map(),
1080 inverse: null,
1081 getInverse: _createInverseContext,
1082 clone: _cloneActiveContext,
1083 revertToPreviousContext: _revertToPreviousContext,
1084 protected: {}
1085 };
1086 // TODO: consider using LRU cache instead
1087 if(INITIAL_CONTEXT_CACHE.size === INITIAL_CONTEXT_CACHE_MAX_SIZE) {
1088 // clear whole cache -- assumes scenario where the cache fills means
1089 // the cache isn't being used very efficiently anyway
1090 INITIAL_CONTEXT_CACHE.clear();
1091 }
1092 INITIAL_CONTEXT_CACHE.set(key, initialContext);
1093 return initialContext;
1094
1095 /**
1096 * Generates an inverse context for use in the compaction algorithm, if
1097 * not already generated for the given active context.
1098 *
1099 * @return the inverse context.
1100 */
1101 function _createInverseContext() {
1102 const activeCtx = this;
1103
1104 // lazily create inverse
1105 if(activeCtx.inverse) {
1106 return activeCtx.inverse;
1107 }
1108 const inverse = activeCtx.inverse = {};
1109
1110 // variables for building fast CURIE map
1111 const fastCurieMap = activeCtx.fastCurieMap = {};
1112 const irisToTerms = {};
1113
1114 // handle default language
1115 const defaultLanguage = (activeCtx['@language'] || '@none').toLowerCase();
1116
1117 // handle default direction
1118 const defaultDirection = activeCtx['@direction'];
1119
1120 // create term selections for each mapping in the context, ordered by
1121 // shortest and then lexicographically least
1122 const mappings = activeCtx.mappings;
1123 const terms = [...mappings.keys()].sort(_compareShortestLeast);
1124 for(const term of terms) {
1125 const mapping = mappings.get(term);
1126 if(mapping === null) {
1127 continue;
1128 }
1129
1130 let container = mapping['@container'] || '@none';
1131 container = [].concat(container).sort().join('');
1132
1133 if(mapping['@id'] === null) {
1134 continue;
1135 }
1136 // iterate over every IRI in the mapping
1137 const ids = _asArray(mapping['@id']);
1138 for(const iri of ids) {
1139 let entry = inverse[iri];
1140 const isKeyword = api.isKeyword(iri);
1141
1142 if(!entry) {
1143 // initialize entry
1144 inverse[iri] = entry = {};
1145
1146 if(!isKeyword && !mapping._termHasColon) {
1147 // init IRI to term map and fast CURIE prefixes
1148 irisToTerms[iri] = [term];
1149 const fastCurieEntry = {iri, terms: irisToTerms[iri]};
1150 if(iri[0] in fastCurieMap) {
1151 fastCurieMap[iri[0]].push(fastCurieEntry);
1152 } else {
1153 fastCurieMap[iri[0]] = [fastCurieEntry];
1154 }
1155 }
1156 } else if(!isKeyword && !mapping._termHasColon) {
1157 // add IRI to term match
1158 irisToTerms[iri].push(term);
1159 }
1160
1161 // add new entry
1162 if(!entry[container]) {
1163 entry[container] = {
1164 '@language': {},
1165 '@type': {},
1166 '@any': {}
1167 };
1168 }
1169 entry = entry[container];
1170 _addPreferredTerm(term, entry['@any'], '@none');
1171
1172 if(mapping.reverse) {
1173 // term is preferred for values using @reverse
1174 _addPreferredTerm(term, entry['@type'], '@reverse');
1175 } else if(mapping['@type'] === '@none') {
1176 _addPreferredTerm(term, entry['@any'], '@none');
1177 _addPreferredTerm(term, entry['@language'], '@none');
1178 _addPreferredTerm(term, entry['@type'], '@none');
1179 } else if('@type' in mapping) {
1180 // term is preferred for values using specific type
1181 _addPreferredTerm(term, entry['@type'], mapping['@type']);
1182 } else if('@language' in mapping && '@direction' in mapping) {
1183 // term is preferred for values using specific language and direction
1184 const language = mapping['@language'];
1185 const direction = mapping['@direction'];
1186 if(language && direction) {
1187 _addPreferredTerm(term, entry['@language'],
1188 `${language}_${direction}`.toLowerCase());
1189 } else if(language) {
1190 _addPreferredTerm(term, entry['@language'], language.toLowerCase());
1191 } else if(direction) {
1192 _addPreferredTerm(term, entry['@language'], `_${direction}`);
1193 } else {
1194 _addPreferredTerm(term, entry['@language'], '@null');
1195 }
1196 } else if('@language' in mapping) {
1197 _addPreferredTerm(term, entry['@language'],
1198 (mapping['@language'] || '@null').toLowerCase());
1199 } else if('@direction' in mapping) {
1200 if(mapping['@direction']) {
1201 _addPreferredTerm(term, entry['@language'],
1202 `_${mapping['@direction']}`);
1203 } else {
1204 _addPreferredTerm(term, entry['@language'], '@none');
1205 }
1206 } else if(defaultDirection) {
1207 _addPreferredTerm(term, entry['@language'], `_${defaultDirection}`);
1208 _addPreferredTerm(term, entry['@language'], '@none');
1209 _addPreferredTerm(term, entry['@type'], '@none');
1210 } else {
1211 // add entries for no type and no language
1212 _addPreferredTerm(term, entry['@language'], defaultLanguage);
1213 _addPreferredTerm(term, entry['@language'], '@none');
1214 _addPreferredTerm(term, entry['@type'], '@none');
1215 }
1216 }
1217 }
1218
1219 // build fast CURIE map
1220 for(const key in fastCurieMap) {
1221 _buildIriMap(fastCurieMap, key, 1);
1222 }
1223
1224 return inverse;
1225 }
1226
1227 /**
1228 * Runs a recursive algorithm to build a lookup map for quickly finding
1229 * potential CURIEs.
1230 *
1231 * @param iriMap the map to build.
1232 * @param key the current key in the map to work on.
1233 * @param idx the index into the IRI to compare.
1234 */
1235 function _buildIriMap(iriMap, key, idx) {
1236 const entries = iriMap[key];
1237 const next = iriMap[key] = {};
1238
1239 let iri;
1240 let letter;
1241 for(const entry of entries) {
1242 iri = entry.iri;
1243 if(idx >= iri.length) {
1244 letter = '';
1245 } else {
1246 letter = iri[idx];
1247 }
1248 if(letter in next) {
1249 next[letter].push(entry);
1250 } else {
1251 next[letter] = [entry];
1252 }
1253 }
1254
1255 for(const key in next) {
1256 if(key === '') {
1257 continue;
1258 }
1259 _buildIriMap(next, key, idx + 1);
1260 }
1261 }
1262
1263 /**
1264 * Adds the term for the given entry if not already added.
1265 *
1266 * @param term the term to add.
1267 * @param entry the inverse context typeOrLanguage entry to add to.
1268 * @param typeOrLanguageValue the key in the entry to add to.
1269 */
1270 function _addPreferredTerm(term, entry, typeOrLanguageValue) {
1271 if(!entry.hasOwnProperty(typeOrLanguageValue)) {
1272 entry[typeOrLanguageValue] = term;
1273 }
1274 }
1275
1276 /**
1277 * Clones an active context, creating a child active context.
1278 *
1279 * @return a clone (child) of the active context.
1280 */
1281 function _cloneActiveContext() {
1282 const child = {};
1283 child.mappings = util.clone(this.mappings);
1284 child.clone = this.clone;
1285 child.inverse = null;
1286 child.getInverse = this.getInverse;
1287 child.protected = util.clone(this.protected);
1288 if(this.previousContext) {
1289 child.previousContext = this.previousContext.clone();
1290 }
1291 child.revertToPreviousContext = this.revertToPreviousContext;
1292 if('@base' in this) {
1293 child['@base'] = this['@base'];
1294 }
1295 if('@language' in this) {
1296 child['@language'] = this['@language'];
1297 }
1298 if('@vocab' in this) {
1299 child['@vocab'] = this['@vocab'];
1300 }
1301 return child;
1302 }
1303
1304 /**
1305 * Reverts any type-scoped context in this active context to the previous
1306 * context.
1307 */
1308 function _revertToPreviousContext() {
1309 if(!this.previousContext) {
1310 return this;
1311 }
1312 return this.previousContext.clone();
1313 }
1314};
1315
1316/**
1317 * Gets the value for the given active context key and type, null if none is
1318 * set or undefined if none is set and type is '@context'.
1319 *
1320 * @param ctx the active context.
1321 * @param key the context key.
1322 * @param [type] the type of value to get (eg: '@id', '@type'), if not
1323 * specified gets the entire entry for a key, null if not found.
1324 *
1325 * @return the value, null, or undefined.
1326 */
1327api.getContextValue = (ctx, key, type) => {
1328 // invalid key
1329 if(key === null) {
1330 if(type === '@context') {
1331 return undefined;
1332 }
1333 return null;
1334 }
1335
1336 // get specific entry information
1337 if(ctx.mappings.has(key)) {
1338 const entry = ctx.mappings.get(key);
1339
1340 if(_isUndefined(type)) {
1341 // return whole entry
1342 return entry;
1343 }
1344 if(entry.hasOwnProperty(type)) {
1345 // return entry value for type
1346 return entry[type];
1347 }
1348 }
1349
1350 // get default language
1351 if(type === '@language' && type in ctx) {
1352 return ctx[type];
1353 }
1354
1355 // get default direction
1356 if(type === '@direction' && type in ctx) {
1357 return ctx[type];
1358 }
1359
1360 if(type === '@context') {
1361 return undefined;
1362 }
1363 return null;
1364};
1365
1366/**
1367 * Processing Mode check.
1368 *
1369 * @param activeCtx the current active context.
1370 * @param version the string or numeric version to check.
1371 *
1372 * @return boolean.
1373 */
1374api.processingMode = (activeCtx, version) => {
1375 if(version.toString() >= '1.1') {
1376 return !activeCtx.processingMode ||
1377 activeCtx.processingMode >= 'json-ld-' + version.toString();
1378 } else {
1379 return activeCtx.processingMode === 'json-ld-1.0';
1380 }
1381};
1382
1383/**
1384 * Returns whether or not the given value is a keyword.
1385 *
1386 * @param v the value to check.
1387 *
1388 * @return true if the value is a keyword, false if not.
1389 */
1390api.isKeyword = v => {
1391 if(!_isString(v) || v[0] !== '@') {
1392 return false;
1393 }
1394 switch(v) {
1395 case '@base':
1396 case '@container':
1397 case '@context':
1398 case '@default':
1399 case '@direction':
1400 case '@embed':
1401 case '@explicit':
1402 case '@graph':
1403 case '@id':
1404 case '@included':
1405 case '@index':
1406 case '@json':
1407 case '@language':
1408 case '@list':
1409 case '@nest':
1410 case '@none':
1411 case '@omitDefault':
1412 case '@prefix':
1413 case '@preserve':
1414 case '@protected':
1415 case '@requireAll':
1416 case '@reverse':
1417 case '@set':
1418 case '@type':
1419 case '@value':
1420 case '@version':
1421 case '@vocab':
1422 return true;
1423 }
1424 return false;
1425};
1426
1427function _deepCompare(x1, x2) {
1428 // compare `null` or primitive types directly
1429 if((!(x1 && typeof x1 === 'object')) ||
1430 (!(x2 && typeof x2 === 'object'))) {
1431 return x1 === x2;
1432 }
1433 // x1 and x2 are objects (also potentially arrays)
1434 const x1Array = Array.isArray(x1);
1435 if(x1Array !== Array.isArray(x2)) {
1436 return false;
1437 }
1438 if(x1Array) {
1439 if(x1.length !== x2.length) {
1440 return false;
1441 }
1442 for(let i = 0; i < x1.length; ++i) {
1443 if(!_deepCompare(x1[i], x2[i])) {
1444 return false;
1445 }
1446 }
1447 return true;
1448 }
1449 // x1 and x2 are non-array objects
1450 const k1s = Object.keys(x1);
1451 const k2s = Object.keys(x2);
1452 if(k1s.length !== k2s.length) {
1453 return false;
1454 }
1455 for(const k1 in x1) {
1456 let v1 = x1[k1];
1457 let v2 = x2[k1];
1458 // special case: `@container` can be in any order
1459 if(k1 === '@container') {
1460 if(Array.isArray(v1) && Array.isArray(v2)) {
1461 v1 = v1.slice().sort();
1462 v2 = v2.slice().sort();
1463 }
1464 }
1465 if(!_deepCompare(v1, v2)) {
1466 return false;
1467 }
1468 }
1469 return true;
1470}