diff --git a/src/html-module.ts b/src/html-module.ts new file mode 100644 index 0000000..df884ea --- /dev/null +++ b/src/html-module.ts @@ -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, + imports: HtmlImport[]; + assetId?: string|null; + document?: DefaultTreeAdapterMap['document']; +} diff --git a/src/index.ts b/src/index.ts index 2be9aa1..19ded21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 => { - const result = {} as ReturnType; - 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 ([]).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, - resolved: HtmlImport[]; - assetId?: string|null; - document?: DefaultTreeAdapterMap['document']; -} - const modulePrefix = `// `; const moduleSuffix = `// `; @@ -118,6 +62,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin { let filter = createFilter(include, exclude, {}); let htmlModules = new Map();// todo clean this per new build? + let virtualSources = new Map(); 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 @@ -126,6 +71,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin { async handler(specifier: string, importer: string | undefined, options: { assertions: Record }){ + if(virtualSources.has(specifier)) return specifier; if(!filter(specifier)) return; // Let it be resolved like others (node_modules, project aliases, ..) @@ -141,7 +87,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(), }; @@ -154,6 +100,7 @@ export default function html(opts: RollupHtmlOptions = {}): Plugin { }, load: { async handler(id: string) { + if(virtualSources.has(id)) return virtualSources.get(id); if(!filter(id)) return; // Load @@ -166,36 +113,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 = (node); - let toLoad: LoadResult | undefined = undefined; - if (el.attrs) { - toLoad = load ? await load({ + const loadFunction: LoadFunction = async ({ + id: sourceId, + source, + type + })=>{ + if(!sourceId){ + sourceId = makeInlineId(id, node, 'js'); + } + if(source){ + virtualSources.set(sourceId, source);// TODO actually loading in the virtual source (if any) + } + + const resolved = await this.resolve(sourceId, id, { + isEntry: type==='entryChunk', + }); + // if(!resolved){ + // 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: sourceId, + resolved: resolved, + // loaded: loaded, node: el, - sourceId: id - }) : []; + 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; } - if (toLoad) { - const loadIds: LoadReference[] = (toLoad instanceof Array) ? toLoad : [toLoad]; - for (const loadId of loadIds) { - loadResults.push({ - reference: loadId, - node: el, - }) - } - } + let toLoad: LoadResult | undefined = load? await Promise.resolve(load({ + node: el, + sourceId: id + }, loadFunction)) : undefined; if (toLoad !== false) { let asParent = (node); @@ -208,63 +183,25 @@ 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 : (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 + + diff --git a/test/basic/snapshots/test.js.md b/test/basic/snapshots/test.js.md index 19e1b38..0930436 100644 --- a/test/basic/snapshots/test.js.md +++ b/test/basic/snapshots/test.js.md @@ -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: `␊ ␊ ␊ - ␊ + ␊ + ␊ + ␊ + `, + }, + ] + +## 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: `␊ + ␊ + ␊ + ␊ ␊ ␊ `, diff --git a/test/basic/snapshots/test.js.snap b/test/basic/snapshots/test.js.snap index e3c19c5..b111688 100644 Binary files a/test/basic/snapshots/test.js.snap and b/test/basic/snapshots/test.js.snap differ diff --git a/test/basic/test.js b/test/basic/test.js index 5609aee..f73efbb 100644 --- a/test/basic/test.js +++ b/test/basic/test.js @@ -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) diff --git a/types/index.d.ts b/types/index.d.ts index 8321dba..c900df1 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -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; } -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; -export type LoadReference = {get: ()=>string, set: (id: string)=>void}; -export type LoadResult = LoadReference|LoadReference[]|undefined|void|false; -export type LoadNodeCallback = (loadContext: RollupHtmlLoadContext) => LoadResult|Promise; -export type ResolveResult = string|true|undefined|void|false; -export type ResolveCallback = (id: string, resolveContext: RollupHtmlResolveContext) => ResolveResult|Promise; export interface RollupHtmlOptions { publicPath?: string; @@ -46,7 +37,7 @@ export interface RollupHtmlOptions { * Detect which references (, ) 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; /** diff --git a/types/load.d.ts b/types/load.d.ts new file mode 100644 index 0000000..9ea6be3 --- /dev/null +++ b/types/load.d.ts @@ -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