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

Commit

Permalink
Merge pull request #18 from ericmorand/issue_17
Browse files Browse the repository at this point in the history
Resolve issue #17 - Allow rebasing of resources referenced inside style tags #17
  • Loading branch information
ericmorand authored Oct 11, 2023
2 parents 82fa4fa + 089304a commit 8896671
Show file tree
Hide file tree
Showing 11 changed files with 311 additions and 44 deletions.
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

0 comments on commit 8896671

Please sign in to comment.