diff --git a/cli/build/transpile/static-asset-plugin.ts b/cli/build/transpile/static-asset-plugin.ts index 793a1119..39e1ba48 100644 --- a/cli/build/transpile/static-asset-plugin.ts +++ b/cli/build/transpile/static-asset-plugin.ts @@ -1,6 +1,7 @@ import fs from "node:fs" import path from "node:path" import { createHash } from "node:crypto" +import type { Plugin } from "rollup" export const STATIC_ASSET_EXTENSIONS = new Set([ ".glb", @@ -29,10 +30,14 @@ export const createStaticAssetPlugin = ({ projectDir: string baseUrl?: string pathMappings?: Record -}) => { +}): Plugin => { const copiedAssets = new Map() const resolvedBaseUrl = baseUrl ?? projectDir const resolvedPathMappings = pathMappings ?? {} + + // Track asset IDs to their output paths for the renderChunk phase + const assetIdToOutputPath = new Map() + return { name: "tsci-static-assets", resolveId(source: string, importer: string | undefined) { @@ -41,7 +46,7 @@ export const createStaticAssetPlugin = ({ // If it's already an absolute path, use it if (path.isAbsolute(source)) { - return fs.existsSync(source) ? source : null + return fs.existsSync(source) ? { id: source, external: true } : null } // Try to resolve relative to the importer @@ -51,14 +56,14 @@ export const createStaticAssetPlugin = ({ source, ) if (fs.existsSync(resolvedFromImporter)) { - return resolvedFromImporter + return { id: resolvedFromImporter, external: true } } } // Try to resolve relative to projectDir (for baseUrl imports) const resolvedFromProject = path.resolve(resolvedBaseUrl, source) if (fs.existsSync(resolvedFromProject)) { - return resolvedFromProject + return { id: resolvedFromProject, external: true } } for (const [pattern, targets] of Object.entries(resolvedPathMappings)) { @@ -77,7 +82,7 @@ export const createStaticAssetPlugin = ({ const resolvedTarget = path.resolve(resolvedBaseUrl, targetPath) if (fs.existsSync(resolvedTarget)) { - return resolvedTarget + return { id: resolvedTarget, external: true } } } } @@ -85,28 +90,69 @@ export const createStaticAssetPlugin = ({ return null }, - load(id: string) { - const ext = path.extname(id).toLowerCase() - if (!STATIC_ASSET_EXTENSIONS.has(ext)) return null + buildStart() { + // Copy all assets that we've resolved during the build + assetIdToOutputPath.clear() + }, + renderChunk(code) { + // Replace absolute asset paths with relative output paths in the generated code + let modifiedCode = code - const assetDir = path.join(outputDir, "assets") - fs.mkdirSync(assetDir, { recursive: true }) + for (const [assetId, outputPath] of assetIdToOutputPath) { + // Replace any references to the absolute path with the relative output path + modifiedCode = modifiedCode.replace( + new RegExp(escapeRegExp(assetId), "g"), + outputPath, + ) + } + + return { code: modifiedCode, map: null } + }, + generateBundle(_options, bundle) { + // Find all external imports that are static assets and copy them + for (const chunk of Object.values(bundle)) { + if (chunk.type !== "chunk") continue - const fileBuffer = fs.readFileSync(id) - const hash = createHash("sha1") - .update(fileBuffer) - .digest("hex") - .slice(0, 8) - const fileName = `${path.basename(id, ext)}-${hash}${ext}` - const outputPath = path.join(assetDir, fileName) + // Process imports in this chunk + for (const importedId of chunk.imports) { + const ext = path.extname(importedId).toLowerCase() + if (!STATIC_ASSET_EXTENSIONS.has(ext)) continue - if (!copiedAssets.has(id)) { - fs.writeFileSync(outputPath, fileBuffer) - copiedAssets.set(id, outputPath) - } + // This is a static asset import - copy it and track the mapping + if (!copiedAssets.has(importedId)) { + const assetDir = path.join(outputDir, "assets") + fs.mkdirSync(assetDir, { recursive: true }) - const relativePath = `./assets/${fileName}` - return `export default ${JSON.stringify(relativePath)};` + const fileBuffer = fs.readFileSync(importedId) + const hash = createHash("sha1") + .update(fileBuffer) + .digest("hex") + .slice(0, 8) + const fileName = `${path.basename(importedId, ext)}-${hash}${ext}` + const outputFilePath = path.join(assetDir, fileName) + + fs.writeFileSync(outputFilePath, fileBuffer) + copiedAssets.set(importedId, `./assets/${fileName}`) + assetIdToOutputPath.set(importedId, `./assets/${fileName}`) + } + } + + // Replace absolute paths in the chunk code with relative paths + if (chunk.code) { + let modifiedCode = chunk.code + for (const [assetId, relativePath] of copiedAssets) { + modifiedCode = modifiedCode.replace( + new RegExp(escapeRegExp(assetId), "g"), + relativePath, + ) + } + chunk.code = modifiedCode + } + } }, } } + +function escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} diff --git a/tests/cli/transpile/transpile-link-glb.test.ts b/tests/cli/transpile/transpile-link-glb.test.ts index b2451411..da4f5f6e 100644 --- a/tests/cli/transpile/transpile-link-glb.test.ts +++ b/tests/cli/transpile/transpile-link-glb.test.ts @@ -114,7 +114,16 @@ export default AliasedBoard const esmPath = path.join(producerDir, "dist", "index.js") const esmContent = await readFile(esmPath, "utf-8") - expect(esmContent).toContain("AliasedBoard") + + expect(esmContent).toMatchInlineSnapshot(` + "import { jsx } from 'react/jsx-runtime'; + import cadModelUrl from './assets/chip-e043b555.glb'; + + const AliasedBoard = () => (jsx("board", { width: "15mm", height: "15mm", children: jsx("chip", { name: "U1", footprint: "soic8", cadModel: jsx("cadmodel", { modelUrl: cadModelUrl }) }) })); + + export { AliasedBoard as default }; + " + `) const linkResult = await runBunCommand(["bun", "link"], producerDir) expect(linkResult.exitCode).toBe(0) @@ -175,6 +184,6 @@ export default () => .replace(/\\/g, "/") expect(normalizedModelGlbUrl).toMatchInlineSnapshot( - `"./assets/chip-e043b555.glb"`, + `"/aliased-glb-lib/dist/assets/chip-e043b555.glb"`, ) }, 60_000) diff --git a/tests/cli/transpile/transpile-static-assets.test.ts b/tests/cli/transpile/transpile-static-assets.test.ts index eee8cdce..f9c44507 100644 --- a/tests/cli/transpile/transpile-static-assets.test.ts +++ b/tests/cli/transpile/transpile-static-assets.test.ts @@ -63,7 +63,7 @@ test("transpile copies static assets and preserves glb_model_url", async () => { const cadComponent = circuitJson.find( (element: any) => element.type === "cad_component", ) - const expectedAssetPath = `./assets/${copiedGlb}` + const expectedAssetPath = path.join(tmpDir, "dist", "assets", copiedGlb!) const cadComponentWithGlb = cadComponent as | { glb_model_url?: string; model_glb_url?: string } | undefined