UNPKG

jamify-source-ghost

Version:

Gatsby source plugin for fetching content from headless Ghost CMS.

385 lines (310 loc) 14.7 kB
const ContentAPI = require(`./content-api`) const _ = require(`lodash`) const cheerio = require(`cheerio`) const PLUGIN = `jamify-source-ghost` // Simple logging const useLog = (reporter, verbose, severity) => (message) => { verbose && reporter[severity](message) } const { GhostNodes, GhostTypes, PrefixedGhostTypes, generateNodeId, } = require(`./ghost-nodes`) const asyncFilter = async (arr, predicate) => Promise.all(arr.map(predicate)) .then(results => arr.filter((_v, index) => results[index])) const normalizedContentDigest = (node, createContentDigest) => { // eslint-disable-next-line camelcase const { id, updated_at, tags, authors } = node //const normalize = arr => arr.map(element => element.id) return createContentDigest(JSON.stringify({ id, updated_at, tags: tags, authors: authors })) } const parseCodeinjection = (html) => { let $ = null try { $ = cheerio.load(html, { decodeEntities: false }) } catch (e) { return {} } /* eslint-disable ghost/ember/no-global-jquery */ const $parsedStyles = $(`style`) const codeInjObj = {} $parsedStyles.each((i, style) => { if (i === 0) { codeInjObj.styles = $(style).html() } else { codeInjObj.styles += $(style).html() } }) /* eslint-enable ghost/ember/no-global-jquery */ return codeInjObj } const transformCodeinjection = (posts) => { posts.map((post) => { const allCodeinjections = [post.codeinjection_head, post.codeinjection_foot].join(``) if (!allCodeinjections) { return post } const headInjection = parseCodeinjection(allCodeinjections) if (_.isEmpty(post.codeinjection_styles)) { post.codeinjection_styles = headInjection.styles } else { post.codeinjection_styles += headInjection.styles } post.codeinjection_styles = _.isNil(post.codeinjection_styles) ? `` : post.codeinjection_styles return post }) return posts } // touch nodes to ensure they aren't garbage collected const touchNodes = (types, getNodesByType, touchNode) => { const existingPosts = getNodesByType(PrefixedGhostTypes.post) const existingPages = getNodesByType(PrefixedGhostTypes.page) const existingTags = getNodesByType(PrefixedGhostTypes.tag) const existingAuthors = getNodesByType(PrefixedGhostTypes.author) const existingSettings = getNodesByType(PrefixedGhostTypes.settings) existingPosts.forEach(node => touchNode({ nodeId: node.id })) existingPages.forEach(node => touchNode({ nodeId: node.id })) existingTags.forEach(node => touchNode({ nodeId: node.id })) existingAuthors.forEach(node => touchNode({ nodeId: node.id })) existingSettings.forEach(node => touchNode({ nodeId: node.id })) return ({ posts: existingPosts, pages: existingPages, tags: existingTags, authors: existingAuthors, }) } const prefetchTagAndAuthorNodes = (existingNodes, sourceNodeFields, settings) => { const { api, log } = settings // Fetch full data for tag an authors here, // so we do not need to fetch a second time const tagAndAuthorFetchOptions = { limit: `all`, include: `count.posts`, } const removeTags = api.tags.browse(tagAndAuthorFetchOptions).then((tags) => { log(`Fetched Tags: ${tags.length}`) const type = GhostTypes.tag const typeLower = type.toLowerCase() removeNode(type, existingNodes.tags, tags, sourceNodeFields, settings) updateTagAndAuthorNode(typeLower, tags, sourceNodeFields, settings) }) const removeAuthors = api.authors.browse(tagAndAuthorFetchOptions).then((authors) => { log(`Fetched Authors: ${authors.length}`) const type = GhostTypes.author const typeLower = type.toLowerCase() removeNode(type, existingNodes.authors, authors, sourceNodeFields, settings) updateTagAndAuthorNode(typeLower, authors, sourceNodeFields, settings) }) return [removeTags, removeAuthors] } const prefetchPostAndPageNodes = (existingNodes, sourceNodeFields, settings) => { const { triggerTime } = sourceNodeFields const { api, log } = settings // Working around Ghost bugs // Need to include tags and authors, because page/post timestamps are not updated on changing tags/authors const prefetchOptions = { limit: `all`, include: `tags,authors`, // Another Ghost API glitch // url must be included as main field, otherwise url is missing in authors and tags! fields: `id,updated_at,url`, } const lastUpdated = new Date(triggerTime) const removeOrUpdatePosts = api.posts.browse(prefetchOptions).then(async (posts) => { log(`Prefetched Posts: ${posts.length}`) const type = GhostTypes.post const typeLower = type.toLowerCase() removeNode(GhostTypes.post, existingNodes.posts, posts, sourceNodeFields, settings) const checkOldPosts = posts.filter(post => lastUpdated >= new Date(post.updated_at)) const updateNodes = await getNodesForUpdate(typeLower, checkOldPosts, sourceNodeFields, settings) // Update old nodes with changes in tag or author if (updateNodes.length > 0) { log(`Found old ${typeLower}s with changes: ${updateNodes.length}`) await updatePostAndPageNode(typeLower, updateNodes, sourceNodeFields, settings, transformCodeinjection) } }) const removeOrUpdatePages = api.pages.browse(prefetchOptions).then(async (pages) => { log(`Prefetched Pages: ${pages.length}`) const type = GhostTypes.page const typeLower = type.toLowerCase() removeNode(GhostTypes.page, existingNodes.pages, pages, sourceNodeFields, settings) const checkOldPages = pages.filter(page => lastUpdated >= new Date(page.updated_at)) const updateNodes = await getNodesForUpdate(typeLower, checkOldPages, sourceNodeFields, settings) // Update old nodes with changes in tag or author if (updateNodes.length > 0) { log(`Found old ${typeLower}s with changes: ${updateNodes.length}`) await updatePostAndPageNode(type.toLowerCase(), updateNodes, sourceNodeFields, settings) } }) /** * Settings are always present, no need to remove them * Only handle updates * */ return [removeOrUpdatePosts, removeOrUpdatePages] } const removeNode = (type, nodes, remoteNodes, sourceNodeFields, settings) => { const { actions, getNode } = sourceNodeFields const { log } = settings nodes .filter(node => remoteNodes.findIndex(remote => node.id === generateNodeId(type, remote.id)) === -1) .forEach((node) => { actions.deleteNode({ node: getNode(node.id) }) log(`Removed node with id: ${node.id}`) }) } const updateTagAndAuthorNode = async (type, nodes, sourceNodeFields, settings) => { const { actions, cache, createContentDigest } = sourceNodeFields const { createNode } = actions const { log, cacheResponse } = settings await nodes.forEach(async (node) => { node.postCount = node.count.posts const newDigest = createContentDigest(JSON.stringify(node)) const existingDigest = cacheResponse && await cache.get(`${PLUGIN}-${type}-${node.id}`) if (existingDigest !== newDigest) { createNode(GhostNodes[type](node)) await cache.set(`${PLUGIN}-${type}-${node.id}`, newDigest) log(`Updated ${type} ${node.slug}`) } else { log(`Preserved ${type} ${node.slug}`) } }) } const getNodesForUpdate = async (type, nodes, sourceNodeFields, settings) => { const { cache, createContentDigest } = sourceNodeFields const { cacheResponse } = settings // always update nodes, if cacheResponse is disabled if (cacheResponse === false) { return nodes } return asyncFilter(nodes, async (node) => { const newDigest = normalizedContentDigest(node, createContentDigest) const existingDigest = await cache.get(`${PLUGIN}-${type}-${node.id}`) return existingDigest !== newDigest }) } const updatePostAndPageNode = async (type, updateNodes, sourceNodeFields, settings, transform = n => n) => { const { actions, cache, createContentDigest } = sourceNodeFields const { createNode } = actions const { api, log } = settings const fetchOptions = { limit: `all`, include: `tags,authors`, filter: `id:[${updateNodes.map(node => node.id)}]`, } // fetch complete data now const updateRemote = await api[`${type}s`].browse(fetchOptions) log(`${type}s update due to tag/author change: ${updateRemote.length}`) // only needed for post type const transformed = transform(updateRemote) transformed.forEach(async (node) => { createNode(GhostNodes[type](node)) const newDigest = normalizedContentDigest(node, createContentDigest) await cache.set(`${PLUGIN}-${type}-${node.id}`, newDigest) log(`${type}: ${node.slug}, updated_at: ${node.updated_at}`) }) } /** * Create Ghost Nodes * Uses the Ghost Content API to fetch all posts, pages, tags, authors and settings * Creates nodes for each record, so that they are all available to Gatsby */ const createGhostNodes = async (sourceNodeFields , configOptions) => { const { triggerTime, actions, reporter, cache, createContentDigest, getNodesByType } = sourceNodeFields const { createNode, touchNode } = actions const { ghostConfig, verbose = false, severity = `info`, cacheResponse = true } = configOptions const api = ContentAPI.configure(ghostConfig) const log = useLog(reporter, verbose, severity) const settings = { api, log , cacheResponse } log(`Last updated: ${triggerTime}`) // Step 1: Keep all existing nodes const existingNodes = touchNodes(PrefixedGhostTypes, getNodesByType, touchNode) // Step 2: Remove vanished tags and authors // Update if content digest changes const removeOrUpdateTagAndAuthor = prefetchTagAndAuthorNodes(existingNodes, sourceNodeFields, settings) // Step 3: Remove vanished posts and pages // Check old nodes only, if content digest changes for tags and authors: update const removeOrUpdatePostAndPage = prefetchPostAndPageNodes(existingNodes, sourceNodeFields, settings) // Step 4: Fetch new and updated posts and pages based on timestamp const postAndPageFetchOptions = { limit: `all`, // Room for improvement: Do not fetch tags and authors with every post // Rather establish a link to previously fetched tags and authors include: `tags,authors`, formats: `html,plaintext`, filter: `updated_at:>${triggerTime}`, } const fetchPosts = api.posts.browse(postAndPageFetchOptions).then((posts) => { log(`Fetched Posts: ${posts.length}`) posts = transformCodeinjection(posts) posts.forEach(async (post) => { log(`Post: ${post.slug}, updated_at: ${post.updated_at}`) createNode(GhostNodes.post(post)) const newDigest = normalizedContentDigest(post, createContentDigest) await cache.set(`${PLUGIN}-post-${post.id}`, newDigest) }) }) const fetchPages = api.pages.browse(postAndPageFetchOptions).then((pages) => { log(`Fetched Pages: ${pages.length}`) pages.forEach(async (page) => { log(`Page: ${page.slug}, updated_at: ${page.updated_at}`) createNode(GhostNodes.page(page)) const newDigest = normalizedContentDigest(page, createContentDigest) await cache.set(`${PLUGIN}-page-${page.id}`, newDigest) }) }) // Step 5: Always fetch settings (no updated timestamp available) // Only update as it is never deleted const fetchSettings = api.settings.browse().then(async (setting) => { log(`Fetched Settings`) const rawSettings = setting const newDigest = createContentDigest(JSON.stringify(rawSettings)) const existingDigest = cacheResponse && await cache.get(`${PLUGIN}-settings`) if (existingDigest !== newDigest) { log(`Settings are being updated`) const codeinjectionHead = setting.codeinjection_head || setting.ghost_head const codeinjectionFoot = setting.codeinjection_foot || setting.ghost_foot const allCodeinjections = codeinjectionHead ? codeinjectionHead.concat(codeinjectionFoot) : codeinjectionFoot ? codeinjectionFoot : null if (allCodeinjections) { const parsedCodeinjections = parseCodeinjection(allCodeinjections) if (_.isEmpty(setting.codeinjection_styles)) { setting.codeinjection_styles = parsedCodeinjections.styles } else { setting.codeinjection_styles += parsedCodeinjections.styles } } setting.codeinjection_styles = _.isNil(setting.codeinjection_styles) ? `` : setting.codeinjection_styles // The settings object doesn't have an id, prevent Gatsby from getting 'undefined' setting.id = 1 // Remove trailing slashes setting.url = setting.url.replace(/\/$/, ``) createNode(GhostNodes.settings(setting)) await cache.set(`${PLUGIN}-settings`, newDigest) } else { log(`Settings node has not changed`) } }) // Now we have a bunch of independent promises // Set timestamp after completion return Promise.all([...removeOrUpdateTagAndAuthor, ...removeOrUpdatePostAndPage, fetchPosts, fetchPages, fetchSettings]).then(async () => { const now = new Date().toISOString() await cache.set(`${PLUGIN}-timestamp`, now) log(`Timestamp updated to ${now}`) }) } // Standard way to create nodes exports.sourceNodes = async (sourceNodeFields, configOptions) => { const { cacheResponse = true } = configOptions const startTime = new Date(0).toISOString() const lastFetched = cacheResponse && await sourceNodeFields.cache.get(`${PLUGIN}-timestamp`) sourceNodeFields.triggerTime = lastFetched || startTime return createGhostNodes(sourceNodeFields, configOptions) } // Explicitely typed schema exports.createSchemaCustomization = require(`./create-schema-customization`) // Resolvers for linking tags/authors to posts/pages exports.createResolvers = require(`./create-resolvers`)