Skip to content
This repository was archived by the owner on Oct 17, 2023. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,27 @@ partials/bar.twig

``` html
<img src="../bar.png">
<style>
.foo {
background-image: url("../bar.png");
}
</style>
```

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

``` html
<img src="foo.png">
<img src="bar.png">
<style>
.foo {
background-image: url("bar.png");
}
</style>
```

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.
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
177 changes: 134 additions & 43 deletions src/lib/Rebaser.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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]);
Expand All @@ -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<void> = Promise.resolve();

const defer = (execution: () => Promise<void>) => {
queue = queue
.then(execution)
.catch((error) => {
reject(error);
});
};

const getRegions = () => {
if (!regions) {
const foundRegions: Array<Region> = [];
Expand Down Expand Up @@ -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<void> => {
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;
Expand All @@ -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!);
Expand Down Expand Up @@ -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()
});
});
});
};

Expand Down
22 changes: 22 additions & 0 deletions test/fixtures/html/expectation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<!-- this is a head comment -->
<script type="text/javascript">
const foo = () => {
return "foo";
};
</script>
<style>
body {
background-image: url("test/fixtures/assets/foo.png");
}
</style>
</head>
<body>
<!-- this is a body comment -->
<img src="test/fixtures/assets/foo.png" alt="foo">
</body>
</html>
22 changes: 22 additions & 0 deletions test/fixtures/html/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<!-- this is a head comment -->
<script type="text/javascript">
const foo = () => {
return "foo";
};
</script>
<style>
body {
background-image: url("../assets/foo.png");
}
</style>
</head>
<body>
<!-- this is a body comment -->
<img src="../assets/foo.png" alt="foo">
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<style>
@font-face {
src: url("foo");
}
</style>
<img src="foo">
6 changes: 6 additions & 0 deletions test/fixtures/inline-style/expectation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<style>
@font-face {
src: url("test/fixtures/assets/foo.png");
}
</style>
<img src="test/fixtures/assets/foo.png">
6 changes: 6 additions & 0 deletions test/fixtures/inline-style/index.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<style>
@font-face {
src: url("../assets/foo.png");
}
</style>
<img src="../assets/foo.png">
10 changes: 10 additions & 0 deletions test/helpers.ts
Original file line number Diff line number Diff line change
@@ -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
});
};
20 changes: 20 additions & 0 deletions test/test.ts → test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
});
Loading