Skip to content

Commit 63c1f83

Browse files
brainkimclaude
andcommitted
feat: support glob patterns in asset imports
Adds a new esbuild plugin that expands glob patterns in import paths into individual asset imports, which flow through the existing asset pipeline. This lets users import entire directories of static files with a single import statement: import urls from "./public/**/*" with { assetBase: "/", assetName: "[name].[ext]" }; Each matched file is hashed, manifested, and served by the assets middleware — same as individually imported assets. Directory structure is preserved via per-file assetBase computation. Closes #75 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3f7957c commit 63c1f83

3 files changed

Lines changed: 326 additions & 0 deletions

File tree

‎src/plugins/glob-assets.ts‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* ESBuild plugin for glob asset imports.
3+
*
4+
* Expands glob patterns in import paths into individual asset imports,
5+
* which then flow through the existing assetsPlugin pipeline.
6+
*
7+
* @example
8+
* // Import all images from ./public/ — each gets hashed and manifested
9+
* import urls from "./public/**\/*.{png,svg,ico}" with { assetBase: "/" };
10+
* // urls = { "logo.png": "/logo-abc123.png", "images/hero.png": "/images/hero-def456.png" }
11+
*
12+
* // Use assetName to preserve original filenames (no hashing)
13+
* import "./static/**\/*" with { assetBase: "/static/", assetName: "[name].[ext]" };
14+
*
15+
* // Side-effect import — files are processed but no URL map is needed
16+
* import "./public/**\/*.{png,svg}" with { assetBase: "/" };
17+
*/
18+
19+
import fg from "fast-glob";
20+
import {posix} from "path";
21+
import type * as ESBuild from "esbuild";
22+
23+
const GLOB_NAMESPACE = "shovel-glob-assets";
24+
25+
/**
26+
* Detect whether an import path contains glob characters.
27+
*/
28+
function isGlobPattern(path: string): boolean {
29+
return path.includes("*") || path.includes("{") || path.includes("?");
30+
}
31+
32+
/**
33+
* Extract the non-glob prefix from a pattern as the root directory.
34+
* e.g., "./public/**\/*.png" → "./public/"
35+
*/
36+
function getGlobRoot(pattern: string): string {
37+
const parts = pattern.split("/");
38+
const rootParts: string[] = [];
39+
for (const part of parts) {
40+
if (isGlobPattern(part)) break;
41+
rootParts.push(part);
42+
}
43+
return rootParts.join("/") || ".";
44+
}
45+
46+
/**
47+
* ESBuild plugin for expanding glob patterns in asset imports.
48+
*/
49+
export function globAssetsPlugin(): ESBuild.Plugin {
50+
return {
51+
name: "shovel-glob-assets",
52+
setup(build) {
53+
// Intercept imports with glob patterns and assetBase attribute
54+
build.onResolve({filter: /[*?{]/}, (args) => {
55+
if (!args.with?.assetBase) return null;
56+
57+
return {
58+
path: args.path,
59+
namespace: GLOB_NAMESPACE,
60+
pluginData: {
61+
resolveDir: args.resolveDir,
62+
assetBase: args.with.assetBase,
63+
assetName: args.with.assetName,
64+
},
65+
};
66+
});
67+
68+
// Expand glob and generate virtual module with individual imports
69+
build.onLoad({filter: /.*/, namespace: GLOB_NAMESPACE}, (args) => {
70+
const {resolveDir, assetBase, assetName} = args.pluginData;
71+
const pattern = args.path;
72+
73+
// Expand the glob relative to the resolve directory
74+
const files = fg.globSync(pattern, {
75+
cwd: resolveDir,
76+
onlyFiles: true,
77+
dot: false,
78+
});
79+
80+
if (files.length === 0) {
81+
return {
82+
warnings: [
83+
{
84+
text: `Glob pattern "${pattern}" matched no files`,
85+
},
86+
],
87+
contents: "export default {};",
88+
loader: "js" as const,
89+
};
90+
}
91+
92+
// Determine the root directory (non-glob prefix) for relative path computation
93+
const globRoot = getGlobRoot(pattern);
94+
95+
// Generate individual import statements
96+
const imports: string[] = [];
97+
const exports: string[] = [];
98+
99+
for (let i = 0; i < files.length; i++) {
100+
const file = files[i];
101+
// Compute path relative to glob root for directory structure preservation
102+
const relativeToRoot = posix.relative(globRoot, file);
103+
const fileDir = posix.dirname(relativeToRoot);
104+
105+
// Compute per-file assetBase: user's base + subdirectory
106+
let fileAssetBase = assetBase;
107+
if (fileDir && fileDir !== ".") {
108+
fileAssetBase = posix.join(assetBase, fileDir, "/");
109+
}
110+
// Ensure trailing slash
111+
if (!fileAssetBase.endsWith("/")) {
112+
fileAssetBase += "/";
113+
}
114+
115+
// Build the with clause
116+
const withParts = [`assetBase: ${JSON.stringify(fileAssetBase)}`];
117+
if (assetName) {
118+
withParts.push(`assetName: ${JSON.stringify(assetName)}`);
119+
}
120+
121+
// Use ./ prefix for the import path relative to resolveDir
122+
const importPath = file.startsWith(".") ? file : `./${file}`;
123+
124+
imports.push(
125+
`import _${i} from ${JSON.stringify(importPath)} with { ${withParts.join(", ")} };`,
126+
);
127+
exports.push(`${JSON.stringify(relativeToRoot)}: _${i}`);
128+
}
129+
130+
const contents = [
131+
...imports,
132+
`export default { ${exports.join(", ")} };`,
133+
].join("\n");
134+
135+
return {
136+
contents,
137+
loader: "js" as const,
138+
resolveDir,
139+
};
140+
});
141+
},
142+
};
143+
}

‎src/utils/bundler.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {getLogger} from "@logtape/logtape";
1616
import type {PlatformModule, ESBuildConfig} from "@b9g/platform/module";
1717

1818
import {assetsPlugin} from "../plugins/assets.js";
19+
import {globAssetsPlugin} from "../plugins/glob-assets.js";
1920
import {importMetaPlugin} from "../plugins/import-meta.js";
2021
import {createConfigPlugin} from "../plugins/config.js";
2122
import {createEntryPlugin} from "../plugins/entry.js";
@@ -314,6 +315,8 @@ export class ServerBundler {
314315
}),
315316
createEntryPlugin(this.#projectRoot, platformEntryPoints),
316317
importMetaPlugin(),
318+
// globAssetsPlugin expands glob patterns into individual imports for assetsPlugin
319+
globAssetsPlugin(),
317320
// assetsPlugin must come before assetsManifestPlugin so onEnd order is correct
318321
assetsPlugin({
319322
outDir: outputDir,

‎test/build.test.js‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,3 +1051,183 @@ self.addEventListener("fetch", (event) => {
10511051
},
10521052
TIMEOUT,
10531053
);
1054+
1055+
// ======================
1056+
// GLOB ASSET IMPORT TESTS
1057+
// ======================
1058+
1059+
test(
1060+
"glob import processes multiple static files through asset pipeline",
1061+
async () => {
1062+
const cleanup_paths = [];
1063+
1064+
try {
1065+
const testDir = await createTempDir("glob-assets-");
1066+
cleanup_paths.push(testDir);
1067+
1068+
// Create a public/ directory with static files
1069+
const publicDir = join(testDir, "public");
1070+
await FS.mkdir(publicDir, {recursive: true});
1071+
await FS.writeFile(join(publicDir, "logo.txt"), "I am a logo");
1072+
await FS.writeFile(join(publicDir, "favicon.ico"), "fake-icon");
1073+
1074+
// Create a subdirectory
1075+
await FS.mkdir(join(publicDir, "images"), {recursive: true});
1076+
await FS.writeFile(join(publicDir, "images", "hero.txt"), "hero image");
1077+
1078+
// Entry point uses glob import
1079+
const entryContent = `
1080+
import urls from "./public/**/*" with { assetBase: "/", assetName: "[name].[ext]" };
1081+
1082+
self.addEventListener("fetch", (event) => {
1083+
event.respondWith(Response.json(urls));
1084+
});
1085+
`;
1086+
1087+
const entryPath = join(testDir, "app.js");
1088+
await FS.writeFile(entryPath, entryContent);
1089+
1090+
await FS.writeFile(
1091+
join(testDir, "package.json"),
1092+
JSON.stringify({name: "test-glob-assets", type: "module"}),
1093+
);
1094+
1095+
await FS.symlink(
1096+
join(process.cwd(), "node_modules"),
1097+
join(testDir, "node_modules"),
1098+
"dir",
1099+
);
1100+
1101+
const outDir = join(testDir, "dist");
1102+
const originalCwd = process.cwd();
1103+
process.chdir(testDir);
1104+
1105+
try {
1106+
await buildForProduction({
1107+
entrypoint: entryPath,
1108+
outDir,
1109+
verbose: false,
1110+
platform: "node",
1111+
});
1112+
} finally {
1113+
process.chdir(originalCwd);
1114+
}
1115+
1116+
// Verify files were written to dist/public/
1117+
expect(await fileExists(join(outDir, "public", "logo.txt"))).toBe(true);
1118+
expect(await fileExists(join(outDir, "public", "favicon.ico"))).toBe(
1119+
true,
1120+
);
1121+
expect(
1122+
await fileExists(join(outDir, "public", "images", "hero.txt")),
1123+
).toBe(true);
1124+
1125+
// Verify content is preserved
1126+
const logoContent = await FS.readFile(
1127+
join(outDir, "public", "logo.txt"),
1128+
"utf8",
1129+
);
1130+
expect(logoContent).toBe("I am a logo");
1131+
1132+
// Verify the worker has URL references
1133+
const workerContent = await FS.readFile(
1134+
join(outDir, "server", "worker.js"),
1135+
"utf8",
1136+
);
1137+
expect(workerContent).toContain("/logo.txt");
1138+
expect(workerContent).toContain("/favicon.ico");
1139+
expect(workerContent).toContain("/images/hero.txt");
1140+
1141+
// Verify manifest includes the glob assets
1142+
const manifest = JSON.parse(
1143+
await FS.readFile(join(outDir, "server", "assets.json"), "utf8"),
1144+
);
1145+
const urls = Object.values(manifest.assets).map((a) => a.url);
1146+
expect(urls).toContain("/logo.txt");
1147+
expect(urls).toContain("/favicon.ico");
1148+
expect(urls).toContain("/images/hero.txt");
1149+
} finally {
1150+
await cleanup(cleanup_paths);
1151+
}
1152+
},
1153+
TIMEOUT,
1154+
);
1155+
1156+
test(
1157+
"glob import with hashed filenames (no assetName)",
1158+
async () => {
1159+
const cleanup_paths = [];
1160+
1161+
try {
1162+
const testDir = await createTempDir("glob-hashed-");
1163+
cleanup_paths.push(testDir);
1164+
1165+
const assetsDir = join(testDir, "assets");
1166+
await FS.mkdir(assetsDir, {recursive: true});
1167+
await FS.writeFile(join(assetsDir, "style.css"), "body { color: red }");
1168+
await FS.writeFile(join(assetsDir, "icon.svg"), "<svg></svg>");
1169+
1170+
const entryContent = `
1171+
import urls from "./assets/**/*" with { assetBase: "/static/" };
1172+
1173+
self.addEventListener("fetch", (event) => {
1174+
event.respondWith(Response.json(urls));
1175+
});
1176+
`;
1177+
1178+
const entryPath = join(testDir, "app.js");
1179+
await FS.writeFile(entryPath, entryContent);
1180+
1181+
await FS.writeFile(
1182+
join(testDir, "package.json"),
1183+
JSON.stringify({name: "test-glob-hashed", type: "module"}),
1184+
);
1185+
1186+
await FS.symlink(
1187+
join(process.cwd(), "node_modules"),
1188+
join(testDir, "node_modules"),
1189+
"dir",
1190+
);
1191+
1192+
const outDir = join(testDir, "dist");
1193+
const originalCwd = process.cwd();
1194+
process.chdir(testDir);
1195+
1196+
try {
1197+
await buildForProduction({
1198+
entrypoint: entryPath,
1199+
outDir,
1200+
verbose: false,
1201+
platform: "node",
1202+
});
1203+
} finally {
1204+
process.chdir(originalCwd);
1205+
}
1206+
1207+
// Verify hashed files exist in dist/public/static/
1208+
const staticDir = join(outDir, "public", "static");
1209+
const files = await FS.readdir(staticDir);
1210+
1211+
// Should have hashed filenames like style-abc123.css and icon-def456.svg
1212+
const cssFile = files.find(
1213+
(f) => f.startsWith("style-") && f.endsWith(".css"),
1214+
);
1215+
const svgFile = files.find(
1216+
(f) => f.startsWith("icon-") && f.endsWith(".svg"),
1217+
);
1218+
expect(cssFile).toBeTruthy();
1219+
expect(svgFile).toBeTruthy();
1220+
1221+
// Verify worker contains hashed URLs
1222+
const workerContent = await FS.readFile(
1223+
join(outDir, "server", "worker.js"),
1224+
"utf8",
1225+
);
1226+
expect(workerContent).toContain("/static/style-");
1227+
expect(workerContent).toContain("/static/icon-");
1228+
} finally {
1229+
await cleanup(cleanup_paths);
1230+
}
1231+
},
1232+
TIMEOUT,
1233+
);

0 commit comments

Comments
 (0)