From 240d5cfe9aca571d121fb618d7c8df031ae1de4a Mon Sep 17 00:00:00 2001 From: Miel Truyen Date: Thu, 27 Apr 2023 15:18:14 +0200 Subject: [PATCH] WIP: transforming through handlebars, parsing the html and resolving the imports --- CHANGELOG.md | 3 + package.json | 7 +- pnpm-lock.yaml | 25 ++- src/index.ts | 341 +++++++++++++++++++++++++----------- test/basic/test.js | 2 +- test/hbs/fixtures/batman.js | 4 + test/hbs/test.js | 8 +- types/index.d.ts | 71 ++++++-- 8 files changed, 335 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d3775..b837f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# Remove me: +TODO: This started as a fork of, but is now something different entirely. Changelog is no longer relevant (neither is the [README.md](README.md)) + # @rollup/plugin-html ChangeLog ## v1.0.2 diff --git a/package.json b/package.json index a44b6b9..2d5a756 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "test": "ava", "ci:coverage": "nyc pnpm test && nyc report --reporter=text-lcov > coverage.lcov", "ci:lint": "pnpm build && pnpm lint-staged", - "ci:test": "pnpm test -- --verbose" + "ci:test": "pnpm test -- --verbose", + "dev-test": "ava --match='handlebars*'" }, "files": [ "dist", @@ -53,6 +54,10 @@ "optional": true } }, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "parse5": "^7.1.2" + }, "devDependencies": { "@types/node": "^18.15.11", "@rollup/plugin-typescript": "^11.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f33c93d..16e0d24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,13 @@ lockfileVersion: '6.0' +dependencies: + '@rollup/pluginutils': + specifier: ^5.0.1 + version: 5.0.2(rollup@3.20.3) + parse5: + specifier: ^7.1.2 + version: 7.1.2 + devDependencies: '@babel/core': specifier: ^7.21.4 @@ -565,7 +573,6 @@ packages: estree-walker: 2.0.2 picomatch: 2.3.1 rollup: 3.20.3 - dev: true /@trysound/sax@0.2.0: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} @@ -590,7 +597,6 @@ packages: /@types/estree@1.0.0: resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} - dev: true /@types/glob@7.2.0: resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} @@ -1370,6 +1376,11 @@ packages: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} dev: true + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: false + /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} dependencies: @@ -1412,7 +1423,6 @@ packages: /estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - dev: true /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} @@ -1528,7 +1538,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true - dev: true optional: true /function-bind@1.1.1: @@ -2468,6 +2477,12 @@ packages: engines: {node: '>=12'} dev: true + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + dev: false + /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2509,7 +2524,6 @@ packages: /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - dev: true /pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} @@ -3088,7 +3102,6 @@ packages: hasBin: true optionalDependencies: fsevents: 2.3.2 - dev: true /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} diff --git a/src/index.ts b/src/index.ts index 7e75d8d..2171a4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,25 @@ import { extname } from "node:path"; -import type { Plugin, NormalizedOutputOptions, OutputBundle, EmittedAsset } from 'rollup'; +import type { + Plugin, + OutputBundle, + OutputChunk, + OutputAsset, + NormalizedOutputOptions, + // ModuleInfo, + ResolvedId, PreRenderedChunk +} from 'rollup'; -import type { RollupHtmlOptions, RollupHtmlTemplateOptions } from '../types/index.d.ts'; +import type { + LoadResult, + RollupHtmlOptions, + LoadNodeCallback, + LoadReference +} from '../types/index.d.ts'; +import {createFilter} from '@rollup/pluginutils'; +import {parse as parseHtml, serialize as serializeHtml, DefaultTreeAdapterMap} from "parse5"; -const getFiles = (bundle: OutputBundle): RollupHtmlTemplateOptions['files'] => { +const getFiles = (bundle: OutputBundle): Record => { const result = {} as ReturnType; for (const file of Object.values(bundle)) { const { fileName } = file; @@ -16,118 +31,244 @@ const getFiles = (bundle: OutputBundle): RollupHtmlTemplateOptions['files'] => { return result; }; -export const makeHtmlAttributes = (attributes: Record): string => { - if (!attributes) { - return ''; +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; +} - const keys = Object.keys(attributes); - // eslint-disable-next-line no-param-reassign - return keys.reduce((result, key) => (result += ` ${key}="${attributes[key]}"`), ''); +const defaults: RollupHtmlOptions = { + transform: (source: string)=>source,// NO-OP + load: makeLoader(), + resolve: ()=>true, + htmlFileNames: "[name].html", + include: [ + '**/*.(html|hbs)',// html or handlebars + ] }; -const defaultTemplate = async ({ - attributes, - files, - meta, - publicPath, - title -}: RollupHtmlTemplateOptions) => { - const scripts = (files.js || []) - .map(({ fileName }) => { - const attrs = makeHtmlAttributes(attributes.script); - return ``; - }) - .join('\n'); - - const links = (files.css || []) - .map(({ fileName }) => { - const attrs = makeHtmlAttributes(attributes.link); - return ``; - }) - .join('\n'); - - const metas = meta - .map((input) => { - const attrs = makeHtmlAttributes(input); - return ``; - }) - .join('\n'); - - return ` - - - - ${metas} - ${title} - ${links} - - - ${scripts} - -`; -}; - -const supportedFormats = ['es', 'esm', 'iife', 'umd']; - -const defaults = { - attributes: { - link: null, - html: { lang: 'en' }, - script: null - }, - fileName: 'index.html', - meta: [{ charset: 'utf-8' }], - publicPath: '', - template: defaultTemplate, - title: 'Rollup Bundle' -}; +// 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, + resolved: HtmlImport[]; +} export default function html(opts: RollupHtmlOptions = {}): Plugin { - const { attributes, fileName, meta, publicPath, template, title } = Object.assign( + const { + publicPath, + transform, + load, + htmlFileNames, + resolve, + include, + exclude, + } = Object.assign( {}, defaults, opts ); + if(publicPath){ throw new Error("TODO, do something with the public path or throw it out of the options. this is just to stop typescript complaining")} + + let filter = createFilter(include, exclude, {}); + let handledHtmls = new Map();// todo clean this per new build? return { - name: 'html', + 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 + load: { + async handler(id: string) { + if(!filter(id)) return; + // We'll be transforming this, but it appears there is no need for us to load it. Rollup will do this + } + }, + outputOptions(options){ + return { + ...options, + entryFileNames: (chunkInfo)=>{ + const htmlModule = chunkInfo.facadeModuleId ? handledHtmls.get(chunkInfo.facadeModuleId!) : null; + const defaultOption = options.entryFileNames ?? "[name]-[hash].js";// This default is copied from the docs. TODO: don't like overwrite it this way, can we remove the need for this or fetch the true default? + if(htmlModule){ + let fileName = typeof (htmlFileNames) === 'string' ? htmlFileNames : (<(chunkInfo:PreRenderedChunk)=>string>htmlFileNames)(chunkInfo); + if(fileName) { + return fileName; + } + } + return typeof (defaultOption) === 'string' ? defaultOption : (<(chunkInfo:PreRenderedChunk)=>string>defaultOption)(chunkInfo); + }, + // TODO do we need to do the same for chunks?? (what if they're dynamically imported?) + } + }, + transform: { + async handler(code: string, id: string){ + if(!filter(id)) return; + + const handled : HtmlModule = { + id, + resolved: [], + }; + handledHtmls.set(id, handled); + + const htmlSrc = transform? await transform(code, { + id, + }) : code; + + const 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'] }[] = []; + 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({ + node: el, + sourceId: id + }): []; + } + + if(toLoad){ + const loadIds: LoadReference[] = (toLoad instanceof Array)? toLoad: [toLoad]; + for(const loadId of loadIds){ + loadResults.push({ + reference: loadId, + node: el, + }) + } + } + + if(toLoad !== false) { + let asParent = (node); + if (asParent.childNodes) { + nextQueue.push(asParent.childNodes); + } + } + })); + nodeQueue = nextQueue.flat(); + }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 rollupResolved = await this.resolve(resolvedId, id, { + skipSelf: true, + isEntry: true, // TODO: for href/src tags, this is probably the right option. For anything that is to be inlined into the HTML... probably no + }); + // TODO: should we check if this is refused for resolving here. i.e. external? + const htmlImport: HtmlImport = { + id: resolvedId, + rollupResolved,//rollupResolved, + node, + reference, + referenceId: rollupResolved? this.emitFile({ + type: 'chunk', // Might want to adapt, or make configurable, + id: rollupResolved.id, + importer: id, + implicitlyLoadedAfterOneOf: [id], + }) : null, + index, + }; + if(htmlImport.referenceId) { + reference.set(`\${import.meta.ROLLUP_FILE_URL_${htmlImport.referenceId}\}`); + } + handled.resolved.push(htmlImport); + } + })); + + // console.log(`TODO, add the following for further transformations:\n${resolveResults.map(x=>` ${x.id}`).join('\n')}`); //and figure out how other libraries later replace the import... + + // Transform to JS + const serialized = serializeHtml(document); + const jsModule = [ + //...resolveResults.map(x=>`import * as dep${x.index} from "${x.id}";`), + // ...handled.resolved.map(x=>`import("${x.id}");`),// Inject as a dynamic import. We need to remove these before outputting // todo better solution to mark the ids as dependencies of this bundle... + `export const html = \`${serialized.replaceAll(/`/g,'\\\`')}\`;`, + `export default html;` + ].join('\n'); + + return {code: jsModule}; + } + }, async generateBundle(output: NormalizedOutputOptions, bundle: OutputBundle) { - if (!supportedFormats.includes(output.format) && !opts.template) { - this.warn( - `plugin-html: The output format '${ - output.format - }' is not directly supported. A custom \`template\` is probably required. Supported formats include: ${supportedFormats.join( - ', ' - )}` - ); - } - - if (output.format === 'es') { - attributes.script = Object.assign({}, attributes.script, { - type: 'module' - }); - } - const files = getFiles(bundle); - const source = await template({ - attributes, - bundle, - files, - meta, - publicPath, - title - }); + console.log("must output?!", output, bundle, files); + }, - const htmlFile: EmittedAsset = { - type: 'asset', - source, - name: 'Rollup HTML Asset', - fileName - }; - - this.emitFile(htmlFile); - } + // async generateBundle(output: NormalizedOutputOptions, bundle: OutputBundle) { + // if (!supportedFormats.includes(output.format) && !opts.transform) { + // this.warn( + // `plugin-html: The output format '${ + // output.format + // }' is not directly supported. A custom \`template\` is probably required. Supported formats include: ${supportedFormats.join( + // ', ' + // )}` + // ); + // } + // + // if (output.format === 'es') { + // attributes.script = Object.assign({}, attributes.script, { + // type: 'module' + // }); + // } + // + // const files = getFiles(bundle); + // + // + // const htmlFile: EmittedAsset = { + // type: 'asset', + // source, + // name: 'Rollup HTML Asset', + // fileName + // }; + // + // this.emitFile(htmlFile); + // } }; } diff --git a/test/basic/test.js b/test/basic/test.js index 568929d..bf085aa 100644 --- a/test/basic/test.js +++ b/test/basic/test.js @@ -115,7 +115,7 @@ test.serial('template', async (t) => { input: 'batman.js', plugins: [ html({ - template: () => '
' + transform: () => '
' }) ] }); diff --git a/test/hbs/fixtures/batman.js b/test/hbs/fixtures/batman.js index e69de29..15c5e86 100644 --- a/test/hbs/fixtures/batman.js +++ b/test/hbs/fixtures/batman.js @@ -0,0 +1,4 @@ +export const notSoIifi = ()=>{ + return `I'm "annoying" ${"in case we need to test \`string\` escaping.''"}`; +} +console.log(notSoIifi()); diff --git a/test/hbs/test.js b/test/hbs/test.js index 00a6879..41428bf 100644 --- a/test/hbs/test.js +++ b/test/hbs/test.js @@ -8,7 +8,7 @@ import { getCode } from "../util/test.js"; import html from "../../src/index.ts"; import handlebars from "handlebars"; -const output = { dir: 'output', format: 'umd' }; +const output = { dir: 'output', format: 'es' }; import {readFile} from "node:fs/promises"; import {fileURLToPath} from "node:url"; @@ -18,11 +18,11 @@ process.chdir(join(__dirname, 'fixtures')); test.serial('handlebars', async (t) => { const template = await readFile('index.hbs', {encoding: "utf-8"}); const bundle = await rollup({ - input: 'batman.js', + input: 'index.hbs', plugins: [ html({ - fileName: 'index.html', - template(ctx){ + // Should we define an output template here?! + transform(ctx){ return handlebars.compile(template)({a:'a'}) } }) diff --git a/types/index.d.ts b/types/index.d.ts index f8d62ef..8321dba 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,24 +1,67 @@ -import type { Plugin, OutputChunk, OutputAsset, OutputBundle } from 'rollup'; +import type {Plugin, OutputChunk, OutputAsset, OutputBundle, TransformModuleJSON, } from 'rollup'; +import {FilterPattern} from "@rollup/pluginutils"; +import type {DefaultTreeAdapterMap} from "parse5"; +import {PreRenderedChunk} from "rollup"; -export interface RollupHtmlTemplateOptions { - title: string; - attributes: Record; - publicPath: string; - meta: Record[]; - bundle: OutputBundle; - files: Record; +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 { - title?: string; - attributes?: Record; - fileName?: string; - meta?: Record[]; publicPath?: string; - template?: (templateoptions?: RollupHtmlTemplateOptions) => string; + /** + * Follows the same logic as rollup's [entryFileNames](https://rollupjs.org/configuration-options/#output-entryfilenames). + */ + htmlFileNames?: string|((chunkInfo: PreRenderedChunk) => string); + /** + * Transform a source file passed into this plugin to HTML. For example: a handlebars transform + * ``` + * transform(source){ + * return handlebars.compile(source)({myVar:'example'}) + * } + * ``` + */ + transform?: TransformCallback; + + /** + * 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 + */ + load?: LoadNodeCallback; + /** + * Callback to filter which references actually need to be resolved. Here you can filter out: + * - Links to extensions that don't need to be handled through rollup + * - Resources that are external to the app (for example non-relative paths) + * - Page navigation within the app + * + * Return a falsey value to skip this reference. Return true to resolve as is. (or string to transform the id) + */ + resolve?: ResolveCallback; + include?: FilterPattern; + exclude?: FilterPattern } -export function makeHtmlAttributes(attributes: Record): string; /** * A Rollup plugin which creates HTML files to serve Rollup bundles.