UNPKG

phtml-define-plus

Version:
369 lines (288 loc) 11.1 kB
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