feat: initial support for importing html from js

This commit is contained in:
2023-11-26 22:15:40 +01:00
parent afd4a3c9ae
commit 71a377417d
18 changed files with 332 additions and 154 deletions

View File

@@ -77,7 +77,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
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<string, HtmlModule>();// todo clean this per new build?
// let htmlModules = new Map<string, HtmlModule>();// todo clean this per new build?
let virtualSources = new Map<string, string>();
let addedEntries = new Map<string, string>();
let entryNames = new Map<string,string>();
@@ -129,22 +129,29 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
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);
// 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);
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}
[pluginName]: {
specifier: specifier,
id: resolved.id,
name: moduleName,
imports: [],
assetId: null,
importers: new Set(),
}
}
}
}
@@ -158,145 +165,163 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
}
},
transform: {
order: 'pre',
async handler(...args){
const [code, id] = args;
if (!filter(id)) return;
// parse
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
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 = (<DefaultTreeAdapterMap['element']>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: <string>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 = (<DefaultTreeAdapterMap['parentNode']>node);
if (asParent.childNodes) {
nextQueue.push(asParent.childNodes);
}
}
}));
nodeQueue = nextQueue.flat();
} while (nodeQueue.length > 0);
const moduleInfo = this.getModuleInfo(id);
const moduleMeta = moduleInfo!.meta ?? {};
let htmlModule = moduleMeta[pluginName];
if(!htmlModule){
const moduleExt = extname(id);
const moduleName = id.replace(new RegExp(`${moduleExt}\$`),''); // strip extension of the name if any
htmlModule = moduleMeta[pluginName] = {
id: id,
name: moduleName,
imports: [],
assetId: null,
importers: new Set(),
}
let htmlJS = new MagicString(serializeHtml(htmlModule.document));// TODO this is still a leak of AST, we're taking parse5 edited result and then transforming sourcemaps, this will only work when no edits were made
htmlJS.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..
htmlJS = htmlJS.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. (
htmlJS.prepend([
...moduleImports,
`export const html = \``
].join('\n')).append([
`\`;`,
`export default html;`,
].join('\n'));
const map = htmlJS.generateMap({
source: id,
file: `${id}.map`,
includeContent: true,
hires: 'boundary'
});
return {
code: htmlJS.toString(),
map: map.toString(),
};
}
// const htmlModule = htmlModules.get(id);
// const contents = await readFile(id, {encoding: "utf-8"});
const contents = code;
const htmlSrc = transform ? await transform(contents, {
id,
}) : contents;
// Parse document and store it
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 = (<DefaultTreeAdapterMap['element']>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: <string>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 = (<DefaultTreeAdapterMap['parentNode']>node);
if (asParent.childNodes) {
nextQueue.push(asParent.childNodes);
}
}
}));
nodeQueue = nextQueue.flat();
} while (nodeQueue.length > 0);
}
let htmlJS = new MagicString(serializeHtml(htmlModule.document));// TODO this is still a leak of AST, we're taking parse5 edited result and then transforming sourcemaps, this will only work when no edits were made
htmlJS.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..
htmlJS = htmlJS.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. (
htmlJS.prepend([
...moduleImports,
`export const html = \``
].join('\n')).append([
`\`;`,
`export default html;`,
].join('\n'));
const map = htmlJS.generateMap({
source: id,
file: `${id}.map`,
includeContent: true,
hires: 'boundary'
});
return {
code: htmlJS.toString(),
map: map.toString(),
meta: moduleMeta,
};
}
},
outputOptions(options){
return {
...options,
entryFileNames: (chunkInfo)=>{
const htmlModule = chunkInfo.facadeModuleId ? htmlModules.get(chunkInfo.facadeModuleId!) : null;
const moduleInfo = chunkInfo.facadeModuleId? this.getModuleInfo(chunkInfo.facadeModuleId) : null;
const htmlModule = moduleInfo?.meta?.[pluginName];
// 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){
@@ -313,7 +338,9 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
}
},
resolveFileUrl(options){
const htmlModule = htmlModules.get(options.moduleId);
// const htmlModule = htmlModules.get(options.moduleId);
const moduleInfo = this.getModuleInfo(options.moduleId);
const htmlModule = moduleInfo?.meta?.[pluginName];
if(htmlModule){
// Simply use the relative path in our HTML-fileURLs instead of the default `new URL('${fileName}', document.baseURI).href`)
return `"${options.relativePath}"`;
@@ -324,7 +351,9 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
order:'post',
handler(chunk: RenderedChunk){
if(chunk.facadeModuleId) {
const htmlModule = htmlModules.get(chunk.facadeModuleId);
const moduleInfo = chunk.facadeModuleId? this.getModuleInfo(chunk.facadeModuleId) : null;
const htmlModule = moduleInfo?.meta?.[pluginName];
// const htmlModule = htmlModules.get(chunk.facadeModuleId);
if (htmlModule) {
return modulePrefix; // Overwrite any added banner with our own
}
@@ -342,7 +371,11 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
const chunk = (<OutputChunk>bundle);
if(chunk.facadeModuleId) {
facadeToChunk.set(chunk.facadeModuleId, chunk);
const htmlModule = htmlModules.get(chunk.facadeModuleId);
const moduleInfo = this.getModuleInfo(chunk.facadeModuleId);
const htmlModule = moduleInfo?.meta?.[pluginName];
// const htmlModule = htmlModules.get(chunk.facadeModuleId);
if(htmlModule){ htmlResults.set(bundleName, {chunk, htmlModule})}
else if(virtualSources.has(chunk.facadeModuleId)){
virtualBundles.add(bundleName);