Skip to content
This repository was archived by the owner on Oct 17, 2023. It is now read-only.

Commit 8896671

Browse files
authored
Merge pull request #18 from ericmorand/issue_17
Resolve issue #17 - Allow rebasing of resources referenced inside style tags #17
2 parents 82fa4fa + 089304a commit 8896671

11 files changed

+311
-44
lines changed

README.md

+12
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,27 @@ partials/bar.twig
2626

2727
``` html
2828
<img src="../bar.png">
29+
<style>
30+
.foo {
31+
background-image: url("../bar.png");
32+
}
33+
</style>
2934
```
3035

3136
By rebasing the assets relatively to the file they were imported from, the resulting HTML would be:
3237

3338
``` html
3439
<img src="foo.png">
3540
<img src="bar.png">
41+
<style>
42+
.foo {
43+
background-image: url("bar.png");
44+
}
45+
</style>
3646
```
3747

48+
Yes, you read it well: it also rebases resources referenced by inline styles.
49+
3850
## How it works
3951

4052
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.

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"prepack": "npm run clean && npm run build",
1313
"prebuild": "npm run clean",
1414
"precover": "rimraf coverage",
15-
"test": "ts-node node_modules/tape/bin/tape test/**/test.ts | tap-spec",
15+
"test": "ts-node node_modules/tape/bin/tape test/**/*.test.ts | tap-spec",
1616
"build": "tsc --project . --module commonjs --outDir dist --declaration true",
1717
"build:doc": "typedoc src/index.ts --out docs --excludePrivate --excludeProtected --excludeExternals",
1818
"cover": "nyc npm t",
@@ -29,6 +29,7 @@
2929
},
3030
"homepage": "https://github.com/NightlyCommit/html-source-map-rebase#readme",
3131
"dependencies": {
32+
"css-source-map-rebase": "^5.0.1",
3233
"parse5-html-rewriting-stream": "^5.1.1",
3334
"slash": "^3.0.0",
3435
"source-map": "^0.6.1"

src/lib/Rebaser.ts

+134-43
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import RewritingStream from "parse5-html-rewriting-stream";
2-
import {SourceMapConsumer} from "source-map";
3-
import type {StartTagToken as StartTag} from "parse5-sax-parser";
2+
import {SourceMapConsumer, SourceMapGenerator} from "source-map";
3+
import type {StartTagToken as StartTag, TextToken} from "parse5-sax-parser";
44
import {EventEmitter} from "events";
55
import {parse, Url} from "url";
66
import {posix, isAbsolute, dirname, join} from "path";
77
import slash from "slash"
8-
import {Readable, Writable} from "stream"
8+
import {Writable} from "stream"
9+
import {Rebaser as CssRebaser} from "css-source-map-rebase";
910

1011
export type Result = {
1112
data: Buffer,
@@ -84,10 +85,6 @@ export const createRebaser = (
8485
return new Promise((resolve, reject) => {
8586
let data: Buffer = Buffer.from('');
8687

87-
const inputStream = new Readable({
88-
encoding: "utf-8"
89-
});
90-
9188
const outputStream = new Writable({
9289
write(chunk: any, _encoding: BufferEncoding, callback: (error?: (Error | null)) => void) {
9390
data = Buffer.concat([data, chunk]);
@@ -103,14 +100,22 @@ export const createRebaser = (
103100
});
104101
});
105102

106-
inputStream
107-
.pipe(rewritingStream)
108-
.pipe(outputStream);
103+
rewritingStream.pipe(outputStream);
109104

110105
const isRebasable = (url: Url): boolean => {
111106
return !isAbsolute(url.href) && (url.host === null) && ((url.hash === null) || (url.path !== null));
112107
};
113108

109+
let queue: Promise<void> = Promise.resolve();
110+
111+
const defer = (execution: () => Promise<void>) => {
112+
queue = queue
113+
.then(execution)
114+
.catch((error) => {
115+
reject(error);
116+
});
117+
};
118+
114119
const getRegions = () => {
115120
if (!regions) {
116121
const foundRegions: Array<Region> = [];
@@ -151,6 +156,80 @@ export const createRebaser = (
151156
return regions;
152157
}
153158

159+
const findRegion = (
160+
startLine: number,
161+
startColumn: number
162+
): Region | null => {
163+
let i = 0;
164+
let result: Region | null = null;
165+
166+
const regions = getRegions();
167+
const tagStartLine = startLine;
168+
const tagStartColumn = startColumn - 1;
169+
170+
while ((i < regions.length) && (result === null)) {
171+
let region = regions[i];
172+
173+
if (
174+
((region.startLine < tagStartLine) || ((region.startLine === tagStartLine) && (region.startColumn <= tagStartColumn))) &&
175+
(
176+
(region.endLine === null) || (region.endLine > tagStartLine) ||
177+
((region.endLine === tagStartLine) && (region.endColumn === null || (region.endColumn >= tagStartColumn)))
178+
)
179+
) {
180+
result = region;
181+
}
182+
183+
i++;
184+
}
185+
186+
return result;
187+
}
188+
189+
const transformText = (textToken: TextToken, rawHtml: string): Promise<void> => {
190+
if (currentStartTag?.tagName !== "style") {
191+
return Promise.resolve();
192+
}
193+
194+
const {startLine, startCol, endLine} = textToken.sourceCodeLocation!;
195+
const numberOfLines = 1 + (endLine - startLine);
196+
const region = findRegion(startLine, startCol)!;
197+
198+
const generator = new SourceMapGenerator();
199+
200+
for (let generatedLine = 1; generatedLine <= numberOfLines; generatedLine++) {
201+
generator.addMapping({
202+
source: region.source,
203+
generated: {
204+
line: generatedLine,
205+
column: 0
206+
},
207+
original: {
208+
line: 1,
209+
column: 0
210+
}
211+
});
212+
}
213+
214+
generator.setSourceContent(region.source, rawHtml);
215+
216+
const cssRebaser = new CssRebaser({
217+
map: Buffer.from(generator.toString()),
218+
rebase
219+
});
220+
221+
cssRebaser.on("rebase", (rebasedPath, resolvedPath) => {
222+
eventEmitter.emit('rebase', rebasedPath, resolvedPath);
223+
});
224+
225+
return cssRebaser.rebase(Buffer.from(rawHtml))
226+
.then((result) => {
227+
const {css} = result;
228+
229+
textToken.text = css.toString();
230+
});
231+
};
232+
154233
const transformStartTag = (tag: StartTag) => {
155234
const processTag = (tag: StartTag) => {
156235
const attributes = tag.attrs;
@@ -162,31 +241,8 @@ export const createRebaser = (
162241
const url = parse(attribute.value);
163242

164243
if (isRebasable(url)) {
165-
const location = tag.sourceCodeLocation!;
166-
167-
let tagStartLine = location.startLine;
168-
let tagStartColumn = location.startCol - 1;
169-
170-
let i = 0;
171-
let tagRegion: Region | null = null;
172-
let regions = getRegions();
173-
174-
while ((i < regions.length) && (tagRegion === null)) {
175-
let region = regions[i];
176-
177-
if (
178-
((region.startLine < tagStartLine) || ((region.startLine === tagStartLine) && (region.startColumn <= tagStartColumn))) &&
179-
(
180-
(region.endLine === null) || (region.endLine > tagStartLine) ||
181-
((region.endLine === tagStartLine) && (region.endColumn === null || (region.endColumn >= tagStartColumn)))
182-
)
183-
) {
184-
tagRegion = region;
185-
}
186-
187-
i++;
188-
}
189-
244+
const {startLine, startCol} = tag.sourceCodeLocation!;
245+
const tagRegion = findRegion(startLine, startCol);
190246
const {source} = tagRegion!;
191247

192248
const resolvedPath = posix.join(dirname(source), url.pathname!);
@@ -215,23 +271,58 @@ export const createRebaser = (
215271
break;
216272
}
217273
});
218-
};
274+
}
219275

220276
processTag(tag);
221277
}
222278

279+
let currentStartTag: StartTag | null = null;
280+
223281
rewritingStream.on('startTag', (startTag) => {
224-
try {
225-
transformStartTag(startTag);
282+
defer(() => {
283+
currentStartTag = startTag;
226284

285+
transformStartTag(startTag);
227286
rewritingStream.emitStartTag(startTag);
228-
} catch (error) {
229-
reject(error);
230-
}
287+
288+
return Promise.resolve();
289+
});
290+
});
291+
292+
rewritingStream.on('text', (text, rawHtml) => {
293+
defer(() => {
294+
return transformText(text, rawHtml)
295+
.then(() => {
296+
rewritingStream.emitRaw(text.text);
297+
});
298+
});
231299
});
232300

233-
inputStream.push(html);
234-
inputStream.push(null);
301+
rewritingStream.on("endTag", (endTag) => {
302+
defer(() => {
303+
currentStartTag = null;
304+
305+
rewritingStream.emitEndTag(endTag);
306+
307+
return Promise.resolve();
308+
});
309+
});
310+
311+
for (const eventName of ['doctype', 'comment']) {
312+
rewritingStream.on(eventName, (_token, rawHtml) => {
313+
defer(() => {
314+
rewritingStream.emitRaw(rawHtml);
315+
316+
return Promise.resolve();
317+
});
318+
});
319+
}
320+
321+
rewritingStream.write(html.toString(), () => {
322+
queue.then(() => {
323+
rewritingStream.end()
324+
});
325+
});
235326
});
236327
};
237328

test/fixtures/html/expectation.html

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Title</title>
6+
<!-- this is a head comment -->
7+
<script type="text/javascript">
8+
const foo = () => {
9+
return "foo";
10+
};
11+
</script>
12+
<style>
13+
body {
14+
background-image: url("test/fixtures/assets/foo.png");
15+
}
16+
</style>
17+
</head>
18+
<body>
19+
<!-- this is a body comment -->
20+
<img src="test/fixtures/assets/foo.png" alt="foo">
21+
</body>
22+
</html>

test/fixtures/html/index.html

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Title</title>
6+
<!-- this is a head comment -->
7+
<script type="text/javascript">
8+
const foo = () => {
9+
return "foo";
10+
};
11+
</script>
12+
<style>
13+
body {
14+
background-image: url("../assets/foo.png");
15+
}
16+
</style>
17+
</head>
18+
<body>
19+
<!-- this is a body comment -->
20+
<img src="../assets/foo.png" alt="foo">
21+
</body>
22+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<style>
2+
@font-face {
3+
src: url("foo");
4+
}
5+
</style>
6+
<img src="foo">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<style>
2+
@font-face {
3+
src: url("test/fixtures/assets/foo.png");
4+
}
5+
</style>
6+
<img src="test/fixtures/assets/foo.png">

test/fixtures/inline-style/index.twig

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<style>
2+
@font-face {
3+
src: url("../assets/foo.png");
4+
}
5+
</style>
6+
<img src="../assets/foo.png">

test/helpers.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {TwingEnvironment, TwingLoaderFilesystem} from "twing";
2+
import {resolve} from "path";
3+
4+
export const warmUp = function () {
5+
let loader = new TwingLoaderFilesystem(resolve('test/fixtures'));
6+
7+
return new TwingEnvironment(loader, {
8+
source_map: true
9+
});
10+
};

test/test.ts renamed to test/index.test.ts

+20
Original file line numberDiff line numberDiff line change
@@ -293,4 +293,24 @@ tape('Rebaser', ({test}) => {
293293
.finally(end);
294294
});
295295
});
296+
297+
test('preserves the other parts of the document untouched', ({same, end}) => {
298+
const environment = warmUp();
299+
300+
return environment.render('html/index.html')
301+
.then((html) => {
302+
const map = environment.getSourceMap();
303+
304+
let rebaser = createRebaser(Buffer.from(map));
305+
306+
return rebaser.rebase(Buffer.from(html))
307+
.then(({data}) => {
308+
const expectation = readFileSync(resolve('test/fixtures/html/expectation.html'));
309+
310+
same(data.toString(), expectation.toString());
311+
312+
end();
313+
});
314+
});
315+
});
296316
});

0 commit comments

Comments
 (0)