Refactoring to support inlined scripts

This commit is contained in:
Miel Truyen 2023-05-02 03:46:47 +02:00
parent e3a022d420
commit 4006f3954e
12 changed files with 563 additions and 231 deletions

34
src/html-module.ts Normal file
View File

@ -0,0 +1,34 @@
// The HTML-Module is an internal helper structure to track the processing of an HTML file
// This is intended to be serialized into chunk-meta, so it can be cached. (thus keep any functions and circular references out of it)
// TODO: Actually making this serialiable (check rollupResolved, node, as we might no longer need them)
import type {
ModuleInfo,
ResolvedId,
} from 'rollup';
import type {
LoadedReference
} from "../types/load.d.ts";
import {DefaultTreeAdapterMap} from "parse5";
// Internal type
export type HtmlImport = LoadedReference & {
id: string;
resolved: ResolvedId|null;
// loaded: ModuleInfo|null;
node: DefaultTreeAdapterMap['element'];
referenceId: string|null;
placeholder: string,
index: number;
}
export type HtmlModule = {
// TODO might want to impose an own unique id, in case this changes after multiple builds
id: string;
name: string;
importers: Set<string|undefined>,
imports: HtmlImport[];
assetId?: string|null;
document?: DefaultTreeAdapterMap['document'];
}

View File

@ -16,57 +16,20 @@ import type {
LoadResult, LoadResult,
RollupHtmlOptions, RollupHtmlOptions,
LoadNodeCallback, LoadNodeCallback,
LoadReference LoadReference, BodyReference, AttributeReference, LoadFunction
} from '../types/index.d.ts'; } from '../types/index.d.ts';
import {createFilter} from '@rollup/pluginutils'; import {createFilter} from '@rollup/pluginutils';
import {parse as parseHtml, serialize as serializeHtml, DefaultTreeAdapterMap} from "parse5"; import {parse as parseHtml, serialize as serializeHtml, DefaultTreeAdapterMap} from "parse5";
import {readFile} from "node:fs/promises" import {readFile} from "node:fs/promises"
const getFiles = (bundle: OutputBundle): Record<string, (OutputChunk | OutputAsset)[]> => { import {makeLoader, makeInlineId} from "./loader.js";
const result = {} as ReturnType<typeof getFiles>; import {HtmlImport, HtmlModule} from "./html-module.js";
for (const file of Object.values(bundle)) {
const { fileName } = file;
const extension = extname(fileName).substring(1);
result[extension] = (result[extension] || []).concat(file); import {dirname} from "node:path";
} import posix from "node:path/posix";
return result; import crypto from "node:crypto";
};
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 defaults: RollupHtmlOptions = { const defaults: RollupHtmlOptions = {
transform: (source: string)=>source,// NO-OP transform: (source: string)=>source,// NO-OP
@ -78,25 +41,6 @@ const defaults: RollupHtmlOptions = {
] ]
}; };
// 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;
name: string;
importers: Set<string|undefined>,
resolved: HtmlImport[];
assetId?: string|null;
document?: DefaultTreeAdapterMap['document'];
}
const modulePrefix = `// <html-module>`; const modulePrefix = `// <html-module>`;
const moduleSuffix = `// </html-module>`; const moduleSuffix = `// </html-module>`;
@ -118,6 +62,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
let filter = createFilter(include, exclude, {}); let filter = createFilter(include, exclude, {});
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>();
return { 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 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
@ -126,6 +71,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
async handler(specifier: string, async handler(specifier: string,
importer: string | undefined, importer: string | undefined,
options: { assertions: Record<string, string> }){ options: { assertions: Record<string, string> }){
if(virtualSources.has(specifier)) return specifier;
if(!filter(specifier)) return; if(!filter(specifier)) return;
// Let it be resolved like others (node_modules, project aliases, ..) // Let it be resolved like others (node_modules, project aliases, ..)
@ -141,7 +87,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
const htmlModule : HtmlModule = htmlModules.get(moduleId) ?? { const htmlModule : HtmlModule = htmlModules.get(moduleId) ?? {
id: resolved.id, id: resolved.id,
name: moduleName, name: moduleName,
resolved: [], imports: [],
assetId: null, assetId: null,
importers: new Set(), importers: new Set(),
}; };
@ -154,6 +100,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
}, },
load: { load: {
async handler(id: string) { async handler(id: string) {
if(virtualSources.has(id)) return virtualSources.get(id);
if(!filter(id)) return; if(!filter(id)) return;
// Load // Load
@ -166,37 +113,65 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
}) : contents; }) : contents;
// Parse document and store it (TODO: check for watch mode, we should check if it needs reparsing or not) // 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); const document = htmlModule.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) // 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'] }[] = []; let htmlImports: HtmlImport[] = htmlModule.imports = [];
if (document.childNodes) { if (document.childNodes) {
let nodeQueue = document.childNodes; let nodeQueue = document.childNodes;
do { do {
const nextQueue: DefaultTreeAdapterMap['childNode'][][] = []; const nextQueue: DefaultTreeAdapterMap['childNode'][][] = [];
await Promise.all(nodeQueue.map(async (node) => { await Promise.all(nodeQueue.map(async (node) => {
const el = (<DefaultTreeAdapterMap['element']>node); const el = (<DefaultTreeAdapterMap['element']>node);
let toLoad: LoadResult | undefined = undefined; const loadFunction: LoadFunction = async ({
if (el.attrs) { id: sourceId,
toLoad = load ? await load({ source,
node: el, type
sourceId: id })=>{
}) : []; if(!sourceId){
sourceId = makeInlineId(id, node, 'js');
}
if(source){
virtualSources.set(sourceId, source);// TODO actually loading in the virtual source (if any)
} }
if (toLoad) { const resolved = await this.resolve(sourceId, id, {
const loadIds: LoadReference[] = (toLoad instanceof Array) ? toLoad : [toLoad]; isEntry: type==='entryChunk',
for (const loadId of loadIds) { });
loadResults.push({ // if(!resolved){
reference: loadId, // throw new Error(`Could not resolve ${sourceId} from ${id}`);
// }
// const loaded = await this.load({
// id: sourceId,
// resolveDependencies: true,
// moduleSideEffects: 'no-treeshake'
// });
const htmlImport: HtmlImport = {
id: <string>sourceId,
resolved: resolved,
// loaded: loaded,
node: el, 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,
importer: id,
}) : null,
placeholder: `html-import-${crypto.randomBytes(32).toString('base64')}`,
index: htmlImports.length,
} }
htmlImports.push(htmlImport);
return htmlImport.placeholder;
} }
let toLoad: LoadResult | undefined = load? await Promise.resolve(load({
node: el,
sourceId: id
}, loadFunction)) : undefined;
if (toLoad !== false) { if (toLoad !== false) {
let asParent = (<DefaultTreeAdapterMap['parentNode']>node); let asParent = (<DefaultTreeAdapterMap['parentNode']>node);
if (asParent.childNodes) { if (asParent.childNodes) {
@ -208,63 +183,25 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
} while (nodeQueue.length > 0); } 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) let html = serializeHtml(htmlModule.document).replaceAll(/`/g,'\\\`').replaceAll(/\$\{/g,'\\${');
await Promise.all(loadResults.map(async ({reference, node}, index) => { const moduleImports = [];
const refId = reference.get(); for(const htmlImport of htmlImports){
const selfResolvedId = resolve ? resolve(refId, { if(htmlImport.type === 'default') {
sourceId: id, const assetId: string = `asset${moduleImports.length}`;
node, 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..
}) : refId; html = html.replace(htmlImport.placeholder, `\${${assetId}}`);// TODO: Should we be worried about windows absolute URLs here?
const resolvedId: string = selfResolvedId === true ? refId : (<string>selfResolvedId); // }else if(htmlImport.type === 'entryChunk' && htmlImport.referenceId){
if (resolvedId) { // html = html.replace(htmlImport.placeholder, `\${import.meta.ROLLUP_FILE_URL_${htmlImport.referenceId}\}`);
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{ }else{
// Asset // 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)
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.. // html = html.replace(htmlImport.placeholder, htmlImport.loaded?.code||htmlImport.source||'');
htmlImports.push(`import ${assetId} from "${htmlImport.id}";`);// TODO: Should we be worried about windows absolute URLs here?
htmlImport.reference.set(`\${${assetId}}`);
} }
}) }
const htmlJSModule = [ const htmlJSModule = [
...htmlImports, ...moduleImports,
``, ``,
`export const html = \`${serializeHtml(htmlModule.document).replaceAll(/`/g,'\\\`')}\`;`, `export const html = \`${html}\`;`,
`export default html;`, `export default html;`,
].join('\n'); ].join('\n');
return { return {
@ -312,16 +249,26 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
}, },
async generateBundle(outputOptions, bundles){ async generateBundle(outputOptions, bundles){
const bundleItems = Object.entries(bundles); const bundleItems = Object.entries(bundles);
for(let [bundlename, bundle] of bundleItems){ const virtualBundles = new Set<string>();
const facadeToChunk = new Map<string,OutputChunk>();
const htmlResults = new Map<string, {chunk: OutputChunk, htmlModule: HtmlModule}>();
for(const [bundleName, bundle] of bundleItems) {
const chunk = (<OutputChunk>bundle); const chunk = (<OutputChunk>bundle);
if(chunk.facadeModuleId) { if(chunk.facadeModuleId) {
facadeToChunk.set(chunk.facadeModuleId, chunk);
const htmlModule = htmlModules.get(chunk.facadeModuleId); const htmlModule = htmlModules.get(chunk.facadeModuleId);
if (htmlModule) { if(htmlModule){ htmlResults.set(bundleName, {chunk, htmlModule})}
else if(virtualSources.has(chunk.facadeModuleId)){
virtualBundles.add(bundleName);
}
}
}
if(htmlModule.document) { 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. // Delete the placeholder chunk from the bundle and emit an asset file for the HTML instead.
delete bundles[bundlename]; deleteFromBundle(bundleName, bundles);
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) // 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; let htmlContents: string;
@ -356,7 +303,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
err err
].join('\n')) ].join('\n'))
// TODO: We could try to fallback as follows, but the issues are likely to persist in the end result // 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){ // for(const htmlImport of htmlModule.imports){
// if(htmlImport.referenceId) { // if(htmlImport.referenceId) {
// const fileName = this.getFileName(htmlImport.referenceId); // const fileName = this.getFileName(htmlImport.referenceId);
// htmlImport.reference.set(fileName); // htmlImport.reference.set(fileName);
@ -365,6 +312,19 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
// serialized = serializeHtml(htmlModule.document); // 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);
htmlContents = htmlContents.replace(htmlImport.placeholder, relPath);
}
}
}
this.emitFile({ this.emitFile({
type: 'asset', type: 'asset',
name: htmlModule.name, name: htmlModule.name,
@ -375,8 +335,16 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
throw new Error('something went wrong...'); 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?)
}
}

110
src/loader-mappings.ts Normal file
View File

@ -0,0 +1,110 @@
// The loader parses a DOM node and detects which resource (script, style, image, ...) needs to be loaded from it
import type {
NodeMapping,
} from '../types/load.d.ts';
// TODO: specifying ext makes sense for inlined script to convey as what kind of content this should be treated as (i.e. is the inlined script JSX/Typescript/..., or the inlined style CSS/PCSS/SASS. Might be prerrable to support a 'compile-time' ext-attribute on the node)
// but in the case of href/src references, it makes more sense to add it as a meta-data property (conveying how we expect it to be loaded) and the existing filename left as is.
export const KnownMappings : {[name: string]: NodeMapping} = {
externalScript: {
tagName: 'script',
attr: 'src',
loadType: 'entryChunk' // TODO: assuming entryChunk is always the right option for now. However we might want to switch to just chunk and leave it to the rollup to decide if this script should be inlined or not.
},
inlinedScript: {
tagName: 'script',
body: true,
ext: 'js',
loadType: 'chunk'
},
externalStylesheet: {
tagName: 'link',
match: {
attr: {
rel: 'stylesheet'
},
},
attr: 'href',
},
inlinedStylesheet: {
tagName: 'style',
body: true,
ext: 'css',
},
externalResource: { // i.e favicons.
tagName: 'link',
match: {
attr: {
rel: /^(?!.*stylesheet$)/ // Anything that is not rel="stylesheet",
}
},
attr: 'href'
// Could probably use finetuning, see possible values: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel
},
externalImage: {
tagName: 'img',
attr: 'src',
},
link: {
tagName: 'a',
attr: 'href',
},
iframe: {
tagName: 'iframe',
attr: 'src',
},
videoSource: {
tagName: 'source',
attr: 'src'
},
subtitle: {
tagName: 'track',
attr: 'src'
},
audio: {
tagName: 'audio',
attr: 'src'
},
portal: {
tagName: 'portal',
attr: 'src',
},
object: {
tagName: 'object',
attr: 'data'
}
}
export type KnownMappingTypes = keyof typeof KnownMappings;
export const defaultMapping: NodeMapping[] = [
// Scripts
KnownMappings.externalScript,
KnownMappings.inlinedScript,
// Stylesheet
KnownMappings.externalStylesheet,
KnownMappings.inlinedStylesheet,
// Images, svgs
KnownMappings.externalImage,
// Links
// knownMappings.link,
// knownMappings.iframe, // Very unlikely to become a default, but who knows if someone has a valid use for this
// Media
KnownMappings.videoSource,
KnownMappings.subtitle,
KnownMappings.audio,
// Misc
// knownMappings.portal,// <portal src="..."> An experimental 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)
// knownMappings.object,// <object 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)
]

91
src/loader.ts Normal file
View File

@ -0,0 +1,91 @@
// The loader parses a DOM node and detects which resource (script, style, image, ...) needs to be loaded from it
import type {
LoadResult,
LoadNodeCallback,
LoadReference,
NodeMapping,
AttributeReference, BodyReference, LoadedReference
} from '../types/index.d.ts';
import {parseFragment as parseHtmlFragment, serialize as serializeHtml, DefaultTreeAdapterMap} from "parse5";
import {KnownMappings, defaultMapping} from "./loader-mappings.js";
/**
* Makes a unique but human-readable name from a path within a HTML file.
* i.e html.body.script0
* @param node
*/
export function makeHtmlPath(node: DefaultTreeAdapterMap['childNode']){
const path = [];
let cur = node;
while(cur?.parentNode){
const parent = cur.parentNode;
const asElement = (<DefaultTreeAdapterMap['element']>cur);
const similarChildNodes = parent.childNodes?.filter(x=>(<DefaultTreeAdapterMap['element']>x).nodeName == cur.nodeName) || [];
const pathName = `${asElement.tagName}${similarChildNodes.length>1? similarChildNodes.indexOf(cur): ''}`;
path.unshift(pathName);
cur = (<DefaultTreeAdapterMap['childNode']>cur.parentNode);
if((<DefaultTreeAdapterMap['element']>cur).tagName==='html'
&& (!cur.parentNode || cur.parentNode?.nodeName === '#document')
&& (!cur.parentNode || cur.parentNode?.childNodes.length===1)
){
break; // Break early, don't include 'html0' if we can prevent it
}
}
return path.join('.');
}
/**
* // TODO check if this works cross platform (windows)
* @param sourceId
* @param node
* @param ext
*/
export function makeInlineId(sourceId: string, node: DefaultTreeAdapterMap['childNode'], ext = '.js'){
return [sourceId, [makeHtmlPath(node), 'js'].join('.')].join('.');
}
export function makeLoader(mappings: NodeMapping[] = defaultMapping){
const fn : LoadNodeCallback = async function ({node, sourceId}, load){
for(const mapping of mappings){
if (mapping.tagName && mapping.tagName !== node.tagName) continue; // No match, skip
if (mapping.match){
if(typeof(mapping.match) === 'function'){
if(!mapping.match(node)) continue;
}else{
if(mapping.match.body && !(node.childNodes?.length>0)) continue; // No match, skip
if(mapping.match.attr) {
for (const [attrName, attrMatch] of Object.entries(mapping.match.attr)) {
if(!node.attrs.find(attr=>{
if(attr.name !== attrName) return false;
if(typeof(attrMatch) === 'string') return attrMatch === attr.value;
if(attrMatch instanceof RegExp) return !!(attrMatch.exec(attr.value));
if(typeof(attrMatch) === 'function') return attrMatch(attr.value);
})) continue; // No match, skip
}
}
}
}
if((<AttributeReference>mapping).attr){
const attr = node.attrs.find(attr=>attr.name === (<AttributeReference>mapping).attr);
if(!attr) continue ;// No match, skip
const placeholder = await load({
id: attr.value,
type: mapping.loadType||'default', // Use the default export unless explicitely mapped differently
});
attr.value = placeholder;
}else if((<BodyReference>mapping).body){
const body = serializeHtml(node); // unlike what you' might expect, this doesn't serialize the <script>-tag itself, only its contents. Which is what we want.
if(!body) continue; // Empty body, skip
const placeholder = await load({
source: body,
type: mapping.loadType||'chunk'
});
node.childNodes = parseHtmlFragment(placeholder).childNodes;
return false;
}
}
}
return fn;
}

View File

@ -1,4 +1,2 @@
export const test = ()=>{ export const b = ()=>'batman';
return `I'm "annoying" ${"in case we need to test \`string\` escaping."}. Hence this files \'tries\' to include all allowed forms of 'it'`; console.log(b());
}
console.log(test());

View File

@ -0,0 +1,12 @@
<html>
<head>
</head>
<body>
<script type="module">
import {b} from "./batman.js";
document.body.appendChild(
document.createTextNode(`Inline script including ${b()}`)
);
</script>
</body>
</html>

View File

@ -10,27 +10,23 @@ Generated by [AVA](https://avajs.dev).
[ [
{ {
code: `const test = ()=>{␊ code: `const b = ()=>'batman';␊
return \`I'm "annoying" ${"in case we need to test \\\`string\\\` escaping."}. Hence this files \\'tries\\' to include all allowed forms of 'it'\`;␊ console.log(b());␊
};␊
console.log(test());␊
export { test };␊ export { b };␊
//# sourceMappingURL=batman-9dbe0e1d.js.map␊ //# sourceMappingURL=batman-c7fa228c.js.map␊
`, `,
fileName: 'batman-9dbe0e1d.js', fileName: 'batman-c7fa228c.js',
map: SourceMap { map: SourceMap {
file: 'batman-9dbe0e1d.js', file: 'batman-c7fa228c.js',
mappings: 'AAAY,MAAC,IAAI,GAAG,IAAI;AACxB,IAAI,OAAO,CAAC,eAAe,EAAE,8CAA8C,CAAC,iEAAiE,CAAC,CAAC;AAC/I,EAAC;AACD,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;;;;', mappings: 'AAAY,MAAC,CAAC,GAAG,IAAI,SAAS;AAC9B,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;;;;',
names: [], names: [],
sources: [ sources: [
'../batman.js', '../batman.js',
], ],
sourcesContent: [ sourcesContent: [
`export const test = ()=>{␊ `export const b = ()=>'batman';␊
return \`I'm "annoying" ${"in case we need to test \\\`string\\\` escaping."}. Hence this files \\'tries\\' to include all allowed forms of 'it'\`;␊ console.log(b());␊
}␊
console.log(test());␊
`, `,
], ],
version: 3, version: 3,
@ -39,9 +35,9 @@ Generated by [AVA](https://avajs.dev).
}, },
{ {
code: undefined, code: undefined,
fileName: 'batman-9dbe0e1d.js.map', fileName: 'batman-c7fa228c.js.map',
map: undefined, map: undefined,
source: '{"version":3,"file":"batman-9dbe0e1d.js","sources":["../batman.js"],"sourcesContent":["export const test = ()=>{\\n return `I\'m \\"annoying\\" ${\\"in case we need to test \\\\`string\\\\` escaping.\\"}. Hence this files \\\\\'tries\\\\\' to include all allowed forms of \'it\'`;\\n}\\nconsole.log(test());\\n"],"names":[],"mappings":"AAAY,MAAC,IAAI,GAAG,IAAI;AACxB,IAAI,OAAO,CAAC,eAAe,EAAE,8CAA8C,CAAC,iEAAiE,CAAC,CAAC;AAC/I,EAAC;AACD,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;;;;"}', source: '{"version":3,"file":"batman-c7fa228c.js","sources":["../batman.js"],"sourcesContent":["export const b = ()=>\'batman\';\\nconsole.log(b());\\n"],"names":[],"mappings":"AAAY,MAAC,CAAC,GAAG,IAAI,SAAS;AAC9B,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;;;;"}',
}, },
{ {
code: undefined, code: undefined,
@ -50,7 +46,39 @@ Generated by [AVA](https://avajs.dev).
source: `<html><head> source: `<html><head>
</head> </head>
<body> <body>
<script src="batman-9dbe0e1d.js" type="module"></script> <script src="batman-c7fa228c.js" type="module"></script>
</body></html>`,
},
]
## inline-script
> Snapshot 1
[
{
code: undefined,
fileName: 'script.html.body.script-e3b82208.js.map',
map: undefined,
source: '{"version":3,"file":"script.html.body.script-e3b82208.js","sources":["../batman.js","../script.html.body.script.js"],"sourcesContent":["export const b = ()=>\'batman\';\\nconsole.log(b());\\n","\\n import {b} from \\"./batman.js\\";\\n document.body.appendChild(\\n document.createTextNode(`Inline script including ${b()}`)\\n );\\n "],"names":[],"mappings":"AAAO,MAAM,CAAC,GAAG,IAAI,QAAQ,CAAC;AAC9B,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;;ACCJ,QAAQ,CAAC,IAAI,CAAC,WAAW;AACrC,gBAAgB,QAAQ,CAAC,cAAc,CAAC,CAAC,wBAAwB,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AACzE,aAAa"}',
},
{
code: undefined,
fileName: 'script.html',
map: undefined,
source: `<html><head>
</head>
<body>
<script type="module">const b = ()=>'batman';
console.log(b());␊
document.body.appendChild(␊
document.createTextNode(\`Inline script including ${b()}\`)␊
);␊
//# sourceMappingURL=script.html.body.script-e3b82208.js.map␊
</script>
</body></html>`, </body></html>`,

Binary file not shown.

View File

@ -30,6 +30,19 @@ test.serial('simple', async (t) => {
t.snapshot(code); t.snapshot(code);
}); });
test.serial('inline-script', async (t) => {
const bundle = await rollup({
input: 'script.html',
plugins: [
html({
}),
]
});
const code = await getCode(bundle, output, true);
debugPrintOutput('inline-script',code);
t.snapshot(code);
});
// TODO various parameters // TODO various parameters
// - format: cjs, iifi, ... // - format: cjs, iifi, ...
// - sourcemap: inline, false, (and the various exotic sourcemap options) // - sourcemap: inline, false, (and the various exotic sourcemap options)

23
types/index.d.ts vendored
View File

@ -3,28 +3,19 @@ import {FilterPattern} from "@rollup/pluginutils";
import type {DefaultTreeAdapterMap} from "parse5"; import type {DefaultTreeAdapterMap} from "parse5";
import {PreRenderedChunk} from "rollup"; import {PreRenderedChunk} from "rollup";
import type {LoadNodeCallback} from "./load.d.ts";
export type * from "./load.d.ts"
import type {ResolveCallback} from "./resolve.d.ts";
export type * from "./resolve.d.ts"
export interface RollupHtmlTransformContext { export interface RollupHtmlTransformContext {
id?: string; id?: string;
// bundle: OutputBundle; // bundle: OutputBundle;
// files: Record<string, (OutputChunk | OutputAsset)[]>; // files: Record<string, (OutputChunk | OutputAsset)[]>;
} }
export interface RollupHtmlLoadContext {
node: DefaultTreeAdapterMap['element'];
sourceId: string;
}
export interface RollupHtmlResolveContext {
node: DefaultTreeAdapterMap['element'];
sourceId: string;
}
export type TransformCallback = (source: string, transformContext: RollupHtmlTransformContext) => string|Promise<string>; export type TransformCallback = (source: string, transformContext: RollupHtmlTransformContext) => string|Promise<string>;
export type LoadReference = {get: ()=>string, set: (id: string)=>void};
export type LoadResult = LoadReference|LoadReference[]|undefined|void|false;
export type LoadNodeCallback = (loadContext: RollupHtmlLoadContext) => LoadResult|Promise<LoadResult>;
export type ResolveResult = string|true|undefined|void|false;
export type ResolveCallback = (id: string, resolveContext: RollupHtmlResolveContext) => ResolveResult|Promise<ResolveResult>;
export interface RollupHtmlOptions { export interface RollupHtmlOptions {
publicPath?: string; publicPath?: string;
@ -46,7 +37,7 @@ export interface RollupHtmlOptions {
* Detect which references (<a href="...">, <img src="...">) to resolve from a HTML node. * Detect which references (<a href="...">, <img src="...">) to resolve from a HTML node.
* This rarely needs to be overloaded, but can be used to support non-native attributes used by custom-elements. * This rarely needs to be overloaded, but can be used to support non-native attributes used by custom-elements.
* *
* Return false to skip any further processing on this node. Else return the id's the resolve based on this node * Return false to skip any further processing on this node. Use the load function to add any resources from this node, and replace the import with a placeholder so the plugin knows where to inject the end result
*/ */
load?: LoadNodeCallback; load?: LoadNodeCallback;
/** /**

79
types/load.d.ts vendored Normal file
View File

@ -0,0 +1,79 @@
import type {DefaultTreeAdapterMap} from "parse5";
// Load hook types
export interface RollupHtmlLoadContext {
node: DefaultTreeAdapterMap['element'];
sourceId: string;
}
export type AttributeReference = {
attr: string;
};
export type BodyReference = {
/**
* Indiciate this is an inlined reference (node body)
*/
body: boolean;
/**
* Describes what the content type is. I.e 'js' for inlined <script>, 'css' for inlined <style>
*/
ext?: string;
};
/**
* Describes how a resource should be loaded.
*/
export type LoadReference = AttributeReference | BodyReference
/**
* Indicate how to load this resource:
* - 'default' uses the default export of the referenced id
* - 'chunk' use the rendered chunk of this file (e.g inlined JS)
* - 'entryChunk' mark this resource as its own entry-chunk and use its rendered output path
* // TODO: add a type 'asset' here, in which we use rollups emitFile({type:'asset'} feature (which reduces the need for plugin-url, and probably makes more sense as the default option instead of 'default' in zero-config scenarios)
*/
export type LoadType = 'default'|'chunk'|'entryChunk';
export type LoadedReference = (
{
// External (virtual) reference
id: string; // path/url referenced. Or identifier for the virtual source
source?: string; // Source to use for this id, for inlined chunks
} | {
// Inline
id?: string; // A unique identifier for snippet
source: string; // Source to use for this id, for inlined chunks
}
) & {
type?: LoadType
};
export type LoadResult = undefined|void|false;
export type LoadFunction = (reference: LoadedReference)=>Promise<string>
export type LoadNodeCallback = (loadContext: RollupHtmlLoadContext, load: LoadFunction) => LoadResult|Promise<LoadResult>;
// Make load hook mapping
/**
* Describes which DOM nodes to extract references from
*/
export type NodeMapping = {
tagName?: string;
/** Filter to specific properties to DOM node must have nodes. TODO allowing a callback here probably makes sense */
match?: ({
/** Whether the element must have a non-null body */
body?: boolean
/** Which additional attributes the element must have to match */
attr?: {[attrName: string]: (string|RegExp|((value:string)=>boolean))}
} | ((el: DefaultTreeAdapterMap['element'])=>boolean));
/**
* Indicate how to load this resource:
* - 'default' uses the default export of the referenced id
* - 'chunk' use the rendered chunk of this file (e.g inlined JS)
* - 'entryChunk' mark this resource as its own entry-chunk and use its rendered output path
*/
loadType?: LoadType
} & LoadReference;

8
types/resolve.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import type {DefaultTreeAdapterMap} from "parse5";
export interface RollupHtmlResolveContext {
node: DefaultTreeAdapterMap['element'];
sourceId: string;
}
export type ResolveResult = string|true|undefined|void|false;
export type ResolveCallback = (id: string, resolveContext: RollupHtmlResolveContext) => ResolveResult|Promise<ResolveResult>;