Compare commits

..

3 Commits

Author SHA1 Message Date
ba07649981 0.0.2: Updated docs to reflect current state
Some checks failed
continuous-integration/drone/push Build is failing
2023-05-02 15:47:23 +02:00
9768b3efe5 Supporting multri entry 2023-05-02 05:05:42 +02:00
4006f3954e Refactoring to support inlined scripts 2023-05-02 03:46:47 +02:00
21 changed files with 779 additions and 238 deletions

View File

@ -1,2 +1,28 @@
# 0.0.2
Private release update. Added experimental support for:
- multiple-entrypoints (i.e index.html and admin/index.html)
- Inlined scripts (i.e <script type="module">...</script>)
# 0.0.1
Initial private release
# Open issues / Short-term ToDo's:
- Implement importing style (#1 linking to a pcss, #2 inlined style)
- Importing html as a JSModule
- Testing on a windows machine and fix whatever issues with paths that come out of it
- Code clean-up / Watch-mode support
- Properly use 'meta' property, and supporting caching
- Supporting 'assets' directly (LoadType) using emitFile({type:'asset',...}). Removes the need for @rollup/plugin-url in small projects (altough it is still the preferred way of including assets)
- Getting rid of the module evaluation step if possible
- Clean up our API, keeping in mind the configurability desired:
- resolving language for inline script/style
- excluding non-relative imports (ie unpkg stuff etc)
- customizing how to import certain things (LoadType)
- support for typescript (might not need extra work, but it should be integrated in tests)
- cjs & iifi supported in tests

View File

@ -95,26 +95,29 @@ By default, this plugin supports the `esm` (`es`). Any other format is currently
## Status
This plugin is in an early state. As such not everything that is supported yet, and the options may change.
This plugin is in an early state. As such not everything that is supported yet, the options are laregely undocumented and may change.
### (Rudimentarily) supported
- Importing JS via `<script src="..." type="module">` tags
- Importing assets using @rollup/plugin-url (which could use an update TBH)
- Compatibility with other plugins such as @rollup/plugin-node-resolve, @rollup/plugin-babel, @rollup/plugin-commonjs, @rollup/plugin-terser and rollup-plugin-livereload
### Not (yet) supported
- Inline scripts (i.e `<script>...</script>`)
### Not (yet/properly) supported
- Plugins importing CSS files
- CommonJS (cjs) and IIFI output formats. (Is UMD actually ever used?)
- Overriding which tags to ignore/include
- Other (various) plugins such as those for HMR etc
- Overriding which DOM-nodes and resulting URLS to ignore/include (in a clean way)
- Other (various) plugins such as typescript, or those for HMR etc
- ...
# Contibuting
You can be helpful by testing, proving helpful feedback, expanding the documentation, responding to issues/questions being reported, resolving the many ToDo`s in the code, implementating features...\
[Get in touch](mailto:rollup-plugin-html-entry2@cerxes.net) or just dive into [the code](https://git.cerxes.net/rollup-apps/plugin-html) or [issues](https://git.cerxes.net/rollup-apps/plugin-html/issues)
[Get in touch](mailto:rollup-plugin-html-entry2@cerxes.net) or just dive into [the code](https://git.cerxes.net/rollup-apps/plugin-html) or [issues](https://git.cerxes.net/rollup-apps/plugin-html/issues).
See also the ToDo-list at the end of the [changelog](./CHANGELOG.md)
# Notes
## git.cerxes.net

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,
RollupHtmlOptions,
LoadNodeCallback,
LoadReference
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"
const getFiles = (bundle: OutputBundle): Record<string, (OutputChunk | OutputAsset)[]> => {
const result = {} as ReturnType<typeof getFiles>;
for (const file of Object.values(bundle)) {
const { fileName } = file;
const extension = extname(fileName).substring(1);
import {makeLoader, makeInlineId} from "./loader.js";
import {HtmlImport, HtmlModule} from "./html-module.js";
result[extension] = (result[extension] || []).concat(file);
}
import {dirname} from "node:path";
import posix from "node:path/posix";
return result;
};
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;
}
import crypto from "node:crypto";
const defaults: RollupHtmlOptions = {
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 moduleSuffix = `// </html-module>`;
@ -118,14 +62,17 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
let filter = createFilter(include, exclude, {});
let htmlModules = new Map<string, HtmlModule>();// todo clean this per new build?
let virtualSources = new Map<string, string>();
const pluginName = 'html2';
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: pluginName,// 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(virtualSources.has(specifier)) return specifier;
if(!filter(specifier)) return;
// Let it be resolved like others (node_modules, project aliases, ..)
@ -141,7 +88,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
const htmlModule : HtmlModule = htmlModules.get(moduleId) ?? {
id: resolved.id,
name: moduleName,
resolved: [],
imports: [],
assetId: null,
importers: new Set(),
};
@ -149,11 +96,19 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
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: {
async handler(id: string) {
if(virtualSources.has(id)) return virtualSources.get(id);
if(!filter(id)) return;
// Load
@ -166,37 +121,64 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
}) : 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;
}
const document = htmlModule.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 loadResults: { reference: LoadReference, node: DefaultTreeAdapterMap['element'] }[] = [];
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);
let toLoad: LoadResult | undefined = undefined;
if (el.attrs) {
toLoad = load ? await load({
node: el,
sourceId: id
}) : [];
const loadFunction: LoadFunction = async ({
id: sourceId,
source,
type
})=>{
if(!sourceId){
sourceId = makeInlineId(id, node, 'js');
}
if(source){
virtualSources.set(sourceId, source);
}
if (toLoad) {
const loadIds: LoadReference[] = (toLoad instanceof Array) ? toLoad : [toLoad];
for (const loadId of loadIds) {
loadResults.push({
reference: loadId,
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);
const importName = (source && selfInfo?.meta[pluginName].name) ? makeInlineId(selfInfo?.meta[pluginName].name, node, extname(sourceId)) : undefined;
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,
}
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) {
@ -208,63 +190,26 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
} 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}\}`);
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{
// 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}}`);
// 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 = [
...htmlImports,
...moduleImports,
``,
`export const html = \`${serializeHtml(htmlModule.document).replaceAll(/`/g,'\\\`')}\`;`,
`export const html = \`${html}\`;`,
`export default html;`,
].join('\n');
return {
@ -312,16 +257,26 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
},
async generateBundle(outputOptions, 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);
if(chunk.facadeModuleId) {
facadeToChunk.set(chunk.facadeModuleId, chunk);
const htmlModule = htmlModules.get(chunk.facadeModuleId);
if (htmlModule) {
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.
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?)
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;
@ -356,7 +311,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
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){
// for(const htmlImport of htmlModule.imports){
// if(htmlImport.referenceId) {
// const fileName = this.getFileName(htmlImport.referenceId);
// htmlImport.reference.set(fileName);
@ -365,6 +320,19 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
// 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({
type: 'asset',
name: htmlModule.name,
@ -375,8 +343,16 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin {
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 = ()=>{
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(test());
export const b = ()=>'batman';
console.log(b());

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 = ()=>{␊
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(test());␊
code: `const b = ()=>'batman';␊
console.log(b());␊
export { test };␊
//# sourceMappingURL=batman-9dbe0e1d.js.map␊
export { b };␊
//# sourceMappingURL=batman-c7fa228c.js.map␊
`,
fileName: 'batman-9dbe0e1d.js',
fileName: 'batman-c7fa228c.js',
map: SourceMap {
file: 'batman-9dbe0e1d.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;;;;',
file: 'batman-c7fa228c.js',
mappings: 'AAAY,MAAC,CAAC,GAAG,IAAI,SAAS;AAC9B,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;;;;',
names: [],
sources: [
'../batman.js',
],
sourcesContent: [
`export const test = ()=>{␊
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(test());␊
`export const b = ()=>'batman';␊
console.log(b());␊
`,
],
version: 3,
@ -39,9 +35,9 @@ Generated by [AVA](https://avajs.dev).
},
{
code: undefined,
fileName: 'batman-9dbe0e1d.js.map',
fileName: 'batman-c7fa228c.js.map',
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,
@ -50,7 +46,39 @@ Generated by [AVA](https://avajs.dev).
source: `<html><head>
</head>
<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.js-e3b82208.js.map',
map: undefined,
source: '{"version":3,"file":"script.html.body.script.js-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.js-e3b82208.js.map␊
</script>
</body></html>`,

Binary file not shown.

View File

@ -30,6 +30,19 @@ test.serial('simple', async (t) => {
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
// - format: cjs, iifi, ...
// - sourcemap: inline, false, (and the various exotic sourcemap options)

View File

@ -0,0 +1,12 @@
<html>
<head>
</head>
<body>
<div id="root"></div>
<script type="module">
import {bootstrap} from "../app/app.js"
import {adminDeps} from "../app/admin-deps.js";
bootstrap(document.getElementById('root'), adminDeps());
</script>
</body>
</html>

View File

@ -0,0 +1,3 @@
export function adminDeps(){
return "robin!";
}

View File

@ -0,0 +1,6 @@
export const bootstrap = (el,deps = [])=>{
el.innerHtml = `
<div>I'm "annoying" ${"in case we need to test \`string\` escaping."}. Hence this file \'tries\' to include all allowed forms of 'it'</div>
<div>Deps: ${deps}</div>
`;
}

View File

@ -0,0 +1,11 @@
<html>
<head>
</head>
<body>
<div id="root"></div>
<script type="module">
import {bootstrap} from "./app/app.js"
bootstrap(document.getElementById('root'), "<none>");
</script>
</body>
</html>

View File

@ -0,0 +1,100 @@
# Snapshot report for `test/multi-entry/test.js`
The actual snapshot is saved in `test.js.snap`.
Generated by [AVA](https://avajs.dev).
## multi-entry
> Snapshot 1
[
{
code: `const bootstrap = (el,deps = [])=>{␊
el.innerHtml = \`␊
<div>I'm "annoying" ${"in case we need to test \\\`string\\\` escaping."}. Hence this file \\'tries\\' to include all allowed forms of 'it'</div>
<div>Deps: ${deps}</div>
\`;␊
};␊
export { bootstrap as b };␊
//# sourceMappingURL=app-01141b67.js.map␊
`,
fileName: 'app-01141b67.js',
map: SourceMap {
file: 'app-01141b67.js',
mappings: 'AAAY,MAAC,SAAS,GAAG,CAAC,EAAE,CAAC,IAAI,GAAG,EAAE,GAAG;AACzC,IAAI,EAAE,CAAC,SAAS,GAAG,CAAC;AACpB,4BAA4B,EAAE,8CAA8C,CAAC;AAC7E,mBAAmB,EAAE,IAAI,CAAC;AAC1B,IAAI,CAAC,CAAC;AACN;;;;',
names: [],
sources: [
'../app/app.js',
],
sourcesContent: [
`export const bootstrap = (el,deps = [])=>{␊
el.innerHtml = \`␊
<div>I'm "annoying" ${"in case we need to test \\\`string\\\` escaping."}. Hence this file \\'tries\\' to include all allowed forms of 'it'</div>
<div>Deps: ${deps}</div>
\`;␊
}␊
`,
],
version: 3,
},
source: undefined,
},
{
code: undefined,
fileName: 'index.html.body.script.js-45303f0f.js.map',
map: undefined,
source: '{"version":3,"file":"index.html.body.script.js-45303f0f.js","sources":["../index.html.body.script.js"],"sourcesContent":["\\n import {bootstrap} from \\"./app/app.js\\"\\n bootstrap(document.getElementById(\'root\'), \\"<none>\\");\\n "],"names":[],"mappings":";;AAEY,SAAS,CAAC,QAAQ,CAAC,cAAc,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC"}',
},
{
code: undefined,
fileName: 'admin/index.html.body.script.js-15dfaff3.js.map',
map: undefined,
source: '{"version":3,"file":"index.html.body.script.js-15dfaff3.js","sources":["../../app/admin-deps.js","../../admin/index.html.body.script.js"],"sourcesContent":["export function adminDeps(){\\n return \\"robin!\\";\\n}\\n","\\n import {bootstrap} from \\"../app/app.js\\"\\n import {adminDeps} from \\"../app/admin-deps.js\\";\\n bootstrap(document.getElementById(\'root\'), adminDeps());\\n "],"names":[],"mappings":";;AAAO,SAAS,SAAS,EAAE;AAC3B,IAAI,OAAO,QAAQ,CAAC;AACpB;;ACCY,SAAS,CAAC,QAAQ,CAAC,cAAc,CAAC,MAAM,CAAC,EAAE,SAAS,EAAE,CAAC"}',
},
{
code: undefined,
fileName: 'app-01141b67.js.map',
map: undefined,
source: '{"version":3,"file":"app-01141b67.js","sources":["../app/app.js"],"sourcesContent":["export const bootstrap = (el,deps = [])=>{\\n el.innerHtml = `\\n <div>I\'m \\"annoying\\" ${\\"in case we need to test \\\\`string\\\\` escaping.\\"}. Hence this file \\\\\'tries\\\\\' to include all allowed forms of \'it\'</div>\\n <div>Deps: ${deps}</div>\\n `;\\n}\\n"],"names":[],"mappings":"AAAY,MAAC,SAAS,GAAG,CAAC,EAAE,CAAC,IAAI,GAAG,EAAE,GAAG;AACzC,IAAI,EAAE,CAAC,SAAS,GAAG,CAAC;AACpB,4BAA4B,EAAE,8CAA8C,CAAC;AAC7E,mBAAmB,EAAE,IAAI,CAAC;AAC1B,IAAI,CAAC,CAAC;AACN;;;;"}',
},
{
code: undefined,
fileName: 'index.html',
map: undefined,
source: `<html><head>
</head>
<body>
<div id="root"></div>
<script type="module">import { b as bootstrap } from './app-01141b67.js';
bootstrap(document.getElementById('root'), "<none>");␊
//# sourceMappingURL=index.html.body.script.js-45303f0f.js.map␊
</script>
</body></html>`,
},
{
code: undefined,
fileName: 'admin/index.html',
map: undefined,
source: `<html><head>
</head>
<body>
<div id="root"></div>
<script type="module">import { b as bootstrap } from '../app-01141b67.js';
function adminDeps(){␊
return "robin!";␊
}␊
bootstrap(document.getElementById('root'), adminDeps());␊
//# sourceMappingURL=index.html.body.script.js-15dfaff3.js.map␊
</script>
</body></html>`,
},
]

Binary file not shown.

40
test/multi-entry/test.js Normal file
View File

@ -0,0 +1,40 @@
import {join, dirname} from "node:path";
import test from "ava";
import { rollup } from "rollup";
import {debugPrintOutput, getCode} from "../util/test.js";
import html from "../../src/index.ts";
const output = {
dir: 'output', // Output all files
format: 'es', // iifi and cjs should be added to tests
sourcemap: true,// Test if #sourcemapUrl is not accidentally included in the html-output
};
import {fileURLToPath} from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
process.chdir(join(__dirname, 'fixtures'));
test.serial('multi-entry', async (t) => {
const bundle = await rollup({
input: {
['index']: 'index.html',
['admin/index']: 'admin/index.html'
},
plugins: [
html({
}),
]
});
const code = await getCode(bundle, output, true);
debugPrintOutput('multi-entry',code);
t.snapshot(code);
});
// TODO various parameters
// - format: cjs, iifi, ...
// - sourcemap: inline, false, (and the various exotic sourcemap options)
// Watch mode tests would be its own dir
// ...

23
types/index.d.ts vendored
View File

@ -3,28 +3,19 @@ import {FilterPattern} from "@rollup/pluginutils";
import type {DefaultTreeAdapterMap} from "parse5";
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 {
id?: string;
// bundle: OutputBundle;
// 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 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 {
publicPath?: string;
@ -46,7 +37,7 @@ export interface RollupHtmlOptions {
* 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.
*
* 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;
/**

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>;