Skip to content
Closed
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ The `build` command also accepts the following options:
- `--ignore-placement-drc` - suppress placement DRC diagnostics
- `--ignore-routing-drc` - suppress routing DRC diagnostics

### Release export

```bash
tsci export board.tsx --release --output dist/release
```

`--release` builds once and reuses that Circuit JSON to write
`circuit.json`, `schematic.svg`, `pcb.svg`, and `gerbers.zip`. The Gerber ZIP
includes the existing BOM and JLCPCB pick-and-place files.

`--output` is a directory for release exports and defaults to `dist/release`, relative
to the input file's directory. It is created automatically. `--release` cannot
be combined with `--format`.

### KiCad PCM compatibility

`tsci build --kicad-pcm` uses the package license from `package.json` and
Expand Down
14 changes: 12 additions & 2 deletions cli/export/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,28 @@ export const registerExport = (program: Command) => {
"-f, --format <format>",
`Output format (${ALLOWED_EXPORT_FORMATS.join(", ")})`,
)
.option("-o, --output <path>", "Output file path")
.option(
"--release",
"Export circuit JSON, schematic SVG, PCB SVG, and Gerbers",
)
.option("-o, --output <path>", "Output file path or release directory")
.option("--disable-parts-engine", "Disable the parts engine")
.option("--show-courtyards", "Show courtyard outlines in PCB SVG output")
.action(
async (
file,
options: {
release?: boolean
format?: string
output?: string
disablePartsEngine?: boolean
showCourtyards?: boolean
},
) => {
if (options.release && options.format !== undefined) {
console.error("--release cannot be combined with --format")
process.exit(1)
}
const formatOption = options.format ?? "json"
const projectConfig = await loadRuntimeProjectConfig(process.cwd())

Expand Down Expand Up @@ -88,11 +97,12 @@ export const registerExport = (program: Command) => {
process.exit(0)
}

const format = formatOption as ExportFormat
const format = options.format as ExportFormat | undefined

await exportSnippet({
filePath: file,
format,
release: options.release,
outputPath: options.output,
platformConfig: platformConfigWithCliDefaults,
pcbSnapshotSettings: options.showCourtyards
Expand Down
104 changes: 87 additions & 17 deletions lib/shared/export-snippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ const OUTPUT_EXTENSIONS: Record<ExportFormat, string> = {
"component-box-3mf": "-component-box.3mf",
}

const RELEASE_EXPORTS = [
{ format: "circuit-json", fileName: "circuit.json" },
{ format: "schematic-svg", fileName: "schematic.svg" },
{ format: "pcb-svg", fileName: "pcb.svg" },
{ format: "gerbers", fileName: "gerbers.zip" },
] satisfies { format: ExportFormat; fileName: string }[]

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null

Expand All @@ -103,7 +110,8 @@ const unwrapSimpleRouteJson = (value: unknown) => {

type ExportOptions = {
filePath: string
format: ExportFormat
format?: ExportFormat
release?: boolean
writeFile?: boolean
outputPath?: string
platformConfig?: PlatformConfig
Expand All @@ -118,7 +126,8 @@ type ExportOptions = {

export const exportSnippet = async ({
filePath,
format,
format: formatOption,
release = false,
outputPath,
platformConfig,
pcbSnapshotSettings,
Expand All @@ -127,14 +136,26 @@ export const exportSnippet = async ({
onError = (message) => console.error(message),
onSuccess = (result: unknown) => console.log(result),
}: ExportOptions) => {
if (release && formatOption) {
onError("--release cannot be combined with --format")
return onExit(1)
}
const format = formatOption ?? "json"
if (!ALLOWED_EXPORT_FORMATS.includes(format)) {
onError(`Invalid format: ${format}`)
return onExit(1)
}

const projectDir = path.dirname(filePath)
const outputBaseName = path.basename(filePath).replace(/\.[^.]+$/, "")
const outputFileName = `${outputBaseName}${OUTPUT_EXTENSIONS[format]}`
let outputFileName = `${outputBaseName}${OUTPUT_EXTENSIONS[format]}`
if (release) {
if (!writeFile) {
onError("Release export requires writing to an output directory")
return onExit(1)
}
outputFileName = path.join("dist", "release")
}
const outputDestination =
outputPath && path.isAbsolute(outputPath)
? outputPath
Expand Down Expand Up @@ -182,7 +203,7 @@ export const exportSnippet = async ({
return onExit(1)
}
} else {
const isJlcpcbFabricationExport = format === "gerbers"
const isJlcpcbFabricationExport = format === "gerbers" || release
const fabricationPlatformConfig = isJlcpcbFabricationExport
? getPlatformConfigWithCliDefaults(
mergePlatformConfigs(platformConfig, {
Expand All @@ -206,6 +227,67 @@ export const exportSnippet = async ({
circuitJson = circuitData.circuitJson
}

if (release) {
try {
await fs.promises.mkdir(outputDestination, { recursive: true })
for (const { format, fileName } of RELEASE_EXPORTS) {
const outputContent = await convertCircuitJsonToExport({
circuitJson,
format,
filePath,
platformConfig,
pcbSnapshotSettings,
})
await writeFileAsync(
path.join(outputDestination, fileName),
outputContent,
)
}
onSuccess({ outputDestination, outputContent: "" })
return onExit(0)
} catch (err) {
onError(`Error exporting release: ${err}`)
return onExit(1)
}
}

const outputContent = await convertCircuitJsonToExport({
circuitJson,
format,
filePath,
platformConfig,
pcbSnapshotSettings,
})
if (writeFile) {
await writeFileAsync(outputDestination, outputContent).catch((err) => {
onError(`Error writing file: ${err}`)
return onExit(1)
})
}

onSuccess({
outputDestination,
outputContent,
})

onExit(0)
}

const convertCircuitJsonToExport = async ({
circuitJson,
format,
filePath,
platformConfig,
pcbSnapshotSettings,
}: {
circuitJson: AnyCircuitElement[]
format: ExportFormat
filePath: string
platformConfig?: PlatformConfig
pcbSnapshotSettings?: PcbSnapshotSettings
}): Promise<string | Buffer> => {
const projectDir = path.dirname(filePath)
const outputBaseName = path.basename(filePath).replace(/\.[^.]+$/, "")
let outputContent: string | Buffer

switch (format) {
Expand Down Expand Up @@ -346,17 +428,5 @@ export const exportSnippet = async ({
default:
outputContent = JSON.stringify(circuitJson, null, 2)
}
if (writeFile) {
await writeFileAsync(outputDestination, outputContent).catch((err) => {
onError(`Error writing file: ${err}`)
return onExit(1)
})
}

onSuccess({
outputDestination,
outputContent,
})

onExit(0)
return outputContent
}
83 changes: 83 additions & 0 deletions tests/cli/export/export-release.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { expect, test } from "bun:test"
import { readFile, readdir, writeFile } from "node:fs/promises"
import path from "node:path"
import JSZip from "jszip"
import { getCliTestFixture } from "../../fixtures/get-cli-test-fixture"

const releaseFiles = [
"circuit.json",
"schematic.svg",
"pcb.svg",
"gerbers.zip",
].sort()

test(
"release builds once and exports circuit JSON, SVGs, and Gerbers",
async () => {
const { tmpDir, runCommand } = await getCliTestFixture()
const circuitPath = path.join(tmpDir, "board.tsx")
const outputDir = path.join(tmpDir, "dist", "release")
await writeFile(
circuitPath,
`
import { appendFileSync } from "node:fs"
export default () => {
appendFileSync(${JSON.stringify(path.join(tmpDir, "builds.txt"))}, "build\\n")
return <board width="10mm" height="10mm">
<resistor name="R1" resistance="1k" footprint="0402" />
</board>
}
`,
)

const result = await runCommand(
`tsci export ${circuitPath} --release --output dist/release --disable-parts-engine`,
)
expect(result.exitCode).toBe(0)
expect(await readFile(path.join(tmpDir, "builds.txt"), "utf8")).toBe(
"build\n",
)
expect((await readdir(outputDir)).sort()).toEqual(releaseFiles)
for (const fileName of ["schematic.svg", "pcb.svg"]) {
expect(await readFile(path.join(outputDir, fileName), "utf8")).toContain(
"<svg",
)
}
const zip = await JSZip.loadAsync(
await readFile(path.join(outputDir, "gerbers.zip")),
)
expect(await zip.file("bom.csv")!.async("string")).toContain("R1")
expect(await zip.file("pick_and_place.csv")!.async("string")).toContain(
"R1",
)
expect(
JSON.parse(await readFile(path.join(outputDir, "circuit.json"), "utf8")),
).toBeArray()
},
{ timeout: 60_000 },
)

test("release rejects conflicting formats and unwritable destinations", async () => {
const { tmpDir, runCommand } = await getCliTestFixture()
for (const format of ["json", "spice", "gerbers"]) {
const conflict = await runCommand(
`tsci export missing.tsx --release --format ${format}`,
)
expect(conflict.exitCode).not.toBe(0)
expect(conflict.stderr).toContain(
"--release cannot be combined with --format",
)
}
const circuitPath = path.join(tmpDir, "board.circuit.json")
await writeFile(circuitPath, "[]")
const outputPath = path.join(tmpDir, "existing-file")
await writeFile(outputPath, "keep me")
expect(
(
await runCommand(
`tsci export ${circuitPath} --release --output ${outputPath}`,
)
).exitCode,
).not.toBe(0)
expect(await readFile(outputPath, "utf8")).toBe("keep me")
})
Loading