phtml-define-plus
Version:
Use custom defined elements in HTML
369 lines (288 loc) • 11.1 kB
JavaScript
import phtml, { Result, Element, Text, Fragment } from 'phtml';
import path, { join, sep } from 'path';
import { stat, readFile } from 'fs';
import fetch from 'node-fetch';
/* Resolve the location of a file within `url(id)` from `cwd`
/* ========================================================================== */
function resolve(id, rawcwd, rawcache) {
const cache = Object(rawcache); // if `id` starts with `/` then `cwd` is the filesystem root
const cwd = starts_with_root(id) ? '' : rawcwd; // resolve as a file using `cwd/id` as `file`
return resolve_as_fetch(id).catch(() => resolve_as_file(join(cwd, id), cache)) // otherwise, resolve as a directory using `cwd/id` as `dir`
.catch(() => resolve_as_directory(join(cwd, id), cache)) // otherwise, if `id` does not begin with `/`, `./`, or `../`
.catch(() => !starts_with_relative(id) // resolve as a module using `cwd` and `id`
? resolve_as_module(cwd, id, cache) : Promise.reject()) // otherwise, throw `"HTML not found"`
.catch(() => Promise.reject(new Error(`HTML not found ${id} in ${cwd}`)));
}
function resolve_as_fetch(url) {
return starts_with_protocol(url) ? fetch(url).then(response => response.text()).then(contents => ({
file: url,
contents
})) : Promise.reject();
}
function resolve_as_file(file, cache) {
// resolve `file` as the file
return file_modified_contents(file, cache) // otherwise, resolve `file.html` as the file
.catch(() => file_modified_contents(`${file}.html`, cache));
}
function resolve_as_directory(dir, cache) {
// resolve the JSON contents of `dir/package.json` as `pkg`
return json_contents(dir, cache).then( // if `pkg` has a `html` field
pkg => 'html' in pkg // resolve `dir/pkg.html` as the file
? file_modified_contents(join(dir, pkg.html), cache) // otherwise, resolve `dir/index.html` as the file
: file_modified_contents(join(dir, 'index.html'), cache));
}
function resolve_as_module(cwd, id, cache) {
// for each `dir` in the node modules directory using `cwd`
return node_modules_dirs(cwd).reduce((promise, dir) => promise.catch( // resolve as a file using `dir/id` as `file`
() => resolve_as_file(join(dir, id), cache) // otherwise, resolve as a directory using `dir/id` as `dir`
.catch(() => resolve_as_directory(join(dir, id), cache))), Promise.reject());
}
function node_modules_dirs(cwd) {
// segments is `cwd` split by `/`
const segments = cwd.split(sep); // `count` is the length of segments
let count = segments.length; // `dirs` is an empty list
const dirs = []; // while `count` is greater than `0`
while (count > 0) {
// if `segments[count]` is not `node_modules`
if (segments[count] !== 'node_modules') {
// push a new path to `dirs` as the `/`-joined `segments[0 - count]` and `node_modules`
dirs.push(join(segments.slice(0, count).join('/') || '/', 'node_modules'));
} // `count` is `count` minus `1`
--count;
} // return `dirs`
return dirs;
}
/* Additional tooling
/* ========================================================================== */
function file_contents(file, mtimeMs, cache) {
cache[file] = new Promise((resolvePromise, rejectPromise) => readFile(file, 'utf8', (error, contents) => error ? rejectPromise(error) : resolvePromise({
file,
contents
})));
cache[file].mtimeMs = mtimeMs;
return cache[file];
}
function file_modified_contents(file, cache) {
return new Promise((resolvePromise, rejectPromise) => {
stat(file, (error, stats) => error ? rejectPromise(error) : cache[file] && cache[file].mtimeMs === stats.mtimeMs ? resolvePromise(cache[file]) : resolvePromise(file_contents(file, stats.mtimeMs, cache)));
});
}
function json_contents(dir, cache) {
const file = join(dir, 'package.json');
return file_modified_contents(file, cache).then(({
contents
}) => JSON.parse(contents));
}
function starts_with_protocol(id) {
return /^(?:\w+:)?\/\/(\S+)$/.test(id);
}
function starts_with_root(id) {
return /^\//.test(id);
}
function starts_with_relative(id) {
return /^\.{0,2}\//.test(id);
}
function getDefineElements(root, opts) {
const defines = {};
let promise = Promise.resolve(); // walk the tree looking for <link rel="html" href> and <define>
root.walk(node => {
const isElement = node.type === 'element'; // ignore non-elements
if (!isElement) {
return;
} // get the href from <link rel="html" href />
const href = node.name === 'link' && node.attrs.get('rel') === 'html' && node.attrs.get('href');
if (href) {
// conditionally remove the <link> if preserving is disabled
if (!opts.preserve) {
node.remove();
} // get the current working directory for the <link>
const cwd = opts.cwd || path.dirname(node.source.input.from); // promise the contents of the href from <link>
const resolved = resolve(href, cwd, opts.cache);
promise = promise.then(() => resolved).then( // promise the contents of the href as an AST
result => new Result(result.contents, Object.assign({}, result, {
from: result.file
})).root).then( // transform the <link> AST
linkroot => transform(linkroot, opts)).then( // add the <link> defines to the current defines
linkdefines => {
Object.assign(defines, linkdefines);
});
return;
} // get the tag from <define tag />
const tag = node.name === 'define' && node.attrs.get('tag');
const isValidTag = tag && isValidTagRegExp.test(tag); // ignore invalid tags
if (!isValidTag) {
return;
} // add the <define> to the current defines
defines[tag] = node;
}); // promise the defines
return promise.then(() => defines);
}
const isValidTagRegExp = /[_a-zA-Z]+[_a-zA-Z0-9-]*-[_a-zA-Z0-9-]+$/;
// transform attribute values with ${slot-X} (e.g. some-attribute="before-${slot-X}-after")
function transformAttrValues(attrs, slots) {
attrs.forEach(attr => {
const hasSlotValues = slotValuesRegExp.test(attr.value); // ignore attributes that don’t use ${slot-X}
if (!hasSlotValues) {
return;
} // replace ${slot-X} with the slot value of X
attr.value = attr.value.replace(slotValuesRegExp, ($0, name, fallback) => name in slots ? String(slots[name]) : fallback ? fallback : '');
});
} // attribute values that use ${slot-X}, where X is the slot
const slotValuesRegExp = /\$\{slot-([_a-zA-Z]+[_a-zA-Z0-9-]*)(?:,([^}]+))?\}/g;
function transformCustomElements(root, opts, defines) {
root.walk((node, result) => {
const isValidCustomElement = node.type === 'element' && node.name in defines; // ignore non-custom-elements and unknown custom elements
if (!isValidCustomElement) {
return;
} // leave template children unprocessed
if (isTemplateOrTemplateChild(node)) {
return;
}
const defineClone = defines[node.name].clone(null, true);
const defineSlots = getSlotsFromDefineElement(defineClone);
const customSlots = getSlotsFromCustomElement(node);
for (const name in defineSlots) {
if (name in customSlots) {
defineSlots[name].replaceWith(customSlots[name]);
} else {
defineSlots[name].replaceWith(...defineSlots[name].nodes);
}
}
defineClone.walk(child => {
if (child.type !== 'element') {
return;
}
transformAttrValues(child.attrs, customSlots);
transferCssClasses(node, child);
});
const newRoot = replaceNode(node, result, opts, defineClone);
if (opts.transformSlots) {
transformCustomElements(newRoot, opts, defines);
}
});
}
const transferCssClasses = (nodeFrom, nodeTo) => {
if (!nodeFrom.attrs.get('class')) {
return;
}
const elementClasses = nodeTo.attrs.get('class') ? nodeTo.attrs.get('class').split(' ') : [];
const classes = elementClasses.concat(nodeFrom.attrs.get('class').split(' '));
const classSet = new Set(classes);
const value = Array.from(classSet.values()).join(' ');
nodeTo.attrs.add([{
name: 'class',
value
}]);
};
const replaceNode = (node, result, opts, define) => {
if (opts.preserve) {
const {
nodes
} = node; // prevent creation of empty <template></template> nodes
if (!nodes.length) {
node.nodes.push(...define.nodes);
return node;
}
const hasTemplateSiblings = nodes.some(n => n.name === 'template');
/* If the template already exists then we just need to finish processing
* the sibilings.
*
* This handles the scenario where there are 2 sibling slot elements and
* are using { transformSlots: true }
*
* <custom-element slot="contents">
* <span slot="left">Left</span>
*
* <span slot="right">Right</span>
* </custom-element>
*/
if (hasTemplateSiblings) {
return node;
}
const template = new Element({
name: 'template',
nodes: node.nodes,
result
});
node.nodes.push(template, ...define.nodes);
return node;
} else {
const {
parent
} = node;
node.replaceWith(...define.nodes);
return parent;
}
};
const isTemplateOrTemplateChild = node => {
let current = node;
while (current) {
if (current.name === 'template') {
return true;
}
current = current.parent;
}
return false;
};
const getSlotsFromDefineElement = node => {
const slots = {};
node.walk(child => {
const isSlot = child.type === 'element' && child.name === 'slot';
const name = isSlot && child.attrs.get('name');
if (!name) {
return;
}
child.attrs.remove('name');
slots[name] = child;
});
return slots;
};
const getSlotsFromCustomElement = node => {
const slots = {};
node.attrs.forEach(attr => {
const slotMatch = attr.name.match(isSlotAttrRegExp);
if (!slotMatch) {
return;
}
const name = slotMatch[1];
slots[name] = new Text({
data: attr.value,
result: node.result
});
});
node.walk(child => {
const isElement = child.type === 'element';
if (!isElement) {
return;
} // transform <slot name>
const slotElementName = child.name === 'slot' && child.attrs.get('name');
if (slotElementName) {
const slotElement = new Fragment({
result: node.result
});
slotElement.append(...child.clone(null, true).nodes);
slots[slotElementName] = slotElement;
return;
} // transform <x slot>
const elementSlotName = child.attrs.get('slot');
if (elementSlotName) {
const slotElement = child.clone(null, true);
slotElement.attrs.remove('slot');
slots[elementSlotName] = slotElement;
return;
}
});
return slots;
};
const isSlotAttrRegExp = /^slot-([_a-zA-Z]+[_a-zA-Z0-9-]*)$/;
function transform(root, opts) {
return getDefineElements(root, opts).then(defines => {
transformCustomElements(root, opts, defines);
return defines;
});
}
var index = new phtml.Plugin('phtml-define', opts => root => transform(root, Object.assign({
cache: {}
}, opts)));
export default index;
//# sourceMappingURL=index.mjs.map