@b9g/shovel 0.2.0-beta.10 → 0.2.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,468 @@
1
+ // src/plugins/assets.ts
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
3
+ import { createHash } from "crypto";
4
+ import { join, basename, extname, relative, dirname } from "path";
5
+ import mime from "mime";
6
+ import * as ESBuild from "esbuild";
7
+ import { getLogger } from "@logtape/logtape";
8
+ import { NodeModulesPolyfillPlugin } from "@esbuild-plugins/node-modules-polyfill";
9
+ import { NodeGlobalsPolyfillPlugin } from "@esbuild-plugins/node-globals-polyfill";
10
+ var TRANSPILABLE_EXTENSIONS = /* @__PURE__ */ new Set([
11
+ ".ts",
12
+ ".tsx",
13
+ ".jsx",
14
+ ".mts",
15
+ ".cts"
16
+ ]);
17
+ var CSS_EXTENSIONS = /* @__PURE__ */ new Set([".css"]);
18
+ var logger = getLogger(["shovel"]);
19
+ var DEFAULT_CONFIG = {
20
+ outDir: "dist",
21
+ clientBuild: {}
22
+ };
23
+ var HASH_LENGTH = 16;
24
+ function mergeConfig(userConfig = {}) {
25
+ return {
26
+ ...DEFAULT_CONFIG,
27
+ ...userConfig
28
+ };
29
+ }
30
+ function normalizePath(basePath) {
31
+ if (!basePath.startsWith("/")) {
32
+ basePath = "/" + basePath;
33
+ }
34
+ if (!basePath.endsWith("/")) {
35
+ basePath = basePath + "/";
36
+ }
37
+ return basePath;
38
+ }
39
+ function assetsPlugin(options = {}) {
40
+ const config = mergeConfig(options);
41
+ const manifest = {
42
+ assets: {},
43
+ generated: (/* @__PURE__ */ new Date()).toISOString(),
44
+ config: {
45
+ outDir: config.outDir
46
+ }
47
+ };
48
+ const contexts = /* @__PURE__ */ new Map();
49
+ return {
50
+ name: "shovel-assets",
51
+ setup(build) {
52
+ build.onResolve({ filter: /.*/ }, (_args) => {
53
+ return null;
54
+ });
55
+ async function getContext(absPath, buildOptions) {
56
+ let ctx = contexts.get(absPath);
57
+ if (!ctx) {
58
+ ctx = await ESBuild.context(buildOptions);
59
+ contexts.set(absPath, ctx);
60
+ }
61
+ return ctx;
62
+ }
63
+ build.onLoad({ filter: /.*/ }, async (args) => {
64
+ if (!args.with?.assetBase || typeof args.with.assetBase !== "string") {
65
+ return null;
66
+ }
67
+ try {
68
+ const ext = extname(args.path);
69
+ const name = basename(args.path, ext);
70
+ const wantsCSS = args.with.type === "css";
71
+ const needsTranspilation = TRANSPILABLE_EXTENSIONS.has(ext);
72
+ const needsCSSBundling = CSS_EXTENSIONS.has(ext);
73
+ if (wantsCSS && !needsTranspilation) {
74
+ return {
75
+ errors: [
76
+ {
77
+ text: `type: "css" can only be used with transpilable files (.ts, .tsx, .jsx, etc.), not ${ext}`
78
+ }
79
+ ]
80
+ };
81
+ }
82
+ let content;
83
+ let outputExt = ext;
84
+ let mimeType;
85
+ if (needsTranspilation) {
86
+ const clientOpts = config.clientBuild;
87
+ const defaultPlugins = [
88
+ NodeModulesPolyfillPlugin(),
89
+ NodeGlobalsPolyfillPlugin({
90
+ process: true,
91
+ buffer: true
92
+ })
93
+ ];
94
+ const plugins = clientOpts.plugins ? [...clientOpts.plugins, ...defaultPlugins] : defaultPlugins;
95
+ const ctx = await getContext(args.path, {
96
+ entryPoints: [args.path],
97
+ bundle: true,
98
+ format: "esm",
99
+ target: "es2022",
100
+ platform: "browser",
101
+ write: false,
102
+ minify: true,
103
+ // outdir is required for esbuild to know where to put extracted CSS
104
+ outdir: config.outDir,
105
+ // Apply polyfills and user-provided client build options
106
+ plugins,
107
+ define: clientOpts.define,
108
+ inject: clientOpts.inject,
109
+ external: clientOpts.external,
110
+ alias: clientOpts.alias,
111
+ // Apply JSX configuration (defaults to @b9g/crank automatic runtime)
112
+ jsx: clientOpts.jsx ?? "automatic",
113
+ jsxFactory: clientOpts.jsxFactory,
114
+ jsxFragment: clientOpts.jsxFragment,
115
+ jsxImportSource: clientOpts.jsxImportSource ?? "@b9g/crank"
116
+ });
117
+ const result = await ctx.rebuild();
118
+ if (!result.outputFiles) {
119
+ return {
120
+ errors: [{ text: `No output files generated for ${args.path}` }]
121
+ };
122
+ }
123
+ if (wantsCSS) {
124
+ const cssOutput = result.outputFiles.find(
125
+ (f) => f.path.endsWith(".css")
126
+ );
127
+ if (!cssOutput) {
128
+ return {
129
+ errors: [
130
+ {
131
+ text: `No CSS was extracted from ${args.path}. The file must import CSS for type: "css" to work.`
132
+ }
133
+ ]
134
+ };
135
+ }
136
+ content = Buffer.from(cssOutput.text);
137
+ outputExt = ".css";
138
+ mimeType = "text/css";
139
+ } else {
140
+ const jsOutput = result.outputFiles.find(
141
+ (f) => f.path.endsWith(".js")
142
+ );
143
+ if (!jsOutput) {
144
+ return {
145
+ errors: [
146
+ {
147
+ text: `No JavaScript output was generated for ${args.path}`
148
+ }
149
+ ]
150
+ };
151
+ }
152
+ content = Buffer.from(jsOutput.text);
153
+ outputExt = ".js";
154
+ mimeType = "application/javascript";
155
+ }
156
+ } else if (needsCSSBundling) {
157
+ const entryPath = args.path;
158
+ const externalAbsolutePathsPlugin = {
159
+ name: "external-absolute-paths",
160
+ setup(build2) {
161
+ build2.onResolve({ filter: /^\// }, (resolveArgs) => {
162
+ if (resolveArgs.kind === "entry-point") {
163
+ return null;
164
+ }
165
+ return {
166
+ path: resolveArgs.path,
167
+ external: true
168
+ };
169
+ });
170
+ }
171
+ };
172
+ const ctx = await getContext(entryPath, {
173
+ entryPoints: [entryPath],
174
+ bundle: true,
175
+ write: false,
176
+ minify: true,
177
+ // outdir required for esbuild to generate output paths
178
+ outdir: config.outDir,
179
+ plugins: [externalAbsolutePathsPlugin],
180
+ // Loaders for web assets referenced in CSS via url()
181
+ loader: {
182
+ // Fonts
183
+ ".woff": "file",
184
+ ".woff2": "file",
185
+ ".ttf": "file",
186
+ ".eot": "file",
187
+ // Images
188
+ ".svg": "file",
189
+ ".png": "file",
190
+ ".jpg": "file",
191
+ ".jpeg": "file",
192
+ ".gif": "file",
193
+ ".webp": "file",
194
+ ".ico": "file",
195
+ // Media
196
+ ".mp4": "file",
197
+ ".webm": "file",
198
+ ".mp3": "file",
199
+ ".ogg": "file"
200
+ }
201
+ });
202
+ const result = await ctx.rebuild();
203
+ const cssOutput = result.outputFiles?.find(
204
+ (f) => f.path.endsWith(".css")
205
+ );
206
+ if (!cssOutput) {
207
+ return {
208
+ errors: [{ text: `No CSS output generated for ${args.path}` }]
209
+ };
210
+ }
211
+ const basePath2 = normalizePath(args.with.assetBase);
212
+ const cssOutputDir = join(config.outDir, "public", basePath2);
213
+ if (!existsSync(cssOutputDir)) {
214
+ mkdirSync(cssOutputDir, { recursive: true });
215
+ }
216
+ for (const file of result.outputFiles || []) {
217
+ if (file === cssOutput)
218
+ continue;
219
+ const assetFilename = file.path.split("/").pop();
220
+ const assetPath = join(cssOutputDir, assetFilename);
221
+ writeFileSync(assetPath, file.contents);
222
+ const assetUrl = `${basePath2}${assetFilename}`;
223
+ const assetHash = createHash("sha256").update(file.contents).digest("hex").slice(0, HASH_LENGTH);
224
+ manifest.assets[assetFilename] = {
225
+ source: assetFilename,
226
+ output: assetFilename,
227
+ url: assetUrl,
228
+ hash: assetHash,
229
+ size: file.contents.length,
230
+ type: mime.getType(assetFilename) || void 0
231
+ };
232
+ }
233
+ content = Buffer.from(cssOutput.text);
234
+ outputExt = ".css";
235
+ mimeType = "text/css";
236
+ } else {
237
+ content = readFileSync(args.path);
238
+ mimeType = mime.getType(args.path) || void 0;
239
+ }
240
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, HASH_LENGTH);
241
+ let filename;
242
+ if (args.with.assetName && typeof args.with.assetName === "string") {
243
+ filename = args.with.assetName.replace(/\[name\]/g, name).replace(/\[ext\]/g, outputExt.slice(1));
244
+ } else {
245
+ filename = `${name}-${hash}${outputExt}`;
246
+ }
247
+ const basePath = normalizePath(args.with.assetBase);
248
+ const publicURL = `${basePath}${filename}`;
249
+ const outputDir = join(config.outDir, "public", basePath);
250
+ if (!existsSync(outputDir)) {
251
+ mkdirSync(outputDir, { recursive: true });
252
+ }
253
+ const outputPath = join(outputDir, filename);
254
+ writeFileSync(outputPath, content);
255
+ const sourcePath = relative(process.cwd(), args.path);
256
+ const manifestEntry = {
257
+ source: sourcePath,
258
+ output: filename,
259
+ url: publicURL,
260
+ hash,
261
+ size: content.length,
262
+ type: mimeType
263
+ };
264
+ manifest.assets[sourcePath] = manifestEntry;
265
+ return {
266
+ contents: `export default ${JSON.stringify(publicURL)};`,
267
+ loader: "js"
268
+ };
269
+ } catch (error) {
270
+ return {
271
+ errors: [
272
+ {
273
+ text: `Failed to process asset: ${error.message}`,
274
+ detail: error
275
+ }
276
+ ]
277
+ };
278
+ }
279
+ });
280
+ build.onEnd(async () => {
281
+ for (const ctx of contexts.values()) {
282
+ await ctx.dispose();
283
+ }
284
+ contexts.clear();
285
+ try {
286
+ const manifestPath = join(config.outDir, "server", "assets.json");
287
+ const manifestDir = dirname(manifestPath);
288
+ if (!existsSync(manifestDir)) {
289
+ mkdirSync(manifestDir, { recursive: true });
290
+ }
291
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
292
+ logger.debug("Generated asset manifest", {
293
+ path: manifestPath,
294
+ assetCount: Object.keys(manifest.assets).length
295
+ });
296
+ } catch (error) {
297
+ logger.warn("Failed to write asset manifest: {error}", { error });
298
+ }
299
+ });
300
+ }
301
+ };
302
+ }
303
+
304
+ // src/plugins/import-meta.ts
305
+ import { readFile } from "fs/promises";
306
+ import { dirname as dirname2 } from "path";
307
+ import { pathToFileURL } from "url";
308
+ function importMetaPlugin() {
309
+ return {
310
+ name: "import-meta-transform",
311
+ setup(build) {
312
+ build.onLoad({ filter: /\.[jt]sx?$/, namespace: "file" }, async (args) => {
313
+ if (args.path.includes("node_modules") || args.path.includes("/packages/")) {
314
+ return null;
315
+ }
316
+ const contents = await readFile(args.path, "utf8");
317
+ if (!contents.includes("import.meta.url") && !contents.includes("import.meta.dirname") && !contents.includes("import.meta.filename")) {
318
+ return null;
319
+ }
320
+ const fileUrl = pathToFileURL(args.path).href;
321
+ const fileDirname = dirname2(args.path);
322
+ const fileFilename = args.path;
323
+ let transformed = contents;
324
+ transformed = transformed.replace(
325
+ /\bimport\.meta\.url\b/g,
326
+ JSON.stringify(fileUrl)
327
+ );
328
+ transformed = transformed.replace(
329
+ /\bimport\.meta\.dirname\b/g,
330
+ JSON.stringify(fileDirname)
331
+ );
332
+ transformed = transformed.replace(
333
+ /\bimport\.meta\.filename\b/g,
334
+ JSON.stringify(fileFilename)
335
+ );
336
+ const ext = args.path.split(".").pop();
337
+ let loader = "js";
338
+ if (ext === "ts")
339
+ loader = "ts";
340
+ else if (ext === "tsx")
341
+ loader = "tsx";
342
+ else if (ext === "jsx")
343
+ loader = "jsx";
344
+ return {
345
+ contents: transformed,
346
+ loader
347
+ };
348
+ });
349
+ }
350
+ };
351
+ }
352
+
353
+ // src/utils/jsx-config.ts
354
+ import { readFile as readFile2 } from "fs/promises";
355
+ import { join as join2, dirname as dirname3 } from "path";
356
+ import { existsSync as existsSync2 } from "fs";
357
+ var CRANK_JSX_DEFAULTS = {
358
+ jsx: "automatic",
359
+ jsxImportSource: "@b9g/crank"
360
+ };
361
+ async function findTsConfig(startDir) {
362
+ let dir = startDir;
363
+ while (dir !== dirname3(dir)) {
364
+ const tsconfigPath = join2(dir, "tsconfig.json");
365
+ if (existsSync2(tsconfigPath)) {
366
+ return tsconfigPath;
367
+ }
368
+ dir = dirname3(dir);
369
+ }
370
+ return null;
371
+ }
372
+ async function parseTsConfig(tsconfigPath) {
373
+ const content = await readFile2(tsconfigPath, "utf8");
374
+ const stripped = content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
375
+ const config = JSON.parse(stripped);
376
+ if (config.extends) {
377
+ const baseDir = dirname3(tsconfigPath);
378
+ let extendsPath = config.extends;
379
+ if (extendsPath.startsWith(".")) {
380
+ extendsPath = join2(baseDir, extendsPath);
381
+ } else {
382
+ extendsPath = join2(baseDir, "node_modules", extendsPath);
383
+ }
384
+ if (!extendsPath.endsWith(".json")) {
385
+ extendsPath += ".json";
386
+ }
387
+ if (existsSync2(extendsPath)) {
388
+ const baseConfig = await parseTsConfig(extendsPath);
389
+ return {
390
+ ...baseConfig,
391
+ ...config,
392
+ compilerOptions: {
393
+ ...baseConfig.compilerOptions,
394
+ ...config.compilerOptions
395
+ }
396
+ };
397
+ }
398
+ }
399
+ return config;
400
+ }
401
+ function mapTSConfigToESBuild(compilerOptions) {
402
+ const options = {};
403
+ if (compilerOptions.jsx) {
404
+ switch (compilerOptions.jsx) {
405
+ case "react":
406
+ case "react-native":
407
+ options.jsx = "transform";
408
+ break;
409
+ case "react-jsx":
410
+ case "react-jsxdev":
411
+ options.jsx = "automatic";
412
+ break;
413
+ case "preserve":
414
+ options.jsx = "preserve";
415
+ break;
416
+ }
417
+ }
418
+ if (compilerOptions.jsxFactory) {
419
+ options.jsxFactory = compilerOptions.jsxFactory;
420
+ }
421
+ if (compilerOptions.jsxFragmentFactory) {
422
+ options.jsxFragment = compilerOptions.jsxFragmentFactory;
423
+ }
424
+ if (compilerOptions.jsxImportSource) {
425
+ options.jsxImportSource = compilerOptions.jsxImportSource;
426
+ }
427
+ return options;
428
+ }
429
+ async function loadJSXConfig(projectRoot) {
430
+ const tsconfigPath = await findTsConfig(projectRoot);
431
+ if (tsconfigPath) {
432
+ const config = await parseTsConfig(tsconfigPath);
433
+ const compilerOptions = config.compilerOptions || {};
434
+ const hasJSXConfig = compilerOptions.jsx || compilerOptions.jsxFactory || compilerOptions.jsxFragmentFactory || compilerOptions.jsxImportSource;
435
+ if (hasJSXConfig) {
436
+ const tsOptions = mapTSConfigToESBuild(compilerOptions);
437
+ return {
438
+ ...CRANK_JSX_DEFAULTS,
439
+ ...tsOptions
440
+ };
441
+ }
442
+ }
443
+ return { ...CRANK_JSX_DEFAULTS };
444
+ }
445
+ function applyJSXOptions(buildOptions, jsxOptions) {
446
+ if (jsxOptions.jsx) {
447
+ buildOptions.jsx = jsxOptions.jsx;
448
+ }
449
+ if (jsxOptions.jsxFactory) {
450
+ buildOptions.jsxFactory = jsxOptions.jsxFactory;
451
+ }
452
+ if (jsxOptions.jsxFragment) {
453
+ buildOptions.jsxFragment = jsxOptions.jsxFragment;
454
+ }
455
+ if (jsxOptions.jsxImportSource) {
456
+ buildOptions.jsxImportSource = jsxOptions.jsxImportSource;
457
+ }
458
+ if (jsxOptions.jsxSideEffects !== void 0) {
459
+ buildOptions.jsxSideEffects = jsxOptions.jsxSideEffects;
460
+ }
461
+ }
462
+
463
+ export {
464
+ assetsPlugin,
465
+ importMetaPlugin,
466
+ loadJSXConfig,
467
+ applyJSXOptions
468
+ };