First private release. Check updated README.md for details
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
378
src/index.ts
378
src/index.ts
@@ -20,7 +20,7 @@ import type {
|
||||
} from '../types/index.d.ts';
|
||||
import {createFilter} from '@rollup/pluginutils';
|
||||
import {parse as parseHtml, serialize as serializeHtml, DefaultTreeAdapterMap} from "parse5";
|
||||
// import {Script, SourceTextModule, createContext} from "node:vm";
|
||||
import {readFile} from "node:fs/promises"
|
||||
|
||||
const getFiles = (bundle: OutputBundle): Record<string, (OutputChunk | OutputAsset)[]> => {
|
||||
const result = {} as ReturnType<typeof getFiles>;
|
||||
@@ -80,20 +80,26 @@ const defaults: RollupHtmlOptions = {
|
||||
|
||||
// Internal type
|
||||
type HtmlImport = {
|
||||
id: string,
|
||||
rollupResolved: ResolvedId|null,
|
||||
node: DefaultTreeAdapterMap['element'],
|
||||
reference: LoadReference,
|
||||
referenceId: string|null,
|
||||
index: number,
|
||||
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,
|
||||
id: string;
|
||||
name: string;
|
||||
importers: Set<string|undefined>,
|
||||
resolved: HtmlImport[];
|
||||
document: DefaultTreeAdapterMap['document'],
|
||||
assetId?: string|null;
|
||||
document?: DefaultTreeAdapterMap['document'];
|
||||
}
|
||||
|
||||
const modulePrefix = `// <html-module>`;
|
||||
const moduleSuffix = `// </html-module>`;
|
||||
|
||||
export default function html(opts: RollupHtmlOptions = {}): Plugin {
|
||||
const {
|
||||
publicPath,
|
||||
@@ -111,22 +117,167 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
|
||||
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?
|
||||
let htmlModules = new Map<string, HtmlModule>();// todo clean this per new build?
|
||||
|
||||
return {
|
||||
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
|
||||
|
||||
resolveId: {
|
||||
async handler(specifier: string,
|
||||
importer: string | undefined,
|
||||
options: { assertions: Record<string, string> }){
|
||||
if(!filter(specifier)) return;
|
||||
|
||||
// Let it be resolved like others (node_modules, project aliases, ..)
|
||||
const resolved = await this.resolve(specifier, importer, {
|
||||
skipSelf: true,
|
||||
...options,
|
||||
});
|
||||
|
||||
if(resolved){
|
||||
const moduleId = resolved.id;
|
||||
const moduleExt = extname(resolved.id);
|
||||
const moduleName = specifier.replace(new RegExp(`${moduleExt}\$`),''); // strip extension of the name if any
|
||||
const htmlModule : HtmlModule = htmlModules.get(moduleId) ?? {
|
||||
id: resolved.id,
|
||||
name: moduleName,
|
||||
resolved: [],
|
||||
assetId: null,
|
||||
importers: new Set(),
|
||||
};
|
||||
htmlModule.importers.add(importer);
|
||||
|
||||
htmlModules.set(htmlModule.id, htmlModule);
|
||||
// TODO: trigger special handling when imported from a JS file (in which case we want might want to export a module returning the HTML, instead of HTML directly)
|
||||
}
|
||||
}
|
||||
},
|
||||
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
|
||||
|
||||
// Load
|
||||
const htmlModule = htmlModules.get(id);
|
||||
if(htmlModule) {
|
||||
const contents = await readFile(id, {encoding: "utf-8"});
|
||||
|
||||
const htmlSrc = transform ? await transform(contents, {
|
||||
id,
|
||||
}) : contents;
|
||||
|
||||
// Parse document and store it (TODO: check for watch mode, we should check if it needs reparsing or not)
|
||||
const document = htmlModule.document ?? parseHtml(htmlSrc);
|
||||
if(!htmlModule.document){
|
||||
htmlModule.document = document;
|
||||
}
|
||||
|
||||
// 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 isEntry = !!/.*\.(js|jsx|ts|tsx)$/i.exec(resolvedId); // TODO: for scripts (via src-tag, not those inlined) entry=true. But it should not check via the id (rather how it is imported from html)
|
||||
const rollupResolved = await this.resolve(resolvedId, id, {
|
||||
skipSelf: true,
|
||||
isEntry: isEntry,
|
||||
});
|
||||
|
||||
// TODO: should we test/check if this is refused for resolving here. i.e. external?
|
||||
const htmlImport: HtmlImport = {
|
||||
id: resolvedId,
|
||||
rollupResolved,
|
||||
node,
|
||||
reference,
|
||||
referenceId:
|
||||
// This was triggering resources being marked as entry, and thus their injected loader modules to be outputed to their own files (ie icon.js to load icon.svg)
|
||||
// Should be able to resolve the final HTML from the exported module instead (which though would ideally mean interpreting it as a browser would... )
|
||||
// TODO: however, probably need to uncomment this for <script src="..."> (as those really are entry...)
|
||||
(rollupResolved && isEntry) ? this.emitFile({
|
||||
type: 'chunk', // Might want to adapt, or make configurable,
|
||||
id: rollupResolved.id,
|
||||
importer: id,
|
||||
// implicitlyLoadedAfterOneOf: [id],// TODO: this was triggering weird results, guess i don't fully understand its purpose... remove when certain
|
||||
}) : null,
|
||||
index,
|
||||
};
|
||||
|
||||
htmlModule.resolved.push(htmlImport);
|
||||
}
|
||||
}));
|
||||
|
||||
// Rollup only understands JS, so we return matching JS module here that would export the html as the default export. And imports any resources through JS, expecting the inlineable value to be in the default export.
|
||||
// Note: Not sure if it is safe to rely on this default-export 'convention' much.
|
||||
// import the default export of all resources through JS and inject them in the resulting HTML
|
||||
const htmlImports : string[] = [];
|
||||
htmlModule.resolved.forEach((htmlImport, index)=>{
|
||||
if(htmlImport.referenceId){
|
||||
// Should only be triggered for <script src="...">
|
||||
htmlImport.reference.set(`\${import.meta.ROLLUP_FILE_URL_${htmlImport.referenceId}\}`);
|
||||
}else{
|
||||
// Asset
|
||||
const assetId = `asset${index}`;// TODO: This is just the easy & safe solution. Would prefer to have recognizable names, and reeuse when something is the exact same resource..
|
||||
htmlImports.push(`import ${assetId} from "${htmlImport.id}";`);// TODO: Should we be worried about windows absolute URLs here?
|
||||
htmlImport.reference.set(`\${${assetId}}`);
|
||||
}
|
||||
})
|
||||
const htmlJSModule = [
|
||||
...htmlImports,
|
||||
``,
|
||||
`export const html = \`${serializeHtml(htmlModule.document).replaceAll(/`/g,'\\\`')}\`;`,
|
||||
`export default html;`,
|
||||
].join('\n');
|
||||
return {
|
||||
code: htmlJSModule,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
outputOptions(options){
|
||||
return {
|
||||
...options,
|
||||
entryFileNames: (chunkInfo)=>{
|
||||
const htmlModule = chunkInfo.facadeModuleId ? handledHtmls.get(chunkInfo.facadeModuleId!) : null;
|
||||
const htmlModule = chunkInfo.facadeModuleId ? htmlModules.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);
|
||||
@@ -139,126 +290,93 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
|
||||
// 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 htmlSrc = transform? await transform(code, {
|
||||
id,
|
||||
}) : code;
|
||||
|
||||
const document = parseHtml(htmlSrc);
|
||||
|
||||
const handled : HtmlModule = {
|
||||
id,
|
||||
resolved: [],
|
||||
document,
|
||||
};
|
||||
handledHtmls.set(id, handled);
|
||||
|
||||
// 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 not
|
||||
});
|
||||
// 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) {
|
||||
// only used when importing from javascript
|
||||
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 = [
|
||||
// This will only make sense when importing from javascript
|
||||
`export const html = \`${serialized.replaceAll(/`/g,'\\\`')}\`;`,
|
||||
`export default html;`
|
||||
].join('\n');
|
||||
|
||||
return {code: jsModule};
|
||||
}
|
||||
},
|
||||
async renderChunk(
|
||||
code: string,
|
||||
chunk: RenderedChunk,
|
||||
options: NormalizedOutputOptions,
|
||||
meta: { chunks: Record<string, RenderedChunk> }
|
||||
){
|
||||
const htmlModule = chunk.facadeModuleId ? handledHtmls.get(chunk.facadeModuleId!) : null;
|
||||
resolveFileUrl(options){
|
||||
const htmlModule = htmlModules.get(options.moduleId);
|
||||
if(htmlModule){
|
||||
let html = '';
|
||||
for(const htmlImport of htmlModule.resolved){
|
||||
if(htmlImport.referenceId) {
|
||||
const fileName = this.getFileName(htmlImport.referenceId);
|
||||
htmlImport.reference.set(fileName);
|
||||
}
|
||||
}
|
||||
html = serializeHtml(htmlModule.document);// This might contain temporary hashes, but it should be alright
|
||||
return {code: html};
|
||||
// Simply use the relative path in our HTML-fileURLs instead of the default `new URL('${fileName}', document.baseURI).href`)
|
||||
return `"${options.relativePath}"`;
|
||||
}
|
||||
},
|
||||
banner: {
|
||||
// Injects a tag so we know where our bundle starts so we can safely ignore other stuff addded via a banner (ie. live-reload)
|
||||
order:'post',
|
||||
handler(chunk: RenderedChunk){
|
||||
if(chunk.facadeModuleId) {
|
||||
const htmlModule = htmlModules.get(chunk.facadeModuleId);
|
||||
if (htmlModule) {
|
||||
return modulePrefix; // Overwrite any added banner with our own
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
},
|
||||
async generateBundle(outputOptions, bundles){
|
||||
const bundleItems = Object.entries(bundles);
|
||||
for(let [bundlename, bundle] of bundleItems){
|
||||
const chunk = (<OutputChunk>bundle);
|
||||
if(chunk.facadeModuleId) {
|
||||
const htmlModule = htmlModules.get(chunk.facadeModuleId);
|
||||
if (htmlModule) {
|
||||
|
||||
if(htmlModule.document) {
|
||||
// Delete the placeholder chunk from the bundle and emit an asset file for the HTML instead.
|
||||
delete bundles[bundlename];
|
||||
delete bundles[`${bundlename}.map`];// Also delete any generated map files because they don't make any sense. (TODO: there seems to be no better way to detect this?)
|
||||
|
||||
// Interpret the module and take its default export (TODO: if [NodeJS vm SourceTextModule](https://nodejs.org/api/vm.html#class-vmsourcetextmodule) ever lands, it would be cleaner to use that one instead of directly importing it)
|
||||
let htmlContents: string;
|
||||
|
||||
// Take out the sourceMapUrl if any (it will not have been written yet and tends to cause a crash, we don't need it anyway))
|
||||
let sanitizedCode = chunk.code;
|
||||
|
||||
// Use the modulePrefix to filter out prepended code that is not relevant for us (like live-reload)
|
||||
const moduleStart = sanitizedCode.indexOf(modulePrefix);
|
||||
if(moduleStart>=0){
|
||||
sanitizedCode = sanitizedCode.slice(moduleStart+modulePrefix.length);
|
||||
}
|
||||
// Filter out any sourceMapping url that may have been added
|
||||
const sourceMapRE = /\/\/# sourceMappingURL=(.+)/.exec(sanitizedCode);
|
||||
if(sourceMapRE){
|
||||
sanitizedCode = sanitizedCode.slice(0,sourceMapRE.index)+sanitizedCode.slice(sourceMapRE.index+sourceMapRE[0].length);
|
||||
}
|
||||
|
||||
// Encode into a url that we can import(...)
|
||||
// const importUrl = `data:text/javascript;base64,${Buffer.from(sanitizedCode).toString('base64')}`; // Safer, but unrecognizable if this throws an error
|
||||
const importUrl = `data:text/javascript,${encodeURIComponent(sanitizedCode)}`; // Due to needed encoding still hard to read, but it might at least be recognizable by the user if it throws an error
|
||||
|
||||
try{
|
||||
let htmlJsModule = await import(importUrl);
|
||||
htmlContents = htmlJsModule.default;
|
||||
}catch(err){
|
||||
throw new Error([
|
||||
`Failed to parse resulting HTML-module. Most likely this is due to a plugin that has altered the module in such a way that we cannot easely evaluate it in NodeJS.`,
|
||||
`The code we tried to evaluate:`,
|
||||
sanitizedCode.split('\n').map(x=>` ${x}`).join('\n'),
|
||||
`The error we got:`,
|
||||
err
|
||||
].join('\n'))
|
||||
// TODO: We could try to fallback as follows, but the issues are likely to persist in the end result
|
||||
// for(const htmlImport of htmlModule.resolved){
|
||||
// if(htmlImport.referenceId) {
|
||||
// const fileName = this.getFileName(htmlImport.referenceId);
|
||||
// htmlImport.reference.set(fileName);
|
||||
// }
|
||||
// }
|
||||
// serialized = serializeHtml(htmlModule.document);
|
||||
}
|
||||
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
name: htmlModule.name,
|
||||
fileName: chunk.fileName,
|
||||
source: htmlContents,
|
||||
});
|
||||
}else{
|
||||
throw new Error('something went wrong...');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user