posthtml-component
Version:
Laravel Blade-inspired components for PostHTML with slots, attributes as props, custom tags and more.
306 lines (249 loc) • 9.89 kB
JavaScript
;
const {readFileSync, existsSync} = require('fs');
const path = require('path');
const {parser} = require('posthtml-parser');
const {match, walk} = require('posthtml/lib/api');
const expressions = require('posthtml-expressions');
const findPathFromTag = require('./find-path');
const processProps = require('./process-props');
const processAttributes = require('./process-attributes');
const {processPushes, processStacks} = require('./process-stacks');
const {setFilledSlots, processSlotContent, processFillContent} = require('./process-slots');
const log = require('./log');
const each = require('lodash/each');
const defaults = require('lodash/defaults');
const assignWith = require('lodash/assignWith');
const mergeWith = require('lodash/mergeWith');
const template = require('lodash/template');
const get = require('lodash/get');
const has = require('lodash/has');
const isObjectLike = require('lodash/isObjectLike');
const isArray = require('lodash/isArray');
const isEmpty = require('lodash/isEmpty');
const isBoolean = require('lodash/isBoolean');
const isUndefined = require('lodash/isUndefined'); // value === undefined
const isNull = require('lodash/isNull'); // value === null
const isNil = require('lodash/isNil'); // value == null
const uniqueId = require('lodash/uniqueId');
const transform = require('lodash/transform');
const assign = require('lodash/assign');
const isPlainObject = require('lodash/isPlainObject');
/* eslint-disable complexity */
module.exports = (options = {}) => tree => {
options.root = path.resolve(options.root || './');
options.folders = options.folders || [''];
options.tagPrefix = options.tagPrefix || 'x-';
options.tag = options.tag || false;
options.attribute = options.attribute || 'src';
options.namespaces = options.namespaces || [];
options.namespaceSeparator = options.namespaceSeparator || '::';
options.fileExtension = options.fileExtension || 'html';
options.yield = options.yield || 'yield';
options.slot = options.slot || 'slot';
options.fill = options.fill || 'fill';
options.slotSeparator = options.slotSeparator || ':';
options.push = options.push || 'push';
options.stack = options.stack || 'stack';
options.propsScriptAttribute = options.propsScriptAttribute || 'props';
options.propsContext = options.propsContext || 'props';
options.propsAttribute = options.propsAttribute || 'props';
options.propsSlot = options.propsSlot || 'props';
options.parserOptions = options.parserOptions || {recognizeSelfClosing: true};
options.expressions = options.expressions || {};
options.plugins = options.plugins || [];
options.attrsParserRules = options.attrsParserRules || {};
options.strict = typeof options.strict === 'undefined' ? true : options.strict;
options.utilities = options.utilities || {
each,
defaults,
assign: assignWith,
merge: mergeWith,
template,
get,
has,
isPlainObject,
isObject: isObjectLike,
isArray,
isEmpty,
isBoolean,
isUndefined,
isNull,
isNil,
uniqueId,
isEnabled: prop => prop === true || prop === ''
};
/**
* Additional element attributes. If they already exist in valid-attributes.js,
* it will replace all attributes. It should be an object with tag name as
* the key and a function modifier as the value, which will receive the
* default attributes and return an array of attributes.
*
* Example:
* { TAG: (attributes) => { attributes[] = 'attribute-name'; return attributes; } }
*/
options.elementAttributes = isPlainObject(options.elementAttributes) ? options.elementAttributes : {};
options.safelistAttributes = Array.isArray(options.safelistAttributes) ? options.safelistAttributes : [];
options.blocklistAttributes = Array.isArray(options.blocklistAttributes) ? options.blocklistAttributes : [];
/**
* Merge customizer callback passed to `lodash.mergeWith` for merging
* attribute `props` and all attributes starting with `merge:`.
*
* @see {@link https://lodash.com/docs/4.17.15#mergeWith|Lodash}
*/
options.mergeCustomizer = options.mergeCustomizer || ((objectValue, sourceValue) => {
if (Array.isArray(objectValue)) {
return objectValue.concat(sourceValue);
}
});
if (!(options.slot instanceof RegExp)) {
options.slot = new RegExp(`^${options.slot}${options.slotSeparator}`, 'i');
}
if (!(options.fill instanceof RegExp)) {
options.fill = new RegExp(`^${options.fill}${options.slotSeparator}`, 'i');
}
if (!(options.tagPrefix instanceof RegExp)) {
options.tagPrefix = new RegExp(`^${options.tagPrefix}`, 'i');
}
if (!Array.isArray(options.matcher)) {
options.matcher = [];
if (options.tagPrefix) {
options.matcher.push({tag: options.tagPrefix});
}
if (options.tag) {
options.matcher.push({tag: options.tag});
}
}
options.folders = Array.isArray(options.folders) ? options.folders : [options.folders];
options.namespaces = Array.isArray(options.namespaces) ? options.namespaces : [options.namespaces];
options.namespaces.forEach((namespace, index) => {
options.namespaces[index].root = path.resolve(namespace.root);
if (namespace.fallback) {
options.namespaces[index].fallback = path.resolve(namespace.fallback);
}
if (namespace.custom) {
options.namespaces[index].custom = path.resolve(namespace.custom);
}
});
options.props = {...options.expressions.locals};
options.aware = {};
const pushedContent = {};
log('Start of processing..', 'init', 'success');
return processStacks(
processPushes(
processTree(options)(
expressions(options.expressions)(tree)
),
pushedContent,
options.push
),
pushedContent,
options.stack
);
};
/* eslint-enable complexity */
// Used to reset aware props
let processCounter = 0;
/**
* @param {Object} options Plugin options
* @return {Object} PostHTML tree
*/
function processTree(options) {
const filledSlots = {};
return tree => {
log(`Processing tree number ${processCounter}..`, 'processTree');
if (options.plugins.length > 0) {
tree = applyPluginsToTree(tree, options.plugins);
}
match.call(tree, options.matcher, currentNode => {
log(`Match found for tag "${currentNode.tag}"..`, 'processTree');
if (!currentNode.attrs) {
currentNode.attrs = {};
}
const componentPath = getComponentPath(currentNode, options);
if (!componentPath) {
return currentNode;
}
log(`${++processCounter}) Processing "${currentNode.tag}" from "${componentPath}"`, 'processTree');
let nextNode = parser(
readFileSync(componentPath, 'utf8'),
mergeWith({recognizeSelfClosing: true}, options.parserOptions)
);
// Set filled slots
setFilledSlots(currentNode, filledSlots, options);
const aware = transform(options.aware, (result, value) => {
assign(result, value);
}, {});
// Reset options.expressions.locals and keep aware locals
options.expressions.locals = {...options.props, ...aware};
const {attributes, props} = processProps(currentNode, nextNode, filledSlots, options, componentPath, processCounter);
options.expressions.locals = attributes;
options.expressions.locals.$slots = filledSlots;
// const plugins = [...options.plugins, expressions(options.expressions)];
nextNode = expressions(options.expressions)(nextNode);
if (options.plugins.length > 0) {
nextNode = applyPluginsToTree(nextNode, options.plugins);
}
// Process <yield> tag
const content = match.call(nextNode, {tag: options.yield}, nextNode => {
// Fill <yield> with current node content or default <yield>
return currentNode.content || nextNode.content;
});
nextNode = processTree(options)(nextNode);
// Process <fill> tags
processFillContent(nextNode, filledSlots, options);
// Process <slot> tags
processSlotContent(nextNode, filledSlots, options);
// Remove component tag and replace content with <yield>
currentNode.tag = false;
currentNode.content = content;
processAttributes(currentNode, attributes, props, options, aware);
/**
* Remove attributes when value is 'null' or 'undefined' so we can
* conditionally add an attribute by setting the value to
* 'undefined' or 'null'.
*/
walk.call(currentNode, node => {
if (node && node.attrs) {
for (const key in node.attrs) {
if (node.attrs[key] === 'undefined' || node.attrs[key] === 'null') {
delete node.attrs[key];
}
}
}
return node;
});
log(`Done processing number ${processCounter}.`, 'processTree', 'success');
// Reset options.aware for current processCounter
delete options.aware[processCounter];
// Decrement counter
processCounter--;
return currentNode;
});
if (processCounter === 0) {
log('End of processing', 'processTree', 'success');
}
return tree;
};
}
function getComponentPath(currentNode, options) {
const componentFile = currentNode.attrs[options.attribute];
if (componentFile) {
const componentPath = path.join(options.root, componentFile);
if (!existsSync(componentPath)) {
if (options.strict) {
throw new Error(`[components] The component was not found in ${componentPath}.`);
}
return false;
}
// Delete attribute used as path
delete currentNode.attrs[options.attribute];
return componentPath;
}
return findPathFromTag(currentNode.tag, options);
}
function applyPluginsToTree(tree, plugins) {
return plugins.reduce((tree, plugin) => {
tree = plugin(tree);
return tree;
}, tree);
}