Skip to content

Commit 62a561d

Browse files
committed
#1: Support referencing local files
1 parent 4c4229f commit 62a561d

8 files changed

Lines changed: 170 additions & 13 deletions

File tree

index.js

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const sass = require("sass");
33
const util = require("util");
44
const tmp = require("tmp");
55
const path = require("path");
6+
const csstree = require("css-tree");
67

78
const sassRender = util.promisify(sass.render);
89
const writeFile = util.promisify(fs.writeFile);
@@ -16,20 +17,24 @@ module.exports = (options = {}) => ({
1617
build.onResolve(
1718
{ filter: /.\.(scss|sass)$/, namespace: "file" },
1819
async (args) => {
19-
const fullFileName = path.resolve(args.resolveDir, args.path);
20-
const fileExt = path.extname(fullFileName);
21-
const baseFileName = path.basename(fullFileName, fileExt);
20+
const sourceFullPath = path.resolve(args.resolveDir, args.path);
21+
const sourceExt = path.extname(sourceFullPath);
22+
const sourceBaseName = path.basename(sourceFullPath, sourceExt);
23+
const sourceDir = path.dirname(sourceFullPath);
24+
const sourceRelDir = path.relative(path.dirname(rootDir), sourceDir);
2225

23-
const sassBuildResult = await sassRender({ file: fullFileName });
26+
const tmpDir = path.resolve(tmpDirPath, sourceRelDir);
27+
const tmpFilePath = path.resolve(tmpDir, `${sourceBaseName}.css`);
28+
await ensureDir(tmpDir);
2429

25-
const relativeDir = path.relative(
26-
path.dirname(rootDir),
27-
path.dirname(fullFileName)
28-
);
29-
const tmpDirFullPath = path.resolve(tmpDirPath, relativeDir);
30-
const tmpFilePath = path.resolve(tmpDirFullPath, `${baseFileName}.css`);
31-
await ensureDir(tmpDirFullPath);
32-
await writeFile(tmpFilePath, sassBuildResult.css);
30+
// Compile SASS to CSS
31+
let css = (await sassRender({ file: sourceFullPath })).css.toString();
32+
33+
// Replace all relative urls
34+
css = await replaceUrls(css, tmpFilePath, sourceDir, rootDir);
35+
36+
// Write result file
37+
await writeFile(tmpFilePath, css);
3338

3439
return {
3540
path: tmpFilePath,
@@ -38,3 +43,76 @@ module.exports = (options = {}) => ({
3843
);
3944
},
4045
});
46+
47+
async function replaceUrls(css, newCssFileName, sourceDir, rootDir) {
48+
const ast = csstree.parse(css);
49+
50+
csstree.walk(ast, {
51+
enter(node) {
52+
if (node.type === "Url") {
53+
const value = node.value;
54+
const stringValue = value.value;
55+
56+
let normalizedUrl;
57+
if (value.type === "String") {
58+
const match = stringValue.match(/^['"](.*)["']$/s);
59+
if (match) {
60+
normalizedUrl = match[1];
61+
} else {
62+
normalizedUrl = stringValue;
63+
}
64+
} else {
65+
normalizedUrl = stringValue;
66+
}
67+
68+
if (isLocalFileUrl(normalizedUrl)) {
69+
const resolved = resolveUrl(normalizedUrl, sourceDir, rootDir);
70+
const relativePath = path.relative(newCssFileName, resolved.file);
71+
72+
node.value = {
73+
...node.value,
74+
type: "String",
75+
// disable keeping query and hash parts of original url, since esbuild doesn't support it yet
76+
// value: `"${relativePath}${resolved.query}${resolved.hash}"`,
77+
value: `"${relativePath}"`,
78+
};
79+
}
80+
}
81+
},
82+
});
83+
84+
return csstree.generate(ast);
85+
}
86+
87+
function isLocalFileUrl(url) {
88+
if (/^https?:\/\//i.test(url)) {
89+
return false;
90+
}
91+
if (/^data:/.test(url)) {
92+
return false;
93+
}
94+
if (/^#/.test(url)) {
95+
return false;
96+
}
97+
98+
return true;
99+
}
100+
101+
function resolveUrl(url, originalFolder, rootDir) {
102+
const [_, pathname, query, hash] = url.match(/^(.*?)(\?.*?)?(#.*?)?$/);
103+
104+
let file = "";
105+
if (pathname.startsWith("/")) {
106+
file = path.resolve(rootDir, pathname.substring(1));
107+
// todo: resolve by root dir
108+
} else {
109+
file = path.resolve(originalFolder, pathname);
110+
}
111+
112+
return {
113+
file,
114+
pathname,
115+
query: query || "",
116+
hash: hash || "",
117+
};
118+
}

package-lock.json

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"description": "Plugin for esbuild to support SASS styles",
55
"main": "index.js",
66
"scripts": {
7-
"test": "tape tests/*.js | tap-spec"
7+
"test": "tape 'tests/*.js' | tap-spec"
88
},
99
"author": {
1010
"name": "Nikolai Mavrenkov",
@@ -22,6 +22,7 @@
2222
"sass"
2323
],
2424
"dependencies": {
25+
"css-tree": "^1.1.2",
2526
"fs-extra": "^9.0.1",
2627
"sass": "^1.32.4",
2728
"tmp": "^0.2.1"

tests/images.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const test = require("tape");
2+
const path = require("path");
3+
const fs = require("fs-extra");
4+
5+
process.chdir(path.resolve(__dirname));
6+
7+
const sassPlugin = require("../index.js");
8+
9+
test("resolving images", function (t) {
10+
(async () => {
11+
fs.removeSync(".output");
12+
13+
await require("esbuild").build({
14+
entryPoints: ["images/index.js"],
15+
bundle: true,
16+
outfile: ".output/bundle.js",
17+
loader: { ".png": "file", ".svg": "file" },
18+
plugins: [sassPlugin()],
19+
});
20+
21+
t.ok(fs.existsSync("./.output/bundle.js"), "Bundled js file should exist");
22+
t.ok(
23+
fs.existsSync("./.output/bundle.css"),
24+
"Bundled css file should exist"
25+
);
26+
t.end();
27+
})().catch((e) => t.fail(e.message));
28+
});

tests/images/atom.svg

Lines changed: 4 additions & 0 deletions
Loading

tests/images/example.scss

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
body {
2+
&.isRed {
3+
background: red;
4+
}
5+
&.withImage {
6+
background: url("image.png");
7+
}
8+
&.withImageUsingComplexUrl {
9+
background: url("../images/image.png");
10+
}
11+
&.withRawImage {
12+
background: url(image.png);
13+
}
14+
&.withAbsoluteImage {
15+
background: url("/images/image.png"); // relative to tests root, since we set working dir to test dir everytime
16+
}
17+
&.dataUrl {
18+
background: url("data:should_be_ignored");
19+
}
20+
&.absoluteUrl {
21+
background: url("https://should-be-ignored.com/image.png");
22+
}
23+
&.pathToLocalSvg {
24+
background: url("atom.svg#content"); // we ignore #content at the output path, since esbuild doesn't support it yet
25+
}
26+
}

tests/images/image.png

95 Bytes
Loading

tests/images/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import "./example.scss";

0 commit comments

Comments
 (0)