UNPKG

@adobe/helix-pipeline

Version:

(formerly known as Hypermedia Pipeline)

205 lines (183 loc) 6.32 kB
/* * Copyright 2020 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ const { selectAll } = require('unist-util-select'); const remove = require('unist-util-remove'); const visit = require('unist-util-visit'); const { deepclone, trySlidingWindow, map, list, reject, contains, is, pipe, setdefault, } = require('ferrum'); const removePosition = require('unist-util-remove-position'); const dotprop = require('dot-prop'); const { merge } = require('../utils/cache-helper'); const pattern = /{{([^{}]+)}}/g; /** * Copied from 'unist-util-map' and promisified. * @param tree * @param iteratee * @returns {Promise<any>} */ async function pmap(tree, iteratee) { async function preorder(node) { async function bound(child) { return preorder(child); } const { children } = node; const newNode = { ...await iteratee(node) }; if (children) { newNode.children = await Promise.all(children.map(bound)); } return newNode; } return preorder(tree); } /** * Finds all MDAST nodes that have a placeholder value and calls * a user-provided callback function. * @param {MDAST} section an MDAST node * @param {*} handlefn a callback function to handle the placeholder */ function findPlaceholders(section, handlefn) { visit(section, (node) => { if (node.value && pattern.test(node.value)) { handlefn(node, 'value'); } if (node.alt && pattern.test(node.alt)) { handlefn(node, 'alt'); } if (node.url && pattern.test(node.url)) { handlefn(node, 'url'); } if (node.title && pattern.test(node.title)) { handlefn(node, 'title'); } }); } /** * Determines if an MDAST node contains placeholders like `{{foo}}` * @param {MDAST} section */ function hasPlaceholders(section) { try { findPlaceholders(section, () => { throw new Error('Placeholder detected'); }); return false; } catch { return true; } } /** * @param {MDAST} section */ function fillPlaceholders(section) { if (!section.meta || (!section.meta.embedData && !Array.isArray(section.meta.embedData))) { return; } const data = section.meta.embedData; // required to make deepclone below work removePosition(section); const children = data.reduce((p, value) => { const workingcopy = deepclone(section); findPlaceholders(workingcopy, (node, prop) => { if (typeof node[prop] === 'string') { node[prop] = node[prop].replace(pattern, (_, expr) => dotprop.get(value, expr)); } }); return [...p, ...workingcopy.children]; }, []); section.children = children; delete section.meta.embedData; } function normalizeLists(section) { function cleanupLists([first, second]) { // two consecutive, identical lists if (first.type === 'list' && second.type === 'list' && first.ordered === second.ordered && first.start === second.start && first.spread === second.spread) { // move the children of the first to the second list second.children = [...first.children, ...second.children]; first.children = []; // mark the first list to be emptied return first; } return null; } // get the sequence of lists to be removed const emptiedLists = pipe( trySlidingWindow(section.children, 2), map(cleanupLists), reject(is(null)), list, ); // perform the cleanup section.children = pipe( section.children, // take all existing children reject((child) => contains(is(child))(emptiedLists)), // reject if they are in the list above list, // make a nice array because the rest of the world doesn't like iterators yet ); } async function fillDataSections(context, { downloader, logger }) { const { content: { mdast } } = context; async function extractData(section) { return pmap(section, async (node) => { if (node.type === 'dataEmbed') { const task = downloader.getTaskById(`dataEmbed:${node.url}`); const downloadeddata = await task; if (downloadeddata.status !== 200) { logger.warn(`Bad status code (${downloadeddata.status}) for data embed ${node.url}`); return node; } try { const json = JSON.parse(downloadeddata.body); if (!Array.isArray(json)) { logger.warn(`Expected array for data embed ${node.url}, got ${typeof json}`); return node; } section.meta.embedData = json; // remember that we are using this source so that we can compute the // surrogate key later setdefault(context.content, 'sources', []); context.content.sources.push(node.url); // pass the cache control header through const res = setdefault(context, 'response', {}); const headers = setdefault(res, 'headers', {}); headers['Cache-Control'] = merge( headers['Cache-Control'], downloadeddata.headers.get('cache-control'), ); } catch (e) { logger.warn(`Unable to parse JSON for data embed ${node.url}: ${e.message}`); return node; } } return node; }); } async function applyDataSections(section) { await extractData(section); remove(section, 'dataEmbed'); fillPlaceholders(section); normalizeLists(section); } const dataSections = selectAll('section', mdast); // extract data from all sections await Promise.all(dataSections .filter(hasPlaceholders) .map(applyDataSections)); if (dataSections.length === 0 && hasPlaceholders(mdast)) { // extract data from the root node (in case there are no sections) await applyDataSections(mdast); } remove(mdast, 'dataEmbed'); } module.exports = fillDataSections;