From 089304af1886c55fc6ff683b5ef0856acd7a496f Mon Sep 17 00:00:00 2001 From: Eric MORAND Date: Wed, 11 Oct 2023 12:16:47 +0200 Subject: [PATCH] Resolve issue #17 --- README.md | 12 ++ package.json | 3 +- src/lib/Rebaser.ts | 177 +++++++++++++----- test/fixtures/html/expectation.html | 22 +++ test/fixtures/html/index.html | 22 +++ .../expectation-with-rebase-option.html | 6 + test/fixtures/inline-style/expectation.html | 6 + test/fixtures/inline-style/index.twig | 6 + test/helpers.ts | 10 + test/{test.ts => index.test.ts} | 20 ++ test/inline-style.test.ts | 71 +++++++ 11 files changed, 311 insertions(+), 44 deletions(-) create mode 100644 test/fixtures/html/expectation.html create mode 100644 test/fixtures/html/index.html create mode 100644 test/fixtures/inline-style/expectation-with-rebase-option.html create mode 100644 test/fixtures/inline-style/expectation.html create mode 100644 test/fixtures/inline-style/index.twig create mode 100644 test/helpers.ts rename test/{test.ts => index.test.ts} (93%) create mode 100644 test/inline-style.test.ts diff --git a/README.md b/README.md index a0c5398..d2e4dfb 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ partials/bar.twig ``` html + ``` By rebasing the assets relatively to the file they were imported from, the resulting HTML would be: @@ -33,8 +38,15 @@ By rebasing the assets relatively to the file they were imported from, the resul ``` html + ``` +Yes, you read it well: it also rebases resources referenced by inline styles. + ## How it works html-source-map-rebase uses the mapping provided by source maps to resolve the original file the assets where imported from. That's why it *needs* a source map to perform its magic. Any tool able to generate a source map from a source file is appropriate. Here is how one could use [Twing](https://www.npmjs.com/package/twing) and html-source-map-rebase together to render an HTML document and rebase its assets. diff --git a/package.json b/package.json index f7092a5..9aaa355 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "prepack": "npm run clean && npm run build", "prebuild": "npm run clean", "precover": "rimraf coverage", - "test": "ts-node node_modules/tape/bin/tape test/**/test.ts | tap-spec", + "test": "ts-node node_modules/tape/bin/tape test/**/*.test.ts | tap-spec", "build": "tsc --project . --module commonjs --outDir dist --declaration true", "build:doc": "typedoc src/index.ts --out docs --excludePrivate --excludeProtected --excludeExternals", "cover": "nyc npm t", @@ -29,6 +29,7 @@ }, "homepage": "https://github.com/NightlyCommit/html-source-map-rebase#readme", "dependencies": { + "css-source-map-rebase": "^5.0.1", "parse5-html-rewriting-stream": "^5.1.1", "slash": "^3.0.0", "source-map": "^0.6.1" diff --git a/src/lib/Rebaser.ts b/src/lib/Rebaser.ts index 890475a..5b03d3d 100644 --- a/src/lib/Rebaser.ts +++ b/src/lib/Rebaser.ts @@ -1,11 +1,12 @@ import RewritingStream from "parse5-html-rewriting-stream"; -import {SourceMapConsumer} from "source-map"; -import type {StartTagToken as StartTag} from "parse5-sax-parser"; +import {SourceMapConsumer, SourceMapGenerator} from "source-map"; +import type {StartTagToken as StartTag, TextToken} from "parse5-sax-parser"; import {EventEmitter} from "events"; import {parse, Url} from "url"; import {posix, isAbsolute, dirname, join} from "path"; import slash from "slash" -import {Readable, Writable} from "stream" +import {Writable} from "stream" +import {Rebaser as CssRebaser} from "css-source-map-rebase"; export type Result = { data: Buffer, @@ -84,10 +85,6 @@ export const createRebaser = ( return new Promise((resolve, reject) => { let data: Buffer = Buffer.from(''); - const inputStream = new Readable({ - encoding: "utf-8" - }); - const outputStream = new Writable({ write(chunk: any, _encoding: BufferEncoding, callback: (error?: (Error | null)) => void) { data = Buffer.concat([data, chunk]); @@ -103,14 +100,22 @@ export const createRebaser = ( }); }); - inputStream - .pipe(rewritingStream) - .pipe(outputStream); + rewritingStream.pipe(outputStream); const isRebasable = (url: Url): boolean => { return !isAbsolute(url.href) && (url.host === null) && ((url.hash === null) || (url.path !== null)); }; + let queue: Promise = Promise.resolve(); + + const defer = (execution: () => Promise) => { + queue = queue + .then(execution) + .catch((error) => { + reject(error); + }); + }; + const getRegions = () => { if (!regions) { const foundRegions: Array = []; @@ -151,6 +156,80 @@ export const createRebaser = ( return regions; } + const findRegion = ( + startLine: number, + startColumn: number + ): Region | null => { + let i = 0; + let result: Region | null = null; + + const regions = getRegions(); + const tagStartLine = startLine; + const tagStartColumn = startColumn - 1; + + while ((i < regions.length) && (result === null)) { + let region = regions[i]; + + if ( + ((region.startLine < tagStartLine) || ((region.startLine === tagStartLine) && (region.startColumn <= tagStartColumn))) && + ( + (region.endLine === null) || (region.endLine > tagStartLine) || + ((region.endLine === tagStartLine) && (region.endColumn === null || (region.endColumn >= tagStartColumn))) + ) + ) { + result = region; + } + + i++; + } + + return result; + } + + const transformText = (textToken: TextToken, rawHtml: string): Promise => { + if (currentStartTag?.tagName !== "style") { + return Promise.resolve(); + } + + const {startLine, startCol, endLine} = textToken.sourceCodeLocation!; + const numberOfLines = 1 + (endLine - startLine); + const region = findRegion(startLine, startCol)!; + + const generator = new SourceMapGenerator(); + + for (let generatedLine = 1; generatedLine <= numberOfLines; generatedLine++) { + generator.addMapping({ + source: region.source, + generated: { + line: generatedLine, + column: 0 + }, + original: { + line: 1, + column: 0 + } + }); + } + + generator.setSourceContent(region.source, rawHtml); + + const cssRebaser = new CssRebaser({ + map: Buffer.from(generator.toString()), + rebase + }); + + cssRebaser.on("rebase", (rebasedPath, resolvedPath) => { + eventEmitter.emit('rebase', rebasedPath, resolvedPath); + }); + + return cssRebaser.rebase(Buffer.from(rawHtml)) + .then((result) => { + const {css} = result; + + textToken.text = css.toString(); + }); + }; + const transformStartTag = (tag: StartTag) => { const processTag = (tag: StartTag) => { const attributes = tag.attrs; @@ -162,31 +241,8 @@ export const createRebaser = ( const url = parse(attribute.value); if (isRebasable(url)) { - const location = tag.sourceCodeLocation!; - - let tagStartLine = location.startLine; - let tagStartColumn = location.startCol - 1; - - let i = 0; - let tagRegion: Region | null = null; - let regions = getRegions(); - - while ((i < regions.length) && (tagRegion === null)) { - let region = regions[i]; - - if ( - ((region.startLine < tagStartLine) || ((region.startLine === tagStartLine) && (region.startColumn <= tagStartColumn))) && - ( - (region.endLine === null) || (region.endLine > tagStartLine) || - ((region.endLine === tagStartLine) && (region.endColumn === null || (region.endColumn >= tagStartColumn))) - ) - ) { - tagRegion = region; - } - - i++; - } - + const {startLine, startCol} = tag.sourceCodeLocation!; + const tagRegion = findRegion(startLine, startCol); const {source} = tagRegion!; const resolvedPath = posix.join(dirname(source), url.pathname!); @@ -215,23 +271,58 @@ export const createRebaser = ( break; } }); - }; + } processTag(tag); } + let currentStartTag: StartTag | null = null; + rewritingStream.on('startTag', (startTag) => { - try { - transformStartTag(startTag); + defer(() => { + currentStartTag = startTag; + transformStartTag(startTag); rewritingStream.emitStartTag(startTag); - } catch (error) { - reject(error); - } + + return Promise.resolve(); + }); + }); + + rewritingStream.on('text', (text, rawHtml) => { + defer(() => { + return transformText(text, rawHtml) + .then(() => { + rewritingStream.emitRaw(text.text); + }); + }); }); - inputStream.push(html); - inputStream.push(null); + rewritingStream.on("endTag", (endTag) => { + defer(() => { + currentStartTag = null; + + rewritingStream.emitEndTag(endTag); + + return Promise.resolve(); + }); + }); + + for (const eventName of ['doctype', 'comment']) { + rewritingStream.on(eventName, (_token, rawHtml) => { + defer(() => { + rewritingStream.emitRaw(rawHtml); + + return Promise.resolve(); + }); + }); + } + + rewritingStream.write(html.toString(), () => { + queue.then(() => { + rewritingStream.end() + }); + }); }); }; diff --git a/test/fixtures/html/expectation.html b/test/fixtures/html/expectation.html new file mode 100644 index 0000000..e1926ba --- /dev/null +++ b/test/fixtures/html/expectation.html @@ -0,0 +1,22 @@ + + + + + Title + + + + + + + foo + + \ No newline at end of file diff --git a/test/fixtures/html/index.html b/test/fixtures/html/index.html new file mode 100644 index 0000000..2f03376 --- /dev/null +++ b/test/fixtures/html/index.html @@ -0,0 +1,22 @@ + + + + + Title + + + + + + + foo + + \ No newline at end of file diff --git a/test/fixtures/inline-style/expectation-with-rebase-option.html b/test/fixtures/inline-style/expectation-with-rebase-option.html new file mode 100644 index 0000000..c5d01bd --- /dev/null +++ b/test/fixtures/inline-style/expectation-with-rebase-option.html @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/test/fixtures/inline-style/expectation.html b/test/fixtures/inline-style/expectation.html new file mode 100644 index 0000000..fb89c51 --- /dev/null +++ b/test/fixtures/inline-style/expectation.html @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/test/fixtures/inline-style/index.twig b/test/fixtures/inline-style/index.twig new file mode 100644 index 0000000..19a5676 --- /dev/null +++ b/test/fixtures/inline-style/index.twig @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..1b4c46a --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,10 @@ +import {TwingEnvironment, TwingLoaderFilesystem} from "twing"; +import {resolve} from "path"; + +export const warmUp = function () { + let loader = new TwingLoaderFilesystem(resolve('test/fixtures')); + + return new TwingEnvironment(loader, { + source_map: true + }); +}; \ No newline at end of file diff --git a/test/test.ts b/test/index.test.ts similarity index 93% rename from test/test.ts rename to test/index.test.ts index 3efc5dc..a781180 100644 --- a/test/test.ts +++ b/test/index.test.ts @@ -293,4 +293,24 @@ tape('Rebaser', ({test}) => { .finally(end); }); }); + + test('preserves the other parts of the document untouched', ({same, end}) => { + const environment = warmUp(); + + return environment.render('html/index.html') + .then((html) => { + const map = environment.getSourceMap(); + + let rebaser = createRebaser(Buffer.from(map)); + + return rebaser.rebase(Buffer.from(html)) + .then(({data}) => { + const expectation = readFileSync(resolve('test/fixtures/html/expectation.html')); + + same(data.toString(), expectation.toString()); + + end(); + }); + }); + }); }); \ No newline at end of file diff --git a/test/inline-style.test.ts b/test/inline-style.test.ts new file mode 100644 index 0000000..5720f9c --- /dev/null +++ b/test/inline-style.test.ts @@ -0,0 +1,71 @@ +import tape from "tape"; +import {warmUp} from "./helpers"; +import {createRebaser} from "../src"; +import {readFileSync} from "fs"; +import {resolve} from "path"; + +tape('Inline style', ({test}) => { + test('inline style resources are rebased', ({same, end}) => { + const environment = warmUp(); + + return environment.render('inline-style/index.twig') + .then((html) => { + const map = environment.getSourceMap(); + + let rebaser = createRebaser(Buffer.from(map)); + + const rebasedPaths: Array = []; + + rebaser.on("rebase", (rebasedPath, resolvedPath) => { + rebasedPaths.push(rebasedPath); + }); + + return rebaser.rebase(Buffer.from(html)) + .then(({data}) => { + const expectation = readFileSync(resolve('test/fixtures/inline-style/expectation.html')); + + same(data.toString(), expectation.toString()); + same(rebasedPaths, [ + 'test/fixtures/assets/foo.png', + 'test/fixtures/assets/foo.png' + ], '"rebase" event is emitted'); + + end(); + }); + }); + }); + + test('inline style resources are rebased according to the rebase option', ({same, end}) => { + const environment = warmUp(); + + return environment.render('inline-style/index.twig') + .then((html) => { + const map = environment.getSourceMap(); + + let rebaser = createRebaser(Buffer.from(map), { + rebase: (_source, _resolvedPath, done) => { + done('foo'); + } + }); + + const rebasedPaths: Array = []; + + rebaser.on("rebase", (rebasedPath, resolvedPath) => { + rebasedPaths.push(rebasedPath); + }); + + return rebaser.rebase(Buffer.from(html)) + .then(({data}) => { + const expectation = readFileSync(resolve('test/fixtures/inline-style/expectation-with-rebase-option.html')); + + same(data.toString(), expectation.toString()); + same(rebasedPaths, [ + 'foo', + 'foo' + ], '"rebase" event is emitted'); + + end(); + }); + }); + }); +}); \ No newline at end of file