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
+
+
+
+
+
+
+
+
+
\ 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
+
+
+
+
+
+
+
+
+
\ 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