Skip to content

Commit

Permalink
Rewrite asset paths if they reference actual esbuild outputs
Browse files Browse the repository at this point in the history
  • Loading branch information
jgoz committed Jul 9, 2023
1 parent 6d01e1c commit af3b259
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 46 deletions.
5 changes: 5 additions & 0 deletions .changeset/honest-lions-breathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'esbd': minor
---

Rewrite asset paths if they reference actual esbuild outputs
94 changes: 48 additions & 46 deletions packages/esbd/src/html-entry-point/write-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ export interface WriteTemplateOptions extends EsbuildHtmlOptions {
}

interface FileSystem {
copyFile: typeof fsp['copyFile'];
writeFile: typeof fsp['writeFile'];
copyFile: (typeof fsp)['copyFile'];
writeFile: (typeof fsp)['writeFile'];
}

export async function writeTemplate(
Expand Down Expand Up @@ -64,7 +64,6 @@ export async function writeTemplate(
}

const copyFile = cachedCopyFile(fs.copyFile);
const outputCache = new Set<string>();

const { document, head, body } = template;

Expand All @@ -73,40 +72,38 @@ export async function writeTemplate(
const templateOutputPath = path.resolve(absOutDir, template.outputPath);
const needsModuleType = format === 'esm';

const outputs: [entryPoint: string, outputPath: string][] = Object.keys(metafile.outputs)
const outputFilenames = Object.keys(metafile.outputs);

// Find emitted entry points and convert them to absolute paths
const entryOutputs: [entryPoint: string, outputPath: string][] = outputFilenames
.map(o => [metafile.outputs[o], o] as const)
.filter(([output]) => !!output.entryPoint)
.map(([output, outputPath]) => [
path.resolve(basedir, output.entryPoint!),
path.relative(absTemplateDir, path.resolve(basedir, outputPath)),
]);

const cssOutput = new Map(outputs.filter(([, o]) => o.endsWith('.css')));
const jsOutput = new Map(outputs.filter(([, o]) => o.endsWith('.js')));

// Check whether any of the output file names have changed since the last
// build finished
let modified = false;
const currentOutputs = new Set([
...Array.from(cssOutput.values()),
...Array.from(jsOutput.values()),
]);
for (const output of currentOutputs) {
if (!outputCache.has(output)) {
outputCache.add(output);
modified = true;
}
}
for (const output of outputCache) {
if (!currentOutputs.has(output)) {
outputCache.delete(output);
modified = true;
}
}

// If no output filenames have changed, then there is no need to emit
// the HTML
if (!modified) return;
// Find any output files that were produced from inputs with matching "href"
// attributes in any <link> or <style> tags. If any are found, this indicates
// that the files were referenced elsewhere in the dependency graph and esbuild
// has copied them to the output directory, possibly with a different basename.
// But if they _also_ appear in the HTML template, it might be for preloading/
// prefetching purposes. In that case, we don't want to copy them again; rather,
// we want to use the output path that esbuild has already produced.
const tagOutputs = tagAssets.map(([element, url]) => {
const matchingOutput = outputFilenames.find(o => {
const inputs = Object.keys(metafile.outputs[o].inputs);
if (inputs.length !== 1) return false;

const absInputPath = path.resolve(basedir, inputs[0]);
const absHrefPath = path.resolve(absTemplateDir, url);
return absInputPath === absHrefPath;
});
return [element, url, matchingOutput] as const;
});

const cssOutput = new Map(entryOutputs.filter(([, o]) => o.endsWith('.css')));
const jsOutput = new Map(entryOutputs.filter(([, o]) => o.endsWith('.js')));

const htmlEntryPathsAbs = Object.values(htmlEntryPoints).map(filePath =>
path.resolve(basedir, filePath),
Expand Down Expand Up @@ -251,27 +248,32 @@ export async function writeTemplate(
// Rebase collected asset paths for tag assets (from <link> tags) and
// text assets (from <style> tags)
const assetPaths: [string, string][] = [];
for (const [node, url] of tagAssets) {
for (const [node, url, outputPath] of tagOutputs) {
const href = node.attrs.find(a => a.name === 'href');
if (!href) continue;

const { basename, inputPath, rebasedURL } = rebaseAssetURL(
substituteDefines(url, define),
outputPath ?? substituteDefines(url, define),
template.inputPath,
publicPath,
);
const href = node.attrs.find(a => a.name === 'href');
if (href) {
href.value = rebasedURL;

if (inputPath && basename) {
// TODO: parallelize this?
if (integrity) {
node.attrs.push({
name: 'integrity',
value: await calculateFileIntegrityHash(
path.resolve(absTemplateDir, inputPath),
integrity,
),
});
}
href.value = rebasedURL;

if (inputPath && basename) {
// TODO: parallelize this?
if (integrity) {
node.attrs.push({
name: 'integrity',
value: await calculateFileIntegrityHash(
path.resolve(absTemplateDir, inputPath),
integrity,
),
});
}
// If outputPath is defined, this file was already copied to the output directory
// by esbuild, so we don't need to copy it again.
if (!outputPath) {
assetPaths.push([inputPath, path.resolve(absOutDir, basename)]);
}
}
Expand Down
48 changes: 48 additions & 0 deletions packages/esbd/test/__snapshots__/esbd-build-html.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,54 @@ index.html
`;

exports[`build command (html entry) > includes referenced assets from HTML using their esbuild-generated output paths 1`] = `
---------------------------------
STDOUT
---------------------------------
✔ Finished index.html with 0 error(s) and 0 warning(s) in XX time
---------------------------------
entry.js
---------------------------------
// src/entry.tsx
import ReactDOM from "react-dom";
// assets/icon.svg
var icon_default = "./icon-IPILGNO5.svg";
// src/entry.tsx
function App() {
return /* @__PURE__ */ React.createElement(
"div",
null,
"Hello world ",
/* @__PURE__ */ React.createElement("img", { src: icon_default })
);
}
ReactDOM.render(
/* @__PURE__ */ React.createElement(App, null),
document.getElementById("root")
);
---------------------------------
icon-IPILGNO5.svg
---------------------------------
<svg></svg>
---------------------------------
index.html
---------------------------------
<!DOCTYPE html>
<html>
<head>
<link rel="apple-touch-icon" href="icon-IPILGNO5.svg" />
<script defer="" type="module" src="entry.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
`;

exports[`build command (html entry) > includes referenced assets from style tags 1`] = `
---------------------------------
STDOUT
Expand Down
32 changes: 32 additions & 0 deletions packages/esbd/test/esbd-build-html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,4 +617,36 @@ describe('build command (html entry)', () => {
}),
).resolves.toMatchSnapshot();
});

it('includes referenced assets from HTML using their esbuild-generated output paths', () => {
return expect(
buildWithHTML({
config: {
external: ['react', 'react-dom'],
loader: {
'.svg': 'file',
},
},
files: {
'index.html': `
<!DOCTYPE html>
<html>
<head>
<link rel="apple-touch-icon" href="./assets/icon.svg"/>
<script defer type="module" src="./src/entry.tsx"></script>
</head>
<body><div id='root'></div></body>
</html>
`,
'assets/icon.svg': '<svg></svg>',
'src/entry.tsx': `
import ReactDOM from 'react-dom';
import icon from '../assets/icon.svg';
function App() { return <div>Hello world <img src={icon} /></div>; }
ReactDOM.render(<App />, document.getElementById('root'));
`,
},
}),
).resolves.toMatchSnapshot();
});
});

0 comments on commit af3b259

Please sign in to comment.