contentfully
Version:
A simple but performant REST client for Contentful.
577 lines • 24.2 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
// imports
import { createClient } from 'contentful';
import assign from 'lodash/assign';
import compact from 'lodash/compact';
import get from 'lodash/get';
import isArray from 'lodash/isArray';
import isEmpty from 'lodash/isEmpty';
import isString from 'lodash/isString';
import isUndefined from 'lodash/isUndefined';
import keys from 'lodash/keys';
import map from 'lodash/map';
import pick from 'lodash/pick';
import { helpers } from '@contentful/rich-text-types';
import { InvalidRequestError } from './errors';
// constants
export const DEFAULT_QUERY = {
include: 10,
limit: 1000
};
export const QUERY_SELECT_ID = 'sys.id';
export const QUERY_SELECT_TYPE = 'sys.contentType';
export const QUERY_SELECT_REVISION = 'sys.revision';
export const QUERY_SELECT_CREATED_AT = 'sys.createdAt';
export const QUERY_SELECT_UPDATED_AT = 'sys.updatedAt';
export const QUERY_SELECT_FIELDS = 'fields';
export const REQUIRED_QUERY_SELECT = [
QUERY_SELECT_ID,
QUERY_SELECT_TYPE,
QUERY_SELECT_REVISION,
QUERY_SELECT_CREATED_AT,
QUERY_SELECT_UPDATED_AT
];
export class Contentfully {
constructor(params) {
// initialize instance variables
this.contentfulClient = createClient(params).withoutLinkResolution;
}
getEntry(entryId, options) {
return __awaiter(this, void 0, void 0, function* () {
var _a;
let multiLocale = false;
let locale;
let mediaTransform;
let flattenLocales = true;
// check if options is the old locale string
if (typeof options === 'string') {
console.warn("[Contentfully] locale string will not be supported in future versions, please use `{allLocales: true}` or `{locale: 'en-US'}`");
multiLocale = options === '*';
// if not multi locale then options is a specific locale
if (!multiLocale) {
locale = options;
}
}
// otherwise check if options is new object
else if (typeof options === 'object') {
multiLocale = options.allLocales === true;
mediaTransform = options.mediaTransform;
flattenLocales = (_a = options.flatten) !== null && _a !== void 0 ? _a : true; // defaults to true
// warn about `allLocales` overriding `locale` if both specified
if (options.allLocales && options.locale !== undefined) {
console.info("[Contentfully] `allLocales` overrides `locale`");
}
// ignore locale option if all locales are selected already
if (!multiLocale) {
if (options.locale === '*') {
throw new InvalidRequestError("locale='*' not supported, please use `{allLocales: true}`");
}
locale = options.locale;
}
}
// build client based on query.locale
const client = multiLocale
? this.contentfulClient.withAllLocales
: this.contentfulClient;
// fetch entry
const entry = yield client.getEntry(entryId, { locale });
// parse includes
const links = yield this._createLinks([entry], multiLocale, mediaTransform);
// split locales to top level objects
if (multiLocale && flattenLocales) {
const locales = yield this.contentfulClient.getLocales();
return this._flattenLocales(locales, [entry]);
}
else {
return this._parseEntry({}, entry, links, multiLocale);
}
});
}
getEntries() {
return __awaiter(this, arguments, void 0, function* (query = {}, options = {}) {
// determine if using multiple locales
let multiLocale = options.allLocales === true;
// warn about `allLocales` overriding `locale` if both specified
if (options.allLocales && query.locale !== undefined) {
console.info("[Contentfully] `options.allLocales` overrides `query.locales`");
}
// check if the old way of setting all locales is specified
if (!multiLocale && query.locale === '*') {
multiLocale = true;
console.warn("[Contentfully] locale='*' will not be supported in future versions, please pass `{allLocales: true}` into options");
}
// build client based on query.locale
const client = multiLocale
? this.contentfulClient.withAllLocales
: this.contentfulClient;
// remove locale from query if multiple locales is specified,
// Contentful client throws error if locale='*' query option is passed in
const cleanedQuery = Object.assign({}, query);
if (multiLocale) {
delete cleanedQuery.locale;
}
// create query
const entries = yield client.getEntries(Contentfully.createQuery(cleanedQuery));
// parse includes
const links = yield this._createLinks(entries, multiLocale, options.mediaTransform);
// parse core entries
let items = this._parseEntries(entries.items, links, multiLocale);
// split locales to top level objects
if (multiLocale && (options.flatten === undefined) || options.flatten === true) {
const locales = yield this.contentfulClient.getLocales();
items = this._flattenLocales(locales, items);
}
// return result
return {
items: items,
skip: entries.skip,
limit: entries.limit,
total: entries.total
};
});
}
_parseAssetByLocale(entry) {
// initialize locale map of entries
const locales = {};
for (const [key, field] of Object.entries(entry.fields)) {
// pull all locales from field
const fieldLocales = keys(field);
for (const locale of fieldLocales) {
// initialize locale (if undefined) with sys and fields
if (!locales[locale]) {
locales[locale] = {
sys: entry.sys,
fields: {}
};
}
// set field
locales[locale].fields[key] = field[locale];
}
}
return locales;
}
_createLinks(json, multiLocale, mediaTransform) {
return __awaiter(this, void 0, void 0, function* () {
// create new links
const links = {};
// link included assets
const assets = get(json, 'includes.Asset') || [];
// console.debug(`parsing ${assets.length} assets`)
for (const asset of assets) {
// TODO: handle non-image assets (e.g. video)
let media = {};
const sys = asset.sys;
// map asset to locale
if (multiLocale) {
const locales = this._parseAssetByLocale(asset);
for (const [locale, entry] of Object.entries(locales)) {
try {
if (entry.fields.file) {
// transform asset to media
const transformed = yield this._toMedia(sys, entry.fields, mediaTransform);
// prune id
delete transformed._id;
// map locale data
media[locale] = transformed;
}
}
catch (e) {
console.error('[_createLinks] error with creating media', e);
}
}
}
else {
media = yield this._toMedia(sys, asset.fields, mediaTransform);
}
// map media
links[sys.id] = media;
}
// link included entries
const linkedEntries = get(json, 'includes.Entry') || [];
// console.debug(`parsing ${linkedEntries.length} linked entries`)
for (const entry of linkedEntries) {
links[entry.sys.id] = {
_deferred: entry
};
}
// link payload entries
const mainEntries = get(json, 'items') || [];
// console.debug(`parsing ${mainEntries.length} main entries`)
for (const entry of mainEntries) {
links[entry.sys.id] = {
_deferred: entry
};
}
// return links
return links;
});
}
_toMedia(sys, fields, mediaTransform) {
return __awaiter(this, void 0, void 0, function* () {
// capture media file
const description = fields.description;
const title = fields.title;
let url, contentType, size;
let dimensions = { height: 0, width: 0 };
// Account for possibility of missing file, if user removes file from media
if (fields.file) {
url = fields.file.url;
contentType = fields.file.contentType;
if (fields.file.details) {
dimensions = pick(fields.file.details.image, ['width', 'height']);
size = fields.file.details.size;
}
}
let media = {
_id: sys.id,
url,
title: title,
description: description,
contentType,
dimensions,
size,
version: sys.revision
};
// apply any transform (if provided)
if (mediaTransform) {
media = yield mediaTransform(media);
}
return media;
});
}
_parseEntries(entries, links, multiLocale) {
// convert entries to models and return result
return map(entries, entry => {
// fetch model (avoids duplicate clones)
const sys = entry.sys;
const model = links[sys.id];
// process entry if not yet transformed
if (model._deferred) {
// return model if in progress
if (model._model) {
return model._model;
}
// create in progress model
model._model = {};
// update entry with parsed value
assign(model, this._parseEntry(model._model, model._deferred, links, multiLocale));
// prune deferral
delete model._model;
delete model._deferred;
}
// return model
return model;
});
}
_parseEntry(model, entry, links, multiLocale) {
// bind metadata to model
this._bindMetadata(entry, model);
// console.debug('parsing entry: ', model._id)
if (!entry.fields) {
return model;
}
// transform entry fields to model
for (const [key, value] of Object.entries(entry.fields)) {
// parse values if multi-locale query
if (multiLocale) {
// parse value (mapped by locale)
const parsedLocale = this._parseValueByLocale(value, links);
// FIXME: is just dropping this value ok? what about a fallback?
// bind if value is localized (otherwise drop field)
if (!isUndefined(parsedLocale)) {
model[key] = parsedLocale;
}
}
// parse array of values
else if (isArray(value)) {
model[key] = compact(map(value, item => this._parseValue(item, links)));
}
// or parse value
else {
// parse value
const parsed = this._parseValue(value, links);
// bind if value could be parsed, drop field otherwise
if (!isUndefined(parsed)) {
model[key] = parsed;
}
}
}
// return parsed model
return model;
}
_bindMetadata(entry, model) {
// bind metadata to model
const sys = entry.sys;
model._id = sys.id;
model._metadata = {
type: sys.contentType.sys.id,
revision: sys.revision,
createdAt: sys.createdAt ? new Date(sys.createdAt).getTime() : 0,
updatedAt: sys.updatedAt ? new Date(sys.updatedAt).getTime() : 0
};
}
_parseValueByLocale(value, links) {
let values = {};
// pull all locales
const locales = keys(value);
for (const locale of locales) {
// parse array of value
if (isArray(value[locale])) {
values[locale] = compact(map(value[locale], item => this._parseValue(item, links, locale)));
}
// or parse value
else {
const sys = value[locale].sys;
if (sys === undefined || sys.type !== 'Link') {
values[locale] = value[locale];
}
// assign asset to values (already mapped by locale)
else if (sys.linkType === 'Asset') {
values = this._dereferenceLink(value, links, locale);
}
else {
values[locale] = this._dereferenceLink(value, links, locale);
}
}
}
return values;
}
_parseValue(value, links, locale) {
// resolve rich text identifier
const { nodeType } = value;
// handle rich text value
if (nodeType && nodeType === 'document') {
return this._parseRichTextValue(value, links, locale);
}
// handle values without a link
const sys = value.sys;
if (sys === undefined || sys.type !== 'Link') {
return value;
}
// dereference link
return this._dereferenceLink(value, links, locale);
}
_parseRichTextValue(value, links, locale) {
// resolve content list
const { content } = value;
// skip parsing if no content
if (!content.length) {
return undefined;
}
return this._parseRichTextContent(content, links, locale);
}
_parseRichTextContent(items, links, locale) {
// convert content items, recursively linking children
return items.map(item => {
var _a;
const { nodeType, data } = item;
// create baseline rich text
const richText = {
nodeType
};
// bind text attributes
if (helpers.isText(item)) {
richText.value = item.value;
richText.marks = item.marks.map(mark => mark.type);
}
// bind basic URL
if (data === null || data === void 0 ? void 0 : data.uri) {
richText.data = { uri: data.uri };
}
// bind entity/assets (if any)
else if ((_a = data === null || data === void 0 ? void 0 : data.target) === null || _a === void 0 ? void 0 : _a.sys.linkType) {
richText.data = this._dereferenceLink(data.target, links, locale);
}
// recursively bind content (if any)
if (helpers.isBlock(item) || helpers.isInline(item)) {
richText.content = this._parseRichTextContent(item.content, links, locale);
}
// return rich text
return richText;
});
}
_dereferenceLink(reference, links, locale) {
// resolve entry sys and id
const sys = locale && reference[locale]
? reference[locale].sys
: reference.sys;
const modelId = sys.id;
// get link (or bail if it isn't mapped)
let link = links[modelId];
if (!link) {
return;
}
// resolve link if not processed
if (link._deferred) {
// return model if in progress
if (link._model) {
return link._model;
}
// create in progress model
link._model = {};
// parse and update link
assign(link, this._parseEntry(link._model, link._deferred, links, !isUndefined(locale)));
// prune deferral
delete link._model;
delete link._deferred;
}
// return link
return link;
}
_getLocaleValue(defaultLocale, localeCodes, locale, value) {
let currentLocale = locale;
while (currentLocale != undefined) {
if (value[currentLocale.code] !== undefined) {
return value[currentLocale.code];
}
if (currentLocale.fallbackCode === null) {
return value;
}
if (currentLocale == defaultLocale) {
return value;
}
if (currentLocale.fallbackCode === undefined) {
currentLocale = defaultLocale;
}
else {
currentLocale = localeCodes[currentLocale.fallbackCode];
}
}
return value;
}
_flattenLocales(localesResult, items) {
// this does not handle circular references well
// TODO handle fallback codes
// get needed values from locales result
const locales = localesResult.items;
const localeCodes = locales.map((locale) => locale.code);
const localeCodeMap = locales.reduce((acc, locale) => {
acc[locale.code] = locale;
return acc;
}, {});
const defaultLocaleObj = locales.find(locale => locale.default !== undefined && locale.default);
// create the object that will hold all the items for each locale
const localeItems = {};
// iterate each locale
for (let locale of localeCodes) {
// the box that will hold the properties for this locale
const localeContext = [];
localeItems[locale] = localeContext;
// for each item itteratively walk the tree of its properties
for (let rawItem of items) {
const itemContext = {};
localeContext.push(itemContext);
const queue = [];
queue.push({
context: itemContext,
item: rawItem,
depth: 0
});
while (queue.length > 0) {
// pull and destruct the current node and exit early is undefined
const current = queue.shift();
if (current == undefined) {
break;
}
const { context, item, depth } = current;
// iterate each key and value on the node item
for (let [key, valueObj] of Object.entries(item)) {
// find the locale value or fallback to default or use the value of the prop
let value = valueObj;
if (isUndefined(value) || isEmpty(value)) {
continue;
}
value = this._getLocaleValue(defaultLocaleObj, localeCodeMap, localeCodeMap[locale], value);
// handle primitives
if (typeof value !== 'object') {
context[key] = value;
continue;
}
// handle Objects
if (Array.isArray(value) === false) {
if (isUndefined(value) || isEmpty(value['_id'])) {
// this isn't a contentful object, it's likely some sort of nested raw json
context[key] = value;
continue;
}
const itemContext = {};
context[key] = itemContext;
queue.push({
context: itemContext,
item: value,
depth: depth + 1
});
continue;
}
// handle Arrays
const itemContext = [];
context[key] = itemContext;
// iterate each item in the array and handle them
for (let index in value) {
// handle primitives
if (typeof value[index] !== 'object') {
itemContext[index] = value[index];
continue;
}
// explicitly handle nested arrays
// they must have come from outsite of a content model
// so leave them raw
if (Array.isArray(value[index])) {
itemContext[index] = value[index];
continue;
}
// handle objects
itemContext[index] = {};
queue.push({
context: itemContext[index],
item: value[index],
depth: depth + 1
});
}
}
}
}
}
return localeItems;
}
static createQuery(query) {
// create default select (if required)
let select;
if (!query.select) {
select = [...REQUIRED_QUERY_SELECT, QUERY_SELECT_FIELDS];
}
// or merge user select into required query
else {
// use user array if provided
if (isArray(query.select)) {
select = query.select;
}
// or convert user string to array
else if (isString(query.select)) {
select = query.select.split(',');
}
// TODO: this should throw in the next major release
// otherwise ignore + fallback
else {
console.warn('[Contentfully] invalid query.select value: ', query.select);
select = [...REQUIRED_QUERY_SELECT, QUERY_SELECT_FIELDS];
}
// normalize + merge using a set
select = Array.from(new Set([
...select.map(value => value.trim()),
...REQUIRED_QUERY_SELECT
]));
}
// create normalized clone of user query
return assign({}, DEFAULT_QUERY, query, { select });
}
}
//# sourceMappingURL=Contentfully.js.map