WIP: transforming through handlebars, parsing the html and resolving the imports
This commit is contained in:
341
src/index.ts
341
src/index.ts
@@ -1,10 +1,25 @@
|
||||
import { extname } from "node:path";
|
||||
|
||||
import type { Plugin, NormalizedOutputOptions, OutputBundle, EmittedAsset } from 'rollup';
|
||||
import type {
|
||||
Plugin,
|
||||
OutputBundle,
|
||||
OutputChunk,
|
||||
OutputAsset,
|
||||
NormalizedOutputOptions,
|
||||
// ModuleInfo,
|
||||
ResolvedId, PreRenderedChunk
|
||||
} from 'rollup';
|
||||
|
||||
import type { RollupHtmlOptions, RollupHtmlTemplateOptions } from '../types/index.d.ts';
|
||||
import type {
|
||||
LoadResult,
|
||||
RollupHtmlOptions,
|
||||
LoadNodeCallback,
|
||||
LoadReference
|
||||
} from '../types/index.d.ts';
|
||||
import {createFilter} from '@rollup/pluginutils';
|
||||
import {parse as parseHtml, serialize as serializeHtml, DefaultTreeAdapterMap} from "parse5";
|
||||
|
||||
const getFiles = (bundle: OutputBundle): RollupHtmlTemplateOptions['files'] => {
|
||||
const getFiles = (bundle: OutputBundle): Record<string, (OutputChunk | OutputAsset)[]> => {
|
||||
const result = {} as ReturnType<typeof getFiles>;
|
||||
for (const file of Object.values(bundle)) {
|
||||
const { fileName } = file;
|
||||
@@ -16,118 +31,244 @@ const getFiles = (bundle: OutputBundle): RollupHtmlTemplateOptions['files'] => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const makeHtmlAttributes = (attributes: Record<string, any>): string => {
|
||||
if (!attributes) {
|
||||
return '';
|
||||
type LoaderNodeMapping = {attr?: string};
|
||||
type LoaderMappings = {[tagName: string]: LoaderNodeMapping[]};
|
||||
const defaultLoaderMappings: LoaderMappings = {
|
||||
'script': [{attr: 'src'}], // Javascript
|
||||
'link': [{attr: 'href'}], // Style
|
||||
// 'style': [{body: true}] // Body of a style tag may have links that we want to resolve (images, other css, ..),
|
||||
'img': [{attr: 'src'}], // Images, svgs
|
||||
// 'a': [{attr: 'href'}], // Links
|
||||
//'iframe': [{attr: 'src'}], // Very unlikely to become a default, but who knows if someone has a valid use for this
|
||||
'source': [{attr: 'src'}], // video source
|
||||
'track': [{attr: 'src'}], // subtitle
|
||||
'audio': [{attr: 'src'}], // audio
|
||||
//'portal': [{attr: 'src'}], // An experimantal feature to replace valid use cases for iframes? Might want to [look into it...](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/portal)
|
||||
//'object': [{attr: 'data'}], // Not sure what to do with this, is this still commonly used? Any valid use-case for this? [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/object)
|
||||
}
|
||||
|
||||
|
||||
function makeLoader(mappings: LoaderMappings = defaultLoaderMappings){
|
||||
const fn : LoadNodeCallback = function ({node}){
|
||||
const tagMapping = mappings[node.tagName];
|
||||
if(tagMapping){
|
||||
const mappingResults = tagMapping.map(mapping=>{
|
||||
let ids : LoadReference[] = [];
|
||||
if(mapping.attr){
|
||||
ids.push(...node.attrs.filter(({name})=>name===mapping.attr).map(attr=>({get: ()=>attr.value, set: (id: string)=>attr.value=id})));
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
return (<LoadReference[]>[]).concat(...mappingResults);
|
||||
}
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
const keys = Object.keys(attributes);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
return keys.reduce((result, key) => (result += ` ${key}="${attributes[key]}"`), '');
|
||||
const defaults: RollupHtmlOptions = {
|
||||
transform: (source: string)=>source,// NO-OP
|
||||
load: makeLoader(),
|
||||
resolve: ()=>true,
|
||||
htmlFileNames: "[name].html",
|
||||
include: [
|
||||
'**/*.(html|hbs)',// html or handlebars
|
||||
]
|
||||
};
|
||||
|
||||
const defaultTemplate = async ({
|
||||
attributes,
|
||||
files,
|
||||
meta,
|
||||
publicPath,
|
||||
title
|
||||
}: RollupHtmlTemplateOptions) => {
|
||||
const scripts = (files.js || [])
|
||||
.map(({ fileName }) => {
|
||||
const attrs = makeHtmlAttributes(attributes.script);
|
||||
return `<script src="${publicPath}${fileName}"${attrs}></script>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const links = (files.css || [])
|
||||
.map(({ fileName }) => {
|
||||
const attrs = makeHtmlAttributes(attributes.link);
|
||||
return `<link href="${publicPath}${fileName}" rel="stylesheet"${attrs}>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const metas = meta
|
||||
.map((input) => {
|
||||
const attrs = makeHtmlAttributes(input);
|
||||
return `<meta${attrs}>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `
|
||||
<!doctype html>
|
||||
<html${makeHtmlAttributes(attributes.html)}>
|
||||
<head>
|
||||
${metas}
|
||||
<title>${title}</title>
|
||||
${links}
|
||||
</head>
|
||||
<body>
|
||||
${scripts}
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
const supportedFormats = ['es', 'esm', 'iife', 'umd'];
|
||||
|
||||
const defaults = {
|
||||
attributes: {
|
||||
link: null,
|
||||
html: { lang: 'en' },
|
||||
script: null
|
||||
},
|
||||
fileName: 'index.html',
|
||||
meta: [{ charset: 'utf-8' }],
|
||||
publicPath: '',
|
||||
template: defaultTemplate,
|
||||
title: 'Rollup Bundle'
|
||||
};
|
||||
// Internal type
|
||||
type HtmlImport = {
|
||||
id: string,
|
||||
rollupResolved: ResolvedId|null,
|
||||
node: DefaultTreeAdapterMap['element'],
|
||||
reference: LoadReference,
|
||||
referenceId: string|null,
|
||||
index: number,
|
||||
}
|
||||
type HtmlModule = {
|
||||
// TODO might want to impose an own unique id, in case this changes after multiple builds
|
||||
id: string,
|
||||
resolved: HtmlImport[];
|
||||
}
|
||||
|
||||
export default function html(opts: RollupHtmlOptions = {}): Plugin {
|
||||
const { attributes, fileName, meta, publicPath, template, title } = Object.assign(
|
||||
const {
|
||||
publicPath,
|
||||
transform,
|
||||
load,
|
||||
htmlFileNames,
|
||||
resolve,
|
||||
include,
|
||||
exclude,
|
||||
} = Object.assign(
|
||||
{},
|
||||
defaults,
|
||||
opts
|
||||
);
|
||||
if(publicPath){ throw new Error("TODO, do something with the public path or throw it out of the options. this is just to stop typescript complaining")}
|
||||
|
||||
let filter = createFilter(include, exclude, {});
|
||||
let handledHtmls = new Map<string, HtmlModule>();// todo clean this per new build?
|
||||
|
||||
return {
|
||||
name: 'html',
|
||||
name: 'html2',// TODO: Need a better name, original plugin was just named `html` and might still make sense to use in conjunction with this one
|
||||
|
||||
load: {
|
||||
async handler(id: string) {
|
||||
if(!filter(id)) return;
|
||||
// We'll be transforming this, but it appears there is no need for us to load it. Rollup will do this
|
||||
}
|
||||
},
|
||||
outputOptions(options){
|
||||
return {
|
||||
...options,
|
||||
entryFileNames: (chunkInfo)=>{
|
||||
const htmlModule = chunkInfo.facadeModuleId ? handledHtmls.get(chunkInfo.facadeModuleId!) : null;
|
||||
const defaultOption = options.entryFileNames ?? "[name]-[hash].js";// This default is copied from the docs. TODO: don't like overwrite it this way, can we remove the need for this or fetch the true default?
|
||||
if(htmlModule){
|
||||
let fileName = typeof (htmlFileNames) === 'string' ? htmlFileNames : (<(chunkInfo:PreRenderedChunk)=>string>htmlFileNames)(chunkInfo);
|
||||
if(fileName) {
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
return typeof (defaultOption) === 'string' ? defaultOption : (<(chunkInfo:PreRenderedChunk)=>string>defaultOption)(chunkInfo);
|
||||
},
|
||||
// TODO do we need to do the same for chunks?? (what if they're dynamically imported?)
|
||||
}
|
||||
},
|
||||
transform: {
|
||||
async handler(code: string, id: string){
|
||||
if(!filter(id)) return;
|
||||
|
||||
const handled : HtmlModule = {
|
||||
id,
|
||||
resolved: [],
|
||||
};
|
||||
handledHtmls.set(id, handled);
|
||||
|
||||
const htmlSrc = transform? await transform(code, {
|
||||
id,
|
||||
}) : code;
|
||||
|
||||
const document = parseHtml(htmlSrc);
|
||||
|
||||
// Figure out which references to load from this HTML by iterating all nodes (looking for src or href attributes)
|
||||
let loadResults : { reference: LoadReference, node: DefaultTreeAdapterMap['element'] }[] = [];
|
||||
if(document.childNodes){
|
||||
let nodeQueue = document.childNodes;
|
||||
do{
|
||||
const nextQueue: DefaultTreeAdapterMap['childNode'][][] = [];
|
||||
await Promise.all(nodeQueue.map(async (node)=>{
|
||||
const el = (<DefaultTreeAdapterMap['element']>node);
|
||||
let toLoad: LoadResult|undefined = undefined;
|
||||
if(el.attrs) {
|
||||
toLoad = load ? await load({
|
||||
node: el,
|
||||
sourceId: id
|
||||
}): [];
|
||||
}
|
||||
|
||||
if(toLoad){
|
||||
const loadIds: LoadReference[] = (toLoad instanceof Array)? toLoad: [toLoad];
|
||||
for(const loadId of loadIds){
|
||||
loadResults.push({
|
||||
reference: loadId,
|
||||
node: el,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if(toLoad !== false) {
|
||||
let asParent = (<DefaultTreeAdapterMap['parentNode']>node);
|
||||
if (asParent.childNodes) {
|
||||
nextQueue.push(asParent.childNodes);
|
||||
}
|
||||
}
|
||||
}));
|
||||
nodeQueue = nextQueue.flat();
|
||||
}while(nodeQueue.length > 0);
|
||||
}
|
||||
|
||||
// Figure out what to resolve (todo, an id can actually be loaded in multiple times, something we might want to keep in mind)
|
||||
await Promise.all(loadResults.map(async ({reference, node}, index)=>{
|
||||
const refId = reference.get();
|
||||
const selfResolvedId = resolve? resolve(refId, {
|
||||
sourceId: id,
|
||||
node,
|
||||
}) : refId;
|
||||
const resolvedId : string = selfResolvedId===true?refId:(<string>selfResolvedId);
|
||||
if(resolvedId){
|
||||
const rollupResolved = await this.resolve(resolvedId, id, {
|
||||
skipSelf: true,
|
||||
isEntry: true, // TODO: for href/src tags, this is probably the right option. For anything that is to be inlined into the HTML... probably no
|
||||
});
|
||||
// TODO: should we check if this is refused for resolving here. i.e. external?
|
||||
const htmlImport: HtmlImport = {
|
||||
id: resolvedId,
|
||||
rollupResolved,//rollupResolved,
|
||||
node,
|
||||
reference,
|
||||
referenceId: rollupResolved? this.emitFile({
|
||||
type: 'chunk', // Might want to adapt, or make configurable,
|
||||
id: rollupResolved.id,
|
||||
importer: id,
|
||||
implicitlyLoadedAfterOneOf: [id],
|
||||
}) : null,
|
||||
index,
|
||||
};
|
||||
if(htmlImport.referenceId) {
|
||||
reference.set(`\${import.meta.ROLLUP_FILE_URL_${htmlImport.referenceId}\}`);
|
||||
}
|
||||
handled.resolved.push(htmlImport);
|
||||
}
|
||||
}));
|
||||
|
||||
// console.log(`TODO, add the following for further transformations:\n${resolveResults.map(x=>` ${x.id}`).join('\n')}`); //and figure out how other libraries later replace the import...
|
||||
|
||||
// Transform to JS
|
||||
const serialized = serializeHtml(document);
|
||||
const jsModule = [
|
||||
//...resolveResults.map(x=>`import * as dep${x.index} from "${x.id}";`),
|
||||
// ...handled.resolved.map(x=>`import("${x.id}");`),// Inject as a dynamic import. We need to remove these before outputting // todo better solution to mark the ids as dependencies of this bundle...
|
||||
`export const html = \`${serialized.replaceAll(/`/g,'\\\`')}\`;`,
|
||||
`export default html;`
|
||||
].join('\n');
|
||||
|
||||
return {code: jsModule};
|
||||
}
|
||||
},
|
||||
async generateBundle(output: NormalizedOutputOptions, bundle: OutputBundle) {
|
||||
if (!supportedFormats.includes(output.format) && !opts.template) {
|
||||
this.warn(
|
||||
`plugin-html: The output format '${
|
||||
output.format
|
||||
}' is not directly supported. A custom \`template\` is probably required. Supported formats include: ${supportedFormats.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (output.format === 'es') {
|
||||
attributes.script = Object.assign({}, attributes.script, {
|
||||
type: 'module'
|
||||
});
|
||||
}
|
||||
|
||||
const files = getFiles(bundle);
|
||||
const source = await template({
|
||||
attributes,
|
||||
bundle,
|
||||
files,
|
||||
meta,
|
||||
publicPath,
|
||||
title
|
||||
});
|
||||
console.log("must output?!", output, bundle, files);
|
||||
},
|
||||
|
||||
const htmlFile: EmittedAsset = {
|
||||
type: 'asset',
|
||||
source,
|
||||
name: 'Rollup HTML Asset',
|
||||
fileName
|
||||
};
|
||||
|
||||
this.emitFile(htmlFile);
|
||||
}
|
||||
// async generateBundle(output: NormalizedOutputOptions, bundle: OutputBundle) {
|
||||
// if (!supportedFormats.includes(output.format) && !opts.transform) {
|
||||
// this.warn(
|
||||
// `plugin-html: The output format '${
|
||||
// output.format
|
||||
// }' is not directly supported. A custom \`template\` is probably required. Supported formats include: ${supportedFormats.join(
|
||||
// ', '
|
||||
// )}`
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// if (output.format === 'es') {
|
||||
// attributes.script = Object.assign({}, attributes.script, {
|
||||
// type: 'module'
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// const files = getFiles(bundle);
|
||||
//
|
||||
//
|
||||
// const htmlFile: EmittedAsset = {
|
||||
// type: 'asset',
|
||||
// source,
|
||||
// name: 'Rollup HTML Asset',
|
||||
// fileName
|
||||
// };
|
||||
//
|
||||
// this.emitFile(htmlFile);
|
||||
// }
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user