Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 69 additions & 23 deletions cli/build/transpile/static-asset-plugin.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -29,10 +30,14 @@ export const createStaticAssetPlugin = ({
projectDir: string
baseUrl?: string
pathMappings?: Record<string, string[]>
}) => {
}): Plugin => {
const copiedAssets = new Map<string, string>()
const resolvedBaseUrl = baseUrl ?? projectDir
const resolvedPathMappings = pathMappings ?? {}

// Track asset IDs to their output paths for the renderChunk phase
const assetIdToOutputPath = new Map<string, string>()

return {
name: "tsci-static-assets",
resolveId(source: string, importer: string | undefined) {
Expand All @@ -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
Expand All @@ -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)) {
Expand All @@ -77,36 +82,77 @@ export const createStaticAssetPlugin = ({
const resolvedTarget = path.resolve(resolvedBaseUrl, targetPath)

if (fs.existsSync(resolvedTarget)) {
return resolvedTarget
return { id: resolvedTarget, external: true }
}
}
}
}

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, "\\$&")
}
13 changes: 11 additions & 2 deletions tests/cli/transpile/transpile-link-glb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Copy link
Contributor Author

@seveibar seveibar Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@imrishabh18 this is what I've been looking for. This is web-standard. I created the snapshot, explained how it needed to import assets, then explained to opus the issue. It quickly generated the fix


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)
Expand Down Expand Up @@ -175,6 +184,6 @@ export default () => <AliasedBoard />
.replace(/\\/g, "/")

expect(normalizedModelGlbUrl).toMatchInlineSnapshot(
`"./assets/chip-e043b555.glb"`,
`"<tmp>/aliased-glb-lib/dist/assets/chip-e043b555.glb"`,
)
}, 60_000)
2 changes: 1 addition & 1 deletion tests/cli/transpile/transpile-static-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading