import path, { extname } from "node:path"; import type { Plugin, OutputBundle, OutputChunk, OutputAsset, NormalizedOutputOptions, // ModuleInfo, ResolvedId, PreRenderedChunk, RenderedChunk, } from 'rollup'; import type { LoadResult, RollupHtmlOptions, LoadNodeCallback, LoadReference, BodyReference, AttributeReference, LoadFunction } from '../types/index.d.ts'; import {createFilter} from '@rollup/pluginutils'; import {parse as parseHtml, serialize as serializeHtml, DefaultTreeAdapterMap} from "parse5"; import {readFile} from "node:fs/promises" import {makeLoader, makeInlineId} from "./loader.js"; import {HtmlImport, HtmlModule} from "./html-module.js"; import {dirname} from "node:path"; import posix from "node:path/posix"; import crypto from "node:crypto"; const defaults: RollupHtmlOptions = { transform: (source: string)=>source,// NO-OP load: makeLoader(), resolve: ()=>true, htmlFileNames: "[name].html", include: [ '**/*.(html|hbs)',// html or handlebars ] }; const modulePrefix = `// `; const moduleSuffix = `// `; export default function html(opts: RollupHtmlOptions = {}): Plugin { const { publicPath, transform, rewriteUrl, 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, {}); // TODO, we need to clear all these properly at sme point to avoid odd bugs in watch mode let htmlModules = new Map();// todo clean this per new build? let virtualSources = new Map(); let addedEntries = new Map(); let entryNames = new Map(); const pluginName = 'html2'; return { name: pluginName,// TODO: Need a better name, original plugin was just named `html` and might still make sense to use in conjunction with this one buildStart(options){ entryNames = new Map(Object.entries(typeof(options.input)==='object'?options.input:{[options.input]:[options.input]}) .map(([k,v])=>[v,k]) ); }, resolveId: { async handler(specifier: string, importer: string | undefined, options: { assertions: Record }){ if(virtualSources.has(specifier)) return specifier; 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, imports: [], 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) return { ...resolved, meta: { ...resolved.meta, [pluginName]: {name: specifier} } } } } }, load: { // TODO, not in the mood to fix this. Load-result is getting cached and that gives us issues. Seperate load/transform behavior and adapt to use magic string for transformations? // Something to figure out: its counter intuitive that rollup expects the load-callback to already return JS. It implies we already do transformations and can't really use rollup to further transform any of it. (i.e handlebars > intermediate-html > html would not be possible?) async handler(id: string) { if(virtualSources.has(id)) return virtualSources.get(id); if(!filter(id)) return; // 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); // Figure out which references to load from this HTML by iterating all nodes (looking for src or href attributes) let htmlImports: HtmlImport[] = htmlModule.imports = []; if (document.childNodes) { let nodeQueue = document.childNodes; do { const nextQueue: DefaultTreeAdapterMap['childNode'][][] = []; await Promise.all(nodeQueue.map(async (node) => { const el = (node); const loadFunction: LoadFunction = async ({ id: sourceId, source, type })=>{ if(!sourceId){ sourceId = makeInlineId(id, node, 'js'); } if(source){ virtualSources.set(sourceId, source); } const resolved = await this.resolve(sourceId, id, { isEntry: type==='entryChunk', }); if(!resolved){ throw new Error(`Could not resolve ${sourceId} from ${id}`); } const selfInfo = this.getModuleInfo(id); let entryName: string|undefined = undefined; const parentName = entryNames.get(id)??selfInfo?.meta[pluginName].name; if(type==='entryChunk'){ entryName= posix.join(posix.dirname(parentName),sourceId); entryName = entryName.slice(0,-(posix.extname(entryName).length)); // Cut off the extension (TODO, is this wise?) } const importName = (source && selfInfo?.meta[pluginName].name) ? makeInlineId(parentName, node, extname(sourceId)) : entryName; const htmlImport: HtmlImport = { id: sourceId, resolved: resolved, // loaded: loaded, node: el, type, source, referenceId: (resolved && (['chunk','entryChunk'].includes(type!))) ? this.emitFile({ type: 'chunk', // Might want to adapt, or make configurable (see LoadType) id: resolved.id, name: importName, importer: id, }) : null, placeholder: `html-import-${crypto.randomBytes(32).toString('base64')}`, index: htmlImports.length, } // if(entryName){ // addedEntries.set(resolved.id, entryName);// (we could do this using meta?) // } htmlImports.push(htmlImport); return htmlImport.placeholder; } let toLoad: LoadResult | undefined = load? await Promise.resolve(load({ node: el, sourceId: id }, loadFunction)) : undefined; if (toLoad !== false) { let asParent = (node); if (asParent.childNodes) { nextQueue.push(asParent.childNodes); } } })); nodeQueue = nextQueue.flat(); } while (nodeQueue.length > 0); } let html = serializeHtml(htmlModule.document).replaceAll(/`/g,'\\\`').replaceAll(/\$\{/g,'\\${'); const moduleImports = []; for(const htmlImport of htmlImports){ if(htmlImport.type === 'default') { const assetId: string = `asset${moduleImports.length}`; moduleImports.push(`import ${assetId} from "${htmlImport.id}";`);// TODO: This is just the easy & safe solution. Would prefer to have recognizable names, and reeuse when something is the exact same resource.. html = html.replace(htmlImport.placeholder, `\${${assetId}}`);// TODO: Should we be worried about windows absolute URLs here? // }else if(htmlImport.type === 'entryChunk' && htmlImport.referenceId){ // html = html.replace(htmlImport.placeholder, `\${import.meta.ROLLUP_FILE_URL_${htmlImport.referenceId}\}`); }else{ // TODO: this will probably not do for complicated cases ( presumably no other method then emitting the chunk as file, loading its result but excluding it from the output bundle) // html = html.replace(htmlImport.placeholder, htmlImport.loaded?.code||htmlImport.source||''); } } // TODO when importing html from .js this will not do. ( const htmlJSModule = [ ...moduleImports, ``, `export const html = \`${html}\`;`, `export default html;`, ].join('\n'); return { code: htmlJSModule, }; } } }, outputOptions(options){ return { ...options, entryFileNames: (chunkInfo)=>{ const htmlModule = chunkInfo.facadeModuleId ? htmlModules.get(chunkInfo.facadeModuleId!) : null; const addedEntry = chunkInfo.facadeModuleId ? addedEntries.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; } }else if(addedEntry){ return addedEntry; } 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?) } }, resolveFileUrl(options){ const htmlModule = htmlModules.get(options.moduleId); if(htmlModule){ // 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); const virtualBundles = new Set(); const facadeToChunk = new Map(); const htmlResults = new Map(); for(const [bundleName, bundle] of bundleItems) { const chunk = (bundle); if(chunk.facadeModuleId) { facadeToChunk.set(chunk.facadeModuleId, chunk); const htmlModule = htmlModules.get(chunk.facadeModuleId); if(htmlModule){ htmlResults.set(bundleName, {chunk, htmlModule})} else if(virtualSources.has(chunk.facadeModuleId)){ virtualBundles.add(bundleName); } } } for(const [bundleName, {chunk, htmlModule}] of htmlResults.entries()){ if(htmlModule. document) { // Delete the placeholder chunk from the bundle and emit an asset file for the HTML instead. deleteFromBundle(bundleName, bundles); // 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.imports){ // if(htmlImport.referenceId) { // const fileName = this.getFileName(htmlImport.referenceId); // htmlImport.reference.set(fileName); // } // } // serialized = serializeHtml(htmlModule.document); } // Inject the inlined chunks (TODO cleanup) for(const htmlImport of htmlModule.imports){ const importResult = facadeToChunk.get(htmlImport.resolved?.id!); if(importResult){ if(htmlImport.type === 'chunk') { htmlContents = htmlContents.replace(htmlImport.placeholder, importResult.code); }else if(htmlImport.type === 'entryChunk'){ const relPath = posix.relative(dirname(chunk.fileName), importResult.fileName); const rootPath = path.posix.join(dirname(chunk.fileName), relPath); const rewritten = rewriteUrl? await Promise.resolve(rewriteUrl(relPath, { from: chunk.fileName, rootPath, })): relPath; htmlContents = htmlContents.replace(htmlImport.placeholder, rewritten); } } } this.emitFile({ type: 'asset', name: htmlModule.name, fileName: chunk.fileName, source: htmlContents, }); }else{ throw new Error('something went wrong...'); } } for( const bundleName of virtualBundles.keys()){ deleteFromBundle(bundleName, bundles, false); } } }; } function deleteFromBundle(bundlename: string, bundle: OutputBundle, deleteMap: boolean = true){ delete bundle[bundlename]; if(deleteMap) { delete bundle[`${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?) } }