wip: reorganize plugin into its hooks
This commit is contained in:
parent
93f99c732f
commit
2d4d097560
142
src/plugin/hooks/generate-bundle.ts
Normal file
142
src/plugin/hooks/generate-bundle.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import type {
|
||||||
|
OutputBundle,
|
||||||
|
OutputChunk,
|
||||||
|
Plugin,
|
||||||
|
} from 'rollup';
|
||||||
|
import {dirname} from "node:path";
|
||||||
|
// nodejs imports (io, path)
|
||||||
|
import path from "node:path";
|
||||||
|
import posix from "node:path/posix";
|
||||||
|
|
||||||
|
// utilities
|
||||||
|
import {HtmlModule} from "../../types/html-module.ts";
|
||||||
|
|
||||||
|
|
||||||
|
export interface RewriteUrlCallbackContext {
|
||||||
|
from: string;
|
||||||
|
rootPath: string;
|
||||||
|
}
|
||||||
|
export type RewriteUrlCallback = (relative: string, context: RewriteUrlCallbackContext) => string|Promise<string>;
|
||||||
|
|
||||||
|
export function generateBundle({
|
||||||
|
virtualSources,
|
||||||
|
pluginName, modulePrefix, rewriteUrl
|
||||||
|
}: {
|
||||||
|
virtualSources: Map<string, string>,
|
||||||
|
pluginName: string,
|
||||||
|
modulePrefix: string,
|
||||||
|
rewriteUrl?: RewriteUrlCallback
|
||||||
|
}): Plugin['generateBundle']{
|
||||||
|
return {
|
||||||
|
async handler(outputOptions, bundles){
|
||||||
|
const bundleItems = Object.entries(bundles);
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
3
src/plugin/hooks/index.ts
Normal file
3
src/plugin/hooks/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./resolve-id.ts";
|
||||||
|
export * from "./transform.ts";
|
||||||
|
export * from "./generate-bundle.ts";
|
||||||
51
src/plugin/hooks/resolve-id.ts
Normal file
51
src/plugin/hooks/resolve-id.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type {
|
||||||
|
Plugin,
|
||||||
|
} from 'rollup';
|
||||||
|
import {createFilter} from '@rollup/pluginutils';
|
||||||
|
import {extname} from "node:path";
|
||||||
|
|
||||||
|
|
||||||
|
export function resolveId({
|
||||||
|
virtualSources,
|
||||||
|
filter,
|
||||||
|
pluginName
|
||||||
|
}: {
|
||||||
|
virtualSources: Map<string, string>,
|
||||||
|
filter: ReturnType<typeof createFilter>,
|
||||||
|
pluginName: string
|
||||||
|
}): Plugin['resolveId']{
|
||||||
|
return {
|
||||||
|
handler: async function (specifier,
|
||||||
|
importer,
|
||||||
|
options){
|
||||||
|
if(virtualSources.has(specifier)) return specifier; // Resolve virtual sources we added
|
||||||
|
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 moduleExt = extname(resolved.id);
|
||||||
|
const moduleName = specifier.replace(new RegExp(`${moduleExt}\$`),''); // strip extension of the name if any
|
||||||
|
|
||||||
|
return {
|
||||||
|
...resolved,
|
||||||
|
meta: {
|
||||||
|
...resolved.meta,
|
||||||
|
[pluginName]: {
|
||||||
|
specifier: specifier,
|
||||||
|
id: resolved.id,
|
||||||
|
name: moduleName,
|
||||||
|
imports: [],
|
||||||
|
assetId: null,
|
||||||
|
importers: new Set(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/plugin/hooks/transform.ts
Normal file
200
src/plugin/hooks/transform.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import type {
|
||||||
|
Plugin,
|
||||||
|
} from 'rollup';
|
||||||
|
import {createFilter} from '@rollup/pluginutils';
|
||||||
|
import {extname} from "node:path";
|
||||||
|
|
||||||
|
// parse5 package is used for parsing HTML.
|
||||||
|
import {parse as parseHtml, serialize as serializeHtml, DefaultTreeAdapterMap} from "parse5";
|
||||||
|
// magic-string to transform code and keeping a sourcemap aligned
|
||||||
|
import MagicString from "magic-string";
|
||||||
|
|
||||||
|
// nodejs imports (io, path)
|
||||||
|
import posix from "node:path/posix";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
import type {LoadNodeCallback, LoadFunction, LoadResult} from "../../types/loader.ts";
|
||||||
|
|
||||||
|
// utilities
|
||||||
|
import {makeInlineId} from "../../loader/loader.ts";
|
||||||
|
import {HtmlImport} from "../../types/html-module.ts";
|
||||||
|
|
||||||
|
|
||||||
|
export interface RollupHtmlTransformContext {
|
||||||
|
id?: string;
|
||||||
|
// bundle: OutputBundle;
|
||||||
|
// files: Record<string, (OutputChunk | OutputAsset)[]>;
|
||||||
|
}
|
||||||
|
export type TransformCallback = (source: string, transformContext: RollupHtmlTransformContext) => string|Promise<string>;
|
||||||
|
|
||||||
|
|
||||||
|
export function transform({
|
||||||
|
virtualSources,
|
||||||
|
filter,
|
||||||
|
entryNames,
|
||||||
|
pluginName,
|
||||||
|
transform,
|
||||||
|
load,
|
||||||
|
}: {
|
||||||
|
virtualSources: Map<string, string>,
|
||||||
|
filter: ReturnType<typeof createFilter>,
|
||||||
|
entryNames: Map<string,string>,
|
||||||
|
pluginName: string,
|
||||||
|
transform: TransformCallback,
|
||||||
|
load: LoadNodeCallback
|
||||||
|
}): Plugin['transform']{
|
||||||
|
return {
|
||||||
|
order: 'pre',
|
||||||
|
async handler(...args){
|
||||||
|
const [code, id] = args;
|
||||||
|
if (!filter(id)) return;
|
||||||
|
|
||||||
|
// parse
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const contents = code;
|
||||||
|
|
||||||
|
const htmlSrc = transform ? await transform(contents, {
|
||||||
|
id,
|
||||||
|
}) : contents;
|
||||||
|
|
||||||
|
// Parse document and store it
|
||||||
|
const document = htmlModule.document = parseHtml(htmlSrc);
|
||||||
|
|
||||||
|
// TODO working on this: to preserve sourcemaps as much as possible we're starting the magic string on the raw html source
|
||||||
|
// question is if we need to though. sourcemaps only make sense for inlined bits of code
|
||||||
|
//let htmlJS = new MagicString(htmlSrc);// This is where we want to go!
|
||||||
|
|
||||||
|
// 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, {
|
||||||
|
skipSelf: false, // defaults to true since rollup 4, and for virtual files this is problematic
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beware leak of AST (we're starting MagicString on a parsed and modified version of the HTML file, sourcemappings in the HTML file will be off. (can't add a sourcemap for a html file anyway, unless it is outputted as JS module)
|
||||||
|
// @ts-ignore
|
||||||
|
let htmlJS = new MagicString(serializeHtml(htmlModule.document));
|
||||||
|
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{
|
||||||
|
// 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||'');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import all dependencies and wrap the HTML in a `...`, assign to a var and export (escaping any ` characters in the HTML)
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,11 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Plugin,
|
Plugin,
|
||||||
OutputBundle,
|
OutputBundle,
|
||||||
OutputChunk,
|
|
||||||
OutputAsset,
|
|
||||||
NormalizedOutputOptions,
|
|
||||||
// ModuleInfo,
|
|
||||||
ResolvedId,
|
|
||||||
PreRenderedChunk,
|
PreRenderedChunk,
|
||||||
RenderedChunk,
|
RenderedChunk,
|
||||||
} from 'rollup';
|
} from 'rollup';
|
||||||
@ -13,37 +8,19 @@ import type {
|
|||||||
// createFilter function is a utility that constructs a filter function from include/exclude patterns.
|
// createFilter function is a utility that constructs a filter function from include/exclude patterns.
|
||||||
import {createFilter} from '@rollup/pluginutils';
|
import {createFilter} from '@rollup/pluginutils';
|
||||||
import type {FilterPattern} from "@rollup/pluginutils";
|
import type {FilterPattern} from "@rollup/pluginutils";
|
||||||
// parse5 package is used for parsing HTML.
|
|
||||||
import {parse as parseHtml, serialize as serializeHtml, DefaultTreeAdapterMap} from "parse5";
|
|
||||||
// magic-string to transform code and keeping a sourcemap aligned
|
|
||||||
import MagicString from "magic-string";
|
|
||||||
|
|
||||||
|
|
||||||
// nodejs imports (io, path)
|
|
||||||
import path, { extname, dirname } from "node:path";
|
|
||||||
import {readFile} from "node:fs/promises"
|
|
||||||
import posix from "node:path/posix";
|
|
||||||
import crypto from "node:crypto";
|
|
||||||
|
|
||||||
// utilities
|
// utilities
|
||||||
import {makeLoader, makeInlineId} from "../loader/loader.ts";
|
import {makeLoader} from "../loader/loader.ts";
|
||||||
import {HtmlImport, HtmlModule} from "../types/html-module.ts";
|
|
||||||
|
|
||||||
import type {LoadNodeCallback, LoadFunction, LoadResult} from "../types/loader.ts";
|
import type {LoadNodeCallback} from "../types/loader.ts";
|
||||||
import type {ResolveCallback} from "../types/resolve.d.ts";
|
import type {ResolveCallback} from "../types/resolve.d.ts";
|
||||||
|
import * as hooks from "./hooks/index.ts";
|
||||||
|
import type {TransformCallback} from "./hooks/transform.ts";
|
||||||
|
import type {RewriteUrlCallback} from "./hooks/generate-bundle.ts";
|
||||||
|
|
||||||
export interface RollupHtmlTransformContext {
|
export type {TransformCallback,RollupHtmlTransformContext} from "./hooks/transform.ts";
|
||||||
id?: string;
|
export type {RewriteUrlCallback, RewriteUrlCallbackContext} from "./hooks/generate-bundle.ts";
|
||||||
// bundle: OutputBundle;
|
|
||||||
// files: Record<string, (OutputChunk | OutputAsset)[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RewriteUrlCallbackContext {
|
|
||||||
from: string;
|
|
||||||
rootPath: string;
|
|
||||||
}
|
|
||||||
export type RewriteUrlCallback = (relative: string, context: RewriteUrlCallbackContext) => string|Promise<string>;
|
|
||||||
export type TransformCallback = (source: string, transformContext: RollupHtmlTransformContext) => string|Promise<string>;
|
|
||||||
|
|
||||||
const modulePrefix = `// <html-module>`;
|
const modulePrefix = `// <html-module>`;
|
||||||
const moduleSuffix = `// </html-module>`;
|
const moduleSuffix = `// </html-module>`;
|
||||||
@ -163,205 +140,32 @@ export function html(opts: {
|
|||||||
|
|
||||||
// Track html entrypoints
|
// Track html entrypoints
|
||||||
buildStart(options){
|
buildStart(options){
|
||||||
entryNames = new Map(Object.entries(typeof(options.input)==='object'?options.input:{[options.input]:[options.input]})
|
entryNames.clear();
|
||||||
.map(([k,v])=>[v,k])
|
const entries = Object.entries(typeof(options.input)==='object'?options.input:{[options.input]:[options.input]})
|
||||||
);
|
.map(([k,v])=>[v,k]);
|
||||||
},
|
for(const [k,v] of entries){
|
||||||
|
entryNames.set(k,v);
|
||||||
resolveId: {
|
|
||||||
async handler(specifier,
|
|
||||||
importer,
|
|
||||||
options){
|
|
||||||
if(virtualSources.has(specifier)) return specifier; // Resolve virtual sources we added
|
|
||||||
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 moduleExt = extname(resolved.id);
|
|
||||||
const moduleName = specifier.replace(new RegExp(`${moduleExt}\$`),''); // strip extension of the name if any
|
|
||||||
|
|
||||||
return {
|
|
||||||
...resolved,
|
|
||||||
meta: {
|
|
||||||
...resolved.meta,
|
|
||||||
[pluginName]: {
|
|
||||||
specifier: specifier,
|
|
||||||
id: resolved.id,
|
|
||||||
name: moduleName,
|
|
||||||
imports: [],
|
|
||||||
assetId: null,
|
|
||||||
importers: new Set(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resolveId: hooks.resolveId(({
|
||||||
|
virtualSources,
|
||||||
|
filter,
|
||||||
|
pluginName
|
||||||
|
})),
|
||||||
load: {
|
load: {
|
||||||
async handler(id: string) {
|
async handler(id: string) {
|
||||||
if (virtualSources.has(id)) return virtualSources.get(id);
|
if (virtualSources.has(id)) return virtualSources.get(id);
|
||||||
// if (!filter(id)) return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transform: {
|
|
||||||
order: 'pre',
|
|
||||||
async handler(...args){
|
|
||||||
const [code, id] = args;
|
|
||||||
if (!filter(id)) return;
|
|
||||||
|
|
||||||
// parse
|
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const contents = code;
|
|
||||||
|
|
||||||
const htmlSrc = transform ? await transform(contents, {
|
|
||||||
id,
|
|
||||||
}) : contents;
|
|
||||||
|
|
||||||
// Parse document and store it
|
|
||||||
const document = htmlModule.document = parseHtml(htmlSrc);
|
|
||||||
|
|
||||||
// TODO working on this: to preserve sourcemaps as much as possible we're starting the magic string on the raw html source
|
|
||||||
// question is if we need to though. sourcemaps only make sense for inlined bits of code
|
|
||||||
//let htmlJS = new MagicString(htmlSrc);// This is where we want to go!
|
|
||||||
|
|
||||||
// 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, {
|
|
||||||
skipSelf: false, // defaults to true since rollup 4, and for virtual files this is problematic
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beware leak of AST (we're starting MagicString on a parsed and modified version of the HTML file, sourcemappings in the HTML file will be off. (can't add a sourcemap for a html file anyway, unless it is outputted as JS module)
|
|
||||||
// @ts-ignore
|
|
||||||
let htmlJS = new MagicString(serializeHtml(htmlModule.document));
|
|
||||||
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{
|
|
||||||
// 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||'');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import all dependencies and wrap the HTML in a `...`, assign to a var and export (escaping any ` characters in the HTML)
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
transform: hooks.transform({
|
||||||
|
virtualSources,
|
||||||
|
filter,
|
||||||
|
entryNames,
|
||||||
|
pluginName,
|
||||||
|
transform,
|
||||||
|
load
|
||||||
|
}),
|
||||||
outputOptions(options){
|
outputOptions(options){
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
@ -407,113 +211,12 @@ export function html(opts: {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async generateBundle(outputOptions, bundles){
|
generateBundle: hooks.generateBundle({
|
||||||
const bundleItems = Object.entries(bundles);
|
virtualSources,
|
||||||
const virtualBundles = new Set<string>();
|
pluginName,
|
||||||
const facadeToChunk = new Map<string,OutputChunk>();
|
modulePrefix,
|
||||||
const htmlResults = new Map<string, {chunk: OutputChunk, htmlModule: HtmlModule}>();
|
rewriteUrl,
|
||||||
|
})
|
||||||
for(const [bundleName, bundle] of bundleItems) {
|
|
||||||
const chunk = (<OutputChunk>bundle);
|
|
||||||
if(chunk.facadeModuleId) {
|
|
||||||
facadeToChunk.set(chunk.facadeModuleId, chunk);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user