UNPKG

@adobe/helix-pipeline

Version:

This project provides helper functions and default implementations for creating Hypermedia Processing Pipelines.

303 lines (265 loc) 9.93 kB
/* * Copyright 2021 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. */ import { expand } from '@emmetio/expand-abbreviation'; import { setdefault } from 'ferrum'; import { fromDom } from 'hast-util-from-dom'; import { JSDOM } from 'jsdom'; import { match as matchUrlBuilder } from 'path-to-regexp'; import { MarkupConfig } from '@adobe/helix-shared-config'; import { visit } from 'unist-util-visit'; import { getProperty as get } from 'dot-prop'; import { match } from '../utils/pattern-compiler.js'; import section from '../utils/section-handler.js'; import VDOMTransformer from '../utils/mdast-to-vdom.js'; /** Placeholder variable for the generate template. */ const PLACEHOLDER_TEMPLATE = /\$\{\d+\}/g; function findAndReplace(root, insert) { visit(root, (node) => node.type === 'text' && node.value && node.value.match(PLACEHOLDER_TEMPLATE), (_, index, parent) => { const newels = Array.isArray(insert) ? insert : [insert]; parent.children.splice(index, 1, ...newels); }); return root; } async function getMarkupConfig(context, action) { setdefault(context, 'content', {}); const { logger, downloader } = action; const markupConfigTask = downloader.getTaskById('markupconfig'); if (!markupConfigTask) { logger.info('unable to adjust markup. no markup config task scheduled.'); return; } const res = await markupConfigTask; if (res.status !== 200) { logger.info(`unable to fetch helix-markup.yaml: ${res.status}`); return; } // remember markupconfig as source setdefault(context.content, 'sources', []).push(markupConfigTask.uri); // Expose markupconfig on the action const cfg = await new MarkupConfig() .withSource(res.body) .init(); setdefault(action, 'markupconfig', cfg.toJSON()); } /** * Checks whether the given mdast node is a section. * * @param {MDAST} node the mdast node to check * @returns {boolean} `true` if the node is a section, `false` otherwise */ function isSection(node) { return node.type === 'root' || node.type === 'section'; } function populate(template, data) { const mod = template.replace(/\${([A-Za-z0-9.]+)}/g, (_, prop) => get(data, prop)); return mod; } /** * Returns the HTML element for the provided HTML template. * * @param {String} template The HTML template to use * * @returns {HTMLElement} the resulting HTML element including a `${0}` placeholder */ function getHTMLElement(template, data) { const html = expand(populate(template, data), { field: (index, placeholder) => { const p = placeholder ? `:${placeholder}` : ''; return `\${${index}${p}}`; }, }); const doc = new JSDOM().window.document; const dom = doc.createElement('div'); dom.innerHTML = html; return dom.firstChild; } /** * Patches the specified VDOM element. * * @param {VDOM} el The VDOM element to patch * @param {*} cfg The configuration options * @returns {VDOM} the new patched element */ function patchVDOMNode(el, cfg, data) { setdefault(el, 'properties', {}); // Append classes to the element (space or comma separated) if (cfg.classnames) { el.properties.className = [ ...(el.properties.className || '').split(' '), ...cfg.classnames, ].join(' ').trim(); } // Append attributes to the element if (cfg.attribute) { Object.assign(el.properties, cfg.attribute); } // Wrap the element if (cfg.wrap) { const wrapperEl = getHTMLElement(cfg.wrap, data); const n = fromDom(wrapperEl); el = findAndReplace(n, el); } // Replace the element if (cfg.replace) { const wrapperEl = getHTMLElement(cfg.replace, data); const n = fromDom(wrapperEl); el = findAndReplace(n, { type: 'text', value: '' }); } return el; } function patchVDOMNodes(els, cfg, data) { if (!Array.isArray(els)) { return patchVDOMNode(els, cfg, data); } const copycfg = { ...cfg }; delete copycfg.wrap; const patched = els.map((el) => patchVDOMNode(el, copycfg, data)); // Wrap the element if (cfg.wrap) { const wrapperEl = getHTMLElement(cfg.wrap, data); const n = fromDom(wrapperEl); return findAndReplace(n, patched); } return patched; } /** * Patches the specified html element. * * @param {HTMLElement} el The html element to patch * @param {*} cfg The configuration options */ function patchHtmlElement(el, cfg) { // Append classes to the element (space or comma separated) if (cfg.classnames) { el.classList.add(...cfg.classnames); } // Append attributes to the element if (cfg.attribute) { Object.entries(cfg.attribute).forEach((e) => el.setAttribute(e[0], e[1])); } // Wrap the element if (cfg.wrap) { const wrapperEl = getHTMLElement(cfg.wrap, el); // if it is a regular element if (el.nodeName !== 'BODY') { wrapperEl.innerHTML = wrapperEl.innerHTML.replace(PLACEHOLDER_TEMPLATE, el.outerHTML); el.replaceWith(wrapperEl); } else { // ... but just merge the properties on the BODY [...wrapperEl.attributes].forEach((attr) => { el.parentNode.setAttribute(attr.name, attr.value); }); } } // Replace the element if (cfg.replace) { const wrapperEl = getHTMLElement(cfg.replace, el); wrapperEl.innerHTML = wrapperEl.innerHTML.replace(PLACEHOLDER_TEMPLATE, ''); el.replaceWith(wrapperEl); } } /** * Adjust the MDAST conversion according to the markup config. * This is done by registering new matchers on the VDOMTransformer before the VDOM * is generated in the pipeline * * @param {Object} context the execution context * @param {Object} logger the pipeline logger * @param {Object} transformer the VDOM transformer * @param {Object} markupconfig the markup config */ export async function adjustMDAST(context, action) { // Since this is called first in the pipeline, we get the markup config here await getMarkupConfig(context, action); const { logger, transformer, markupconfig } = action; if (!markupconfig || !markupconfig.markup) { return; } // Adjust the MDAST conversion based on markdown config Object.entries(markupconfig.markup) .filter(([_, cfg]) => cfg.type === 'markdown') .forEach(([name, cfg]) => { logger.info(`Applying markdown markup adjustment: ${name}`); transformer.match(cfg.match, function myhandler(h, node, parent) { const [fallbackhandler] = transformer .allmatches(node) .filter((theirhandler) => theirhandler !== myhandler); // Generate the matching VDOM element /** * A function that enables the recursive processing of MDAST child nodes * in handler functions. * @param {function} callback the HAST-constructing callback function * @param {Node} childnode the MDAST child node that should be handled * @param {Node} mdastparent the MDAST parent node, usually the current MDAST node * processed by the handler function * @param {*} hastparent the HAST parent node that the transformed child will be appended to */ function handlechild(callback, childnode, mdastparent, hastparent) { if (hastparent && hastparent.children) { hastparent.children.push(VDOMTransformer.handle( callback, childnode, mdastparent, transformer, )); } } const el = fallbackhandler(h, node, parent, handlechild); return patchVDOMNodes(el, cfg, node); }); }); // Adjust the MDAST conversion based on content config Object.entries(markupconfig.markup) .filter(([_, cfg]) => cfg.type === 'content') .forEach(([name, cfg]) => { logger.info(`Applying content intelligence adjustment: ${name}`); transformer.match((node) => { const childtypes = node.children ? node.children.map((n) => n.type).filter((type) => !!type) : []; return isSection(node) && match(childtypes, cfg.match); }, (h, node) => { // Generate the matching VDOM element const sectionHandler = section(); const el = sectionHandler(h, node); return patchVDOMNode(el, cfg, node); }); }); } /** * Adjust the DOM tree according to the markup config. * This is done by directly manipulating the DOM after it has been generated by the pipeline. * * @param {Object} context the execution context * @param {Object} logger the pipeline logger * @param {Object} markupconfig the markup config */ export async function adjustHTML(context, { logger, markupconfig }) { if (!markupconfig || !markupconfig.markup) { return; } Object.entries(markupconfig.markup) .filter(([_, cfg]) => !cfg.type || cfg.type === 'url') .forEach(([name, cfg]) => { logger.info(`Applying URL markup adjustment: ${name}`); const matchUrl = matchUrlBuilder(cfg.match, { decode: decodeURIComponent }); if (matchUrl(context.request.path)) { patchHtmlElement(context.content.document.body, cfg); } }); Object.entries(markupconfig.markup) .filter(([_, cfg]) => !cfg.type || cfg.type === 'html') .forEach(([name, cfg]) => { logger.info(`Applying HTML markup adjustment: ${name}`); const elements = context.content.document.querySelectorAll(cfg.match); elements.forEach((el) => patchHtmlElement(el, cfg)); }); }