UNPKG

contentfully

Version:

A simple but performant REST client for Contentful.

577 lines 24.2 kB
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