feat: GPL backend self-provisions example libs + standalone port override

Port the KiCad symbol/footprint brace-scanner parsers + a combined extractor
into web/backend/src/extract/; ensure-example-libs blobless/sparse-clones a
curated KiCad 10.0.3 slice and extracts to a gitignored .libs/ on dev/start, so
a bare GPL clone has libraries with no closed repo present. libsConfig() defaults
LIBS_DIR to ./.libs. vite honors STANDALONE_PORT (strictPort) so a second editor
can run on :3049 alongside the closed stack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-15 11:12:56 +02:00
commit c2e1214fd1
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
10 changed files with 687 additions and 7 deletions

View file

@ -27,8 +27,12 @@ web/
(no iframe), syncs the project tree into MEMFS, drives File→Open, and runs
same-tab collaboration over BroadcastChannel.
- **Example backend**: Fastify + ts-rest serving a single project off the local
filesystem (`PROJECT_DIR`). No DB, no auth, no uploads — the minimum the editor
needs, and a worked example of the contract.
filesystem (`PROJECT_DIR`). **No DB**, no auth, no uploads — the minimum the
editor needs, and a worked example of the contract. Listens on `:3060`. On
`dev`/`start` it **self-provisions example libraries** — a curated slice of
upstream KiCad symbol + footprint libs is cloned + extracted into `.libs`
(served as read-only origins), so a bare clone has libraries to browse with no
closed repo present. See `backend/src/extract/`.
## Quick start

View file

@ -1,4 +1,9 @@
# Thin reference backend. Copy to backend/.env.
#
# It uses NO database — it serves a single project folder off disk plus a small
# set of example libraries. The example libs are self-provisioned on `dev`/`start`
# (see src/extract/ensure-example-libs.ts): a curated slice of upstream KiCad
# symbol + footprint libs is cloned + extracted into ./.libs the first time.
# Absolute or relative path to the single KiCad project folder to serve.
PROJECT_DIR=../../tests/fixtures/demo
@ -8,3 +13,11 @@ PORT=3060
# Browser origin allowed to call this backend (the Vite dev server).
CORS_ORIGIN=http://localhost:3048
# Origin (read-only) libraries dir. Unset ⇒ defaults to ./.libs (auto-provisioned).
# Point it at your own fixtures tree to override.
# LIBS_DIR=./.libs
# Writable user libraries dir (symbols/footprints created in the editor).
# Unset ⇒ defaults to ./.user-libs.
# USER_LIBS_DIR=./.user-libs

7
web/backend/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Self-provisioned example libraries (src/extract/ensure-example-libs.ts):
# .cache/ — blobless sparse clones of upstream kicad-symbols / kicad-footprints
# .libs/ — extracted serve-format fixtures (LIBS_DIR default)
# .user-libs/ — writable user libraries created via the editor (USER_LIBS_DIR default)
.cache/
.libs/
.user-libs/

View file

@ -4,8 +4,10 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "tsx src/server.ts",
"dev": "tsx src/extract/ensure-example-libs.ts && tsx watch src/server.ts",
"start": "tsx src/extract/ensure-example-libs.ts && tsx src/server.ts",
"ensure-libs": "tsx src/extract/ensure-example-libs.ts",
"extract-libs": "tsx src/extract/extract-libs.ts",
"build": "tsc -p tsconfig.json --noEmit false --declaration false --outDir dist",
"typecheck": "tsc --noEmit"
},

View file

@ -0,0 +1,122 @@
import { spawn } from "node:child_process";
import { access } from "node:fs/promises";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import {
extractAll,
FOOTPRINT_MANIFEST,
SYMBOL_MANIFEST,
} from "./extract-libs.js";
/**
* Self-provision the example libraries the GPL reference backend serves, so a
* bare `pcbjam` clone Just Works: clone the curated slice of upstream KiCad
* symbol + footprint libraries (cached), extract them into the serve format,
* and leave them in `<backend>/.libs` (which `libsConfig()` defaults to).
*
* Wired into the backend's `dev`/`start` (see package.json). Idempotent and
* offline after the first run:
* - if `.libs` is already populated, it does nothing (no network);
* - otherwise it shallow + blobless + sparse-clones ONLY the needed lib dirs
* into `.cache/`, then extracts.
*
* Re-provision from scratch with `FORCE_EXAMPLE_LIBS=1`.
*/
// KiCad library tag to pin. 10.0.x ships the unpacked one-symbol-per-file
// (`<Lib>.kicad_symdir/`) format the extractor parses; 9.0.x is monolithic.
const KICAD_REF = "10.0.3";
const SYMBOLS_URL = "https://gitlab.com/kicad/libraries/kicad-symbols.git";
const FOOTPRINTS_URL = "https://gitlab.com/kicad/libraries/kicad-footprints.git";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const BACKEND_ROOT = path.resolve(HERE, "../..");
const CACHE_DIR = path.join(BACKEND_ROOT, ".cache");
const OUT_DIR = path.join(BACKEND_ROOT, ".libs");
const SYMBOLS_SRC = path.join(CACHE_DIR, "kicad-symbols");
const FOOTPRINTS_SRC = path.join(CACHE_DIR, "kicad-footprints");
function run(cmd: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ["ignore", "inherit", "inherit"] });
child.on("error", reject);
child.on("exit", (code) =>
code === 0
? resolve()
: reject(new Error(`${cmd} ${args.join(" ")} exited ${code}`)),
);
});
}
async function exists(p: string): Promise<boolean> {
return access(p)
.then(() => true)
.catch(() => false);
}
/**
* Blobless + shallow + cone-sparse clone of only the lib directories we need
* (kicad-footprints is large; we want a few `.pretty` dirs, not all of it).
*/
async function ensureCheckout(
url: string,
dest: string,
sparseDirs: string[],
): Promise<void> {
if (await exists(dest)) return; // cached — stay offline
console.log(`[example-libs] cloning ${url} @ ${KICAD_REF} (sparse)…`);
await run("git", [
"clone",
"--filter=blob:none",
"--no-checkout",
"--depth",
"1",
"--branch",
KICAD_REF,
url,
dest,
]);
await run("git", ["-C", dest, "sparse-checkout", "init", "--cone"]);
await run("git", ["-C", dest, "sparse-checkout", "set", ...sparseDirs]);
await run("git", ["-C", dest, "checkout"]);
}
async function provisioned(): Promise<boolean> {
// A couple of representative index.json files signal a complete extract.
const [sym, fp] = await Promise.all([
exists(path.join(OUT_DIR, "Device", "index.json")),
exists(path.join(OUT_DIR, "Resistor_SMD", "index.json")),
]);
return sym && fp;
}
async function main(): Promise<void> {
const force = process.env.FORCE_EXAMPLE_LIBS === "1";
if (!force && (await provisioned())) {
console.log(`[example-libs] ${OUT_DIR} already provisioned — skipping`);
return;
}
const symbolDirs = Object.keys(SYMBOL_MANIFEST).map((l) => `${l}.kicad_symdir`);
const footprintDirs = Object.keys(FOOTPRINT_MANIFEST).map((l) => `${l}.pretty`);
await ensureCheckout(SYMBOLS_URL, SYMBOLS_SRC, symbolDirs);
await ensureCheckout(FOOTPRINTS_URL, FOOTPRINTS_SRC, footprintDirs);
console.log(`[example-libs] extracting -> ${OUT_DIR}`);
const { libs, symbols, footprints } = await extractAll({
symbolsSrc: SYMBOLS_SRC,
footprintsSrc: FOOTPRINTS_SRC,
out: OUT_DIR,
});
console.log(
`[example-libs] ready: ${libs} lib(s), ${symbols} symbols + ${footprints} footprints`,
);
}
main().catch((err) => {
// Don't hard-fail the dev server over a libs hiccup (e.g. offline first run):
// the backend still serves projects, just with no origin libraries.
console.error(`[example-libs] WARN: ${err instanceof Error ? err.message : err}`);
console.error("[example-libs] backend will start without origin libraries.");
});

View file

@ -0,0 +1,217 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
import {
buildSelfContainedLib,
libHeader,
type ParsedSymbol,
parseSymbolFile,
} from "./kicad-symdir.js";
import { parseFootprintFile } from "./kicad-pretty.js";
/**
* Extract a small curated set of real KiCad symbols + footprints into a fixtures
* tree the GPL example backend (../server.ts) serves from LIBS_DIR. Produces the
* PRE-BUILT self-contained bodies the backend hands the editor verbatim, so the
* server itself stays parser-free at request time.
*
* tsx src/extract/extract-libs.ts \
* --symbols-src <kicad-symbols checkout> \
* --footprints-src <kicad-footprints checkout> \
* --out <dir>
*
* Either source may be omitted to extract just the other kind. Layout written:
* <out>/<Lib>/index.json + <item>.kicad_sym|.kicad_mod + LICENSE.md
*
* This is the open analog of the closed apps/server extract-fixtures.ts +
* extract-footprint-fixtures.ts; it runs at GPL dev time so a bare pcbjam clone
* self-provisions example libs (see ensure-example-libs.ts). The exported
* manifests double as the sparse-checkout pick list for the clone step.
*/
/** Curated symbol pick list: the common parts a first board needs. */
export const SYMBOL_MANIFEST: Record<string, string[]> = {
Device: ["R", "C", "L", "D", "LED", "D_Schottky", "D_Zener"],
// 1N4148 extends 1N4001, 1N5817 extends SB120 — exercises extends-bundling.
Diode: ["1N4001", "1N4148", "SB120", "1N5817"],
Connector: ["Conn_01x02_Pin", "Conn_01x04_Pin"],
power: ["GND", "GNDA", "VCC", "+5V", "+3V3"],
};
/** Curated footprint pick list: the common SMD parts a first board needs. */
export const FOOTPRINT_MANIFEST: Record<string, string[]> = {
Resistor_SMD: ["R_0402_1005Metric", "R_0603_1608Metric", "R_0805_2012Metric"],
Capacitor_SMD: ["C_0402_1005Metric", "C_0603_1608Metric"],
LED_SMD: ["LED_0603_1608Metric"],
Diode_SMD: ["D_0603_1608Metric"],
};
interface IndexItem {
kind: "symbol" | "footprint";
name: string;
description: string | null;
keywords: string | null;
}
export interface ExtractOptions {
/** Path to an unpacked kicad-symbols checkout (`<Lib>.kicad_symdir/`). */
symbolsSrc?: string;
/** Path to a kicad-footprints checkout (`<Lib>.pretty/`). */
footprintsSrc?: string;
/** Output LIBS_DIR to (re)create. */
out: string;
}
async function writeLib(
out: string,
lib: string,
items: IndexItem[],
license: Uint8Array | null,
): Promise<void> {
items.sort((a, b) => a.name.localeCompare(b.name));
await writeFile(
path.join(out, lib, "index.json"),
`${JSON.stringify(
{ lib, description: `KiCad ${lib} (curated example subset)`, items },
null,
2,
)}\n`,
);
if (license) await writeFile(path.join(out, lib, "LICENSE.md"), license);
}
async function readSymbol(
symdir: string,
name: string,
): Promise<{ src: string; sym: ParsedSymbol }> {
const src = await readFile(path.join(symdir, `${name}.kicad_sym`), "utf8");
return { src, sym: parseSymbolFile(src) };
}
/** Resolve a symbol's extends chain (root-first) by reading sibling files. */
async function resolveChain(
symdir: string,
sym: ParsedSymbol,
): Promise<ParsedSymbol[]> {
const chain: ParsedSymbol[] = [];
const seen = new Set<string>([sym.name]);
let cur = sym;
while (cur.extends) {
if (seen.has(cur.extends)) throw new Error(`extends cycle at ${cur.extends}`);
seen.add(cur.extends);
const { sym: parent } = await readSymbol(symdir, cur.extends);
chain.unshift(parent);
cur = parent;
}
return chain;
}
async function extractSymbols(src: string, out: string): Promise<number> {
const license = await readFile(path.join(src, "LICENSE.md")).catch(() => null);
let total = 0;
for (const [lib, names] of Object.entries(SYMBOL_MANIFEST)) {
const symdir = path.join(src, `${lib}.kicad_symdir`);
await mkdir(path.join(out, lib), { recursive: true });
const items: IndexItem[] = [];
for (const name of names) {
const { src: fileSrc, sym } = await readSymbol(symdir, name);
const parents = await resolveChain(symdir, sym);
const body = buildSelfContainedLib(
libHeader(fileSrc),
parents.map((p) => p.block),
sym.block,
);
await writeFile(path.join(out, lib, `${name}.kicad_sym`), body);
items.push({
kind: "symbol",
name: sym.name,
description: sym.description,
keywords: sym.keywords,
});
total += 1;
}
await writeLib(out, lib, items, license);
console.log(` ${lib.padEnd(16)} ${items.length} symbols`);
}
return total;
}
async function extractFootprints(src: string, out: string): Promise<number> {
const license = await readFile(path.join(src, "LICENSE.md")).catch(() => null);
let total = 0;
for (const [lib, names] of Object.entries(FOOTPRINT_MANIFEST)) {
const pretty = path.join(src, `${lib}.pretty`);
await mkdir(path.join(out, lib), { recursive: true });
const items: IndexItem[] = [];
for (const name of names) {
const file = path.join(pretty, `${name}.kicad_mod`);
const fileSrc = await readFile(file, "utf8").catch(() => {
throw new Error(`footprint not found: ${lib}.pretty/${name}.kicad_mod`);
});
const fp = parseFootprintFile(fileSrc, name);
await writeFile(path.join(out, lib, `${name}.kicad_mod`), fp.body);
items.push({
kind: "footprint",
name: fp.name,
description: fp.description,
keywords: fp.keywords,
});
total += 1;
}
await writeLib(out, lib, items, license);
console.log(` ${lib.padEnd(16)} ${items.length} footprints`);
}
return total;
}
/** Build the combined fixtures tree. Clears `out` once, then writes every lib. */
export async function extractAll(
opts: ExtractOptions,
): Promise<{ libs: number; symbols: number; footprints: number }> {
const { symbolsSrc, footprintsSrc, out } = opts;
if (!symbolsSrc && !footprintsSrc) {
throw new Error("at least one of symbolsSrc / footprintsSrc is required");
}
// Clear the output ONCE, then write every lib (symbol + footprint) into it —
// the closed extractors each rm their own --out, which can't share a tree.
await rm(out, { recursive: true, force: true });
let libs = 0;
let symbols = 0;
let footprints = 0;
if (symbolsSrc) {
symbols = await extractSymbols(symbolsSrc, out);
libs += Object.keys(SYMBOL_MANIFEST).length;
}
if (footprintsSrc) {
footprints = await extractFootprints(footprintsSrc, out);
libs += Object.keys(FOOTPRINT_MANIFEST).length;
}
return { libs, symbols, footprints };
}
function arg(name: string): string | undefined {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : undefined;
}
async function main(): Promise<void> {
const out = arg("out");
if (!out) throw new Error("--out <dir> is required");
const { libs, symbols, footprints } = await extractAll({
symbolsSrc: arg("symbols-src"),
footprintsSrc: arg("footprints-src"),
out,
});
console.log(
`\nextracted ${libs} lib(s), ${symbols} symbols + ${footprints} footprints -> ${out}`,
);
}
// Run as a CLI only when invoked directly (not when imported by ensure-example-libs).
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

View file

@ -0,0 +1,97 @@
/**
* Parsing helpers for KiCad's footprint-library format (`.pretty` directories).
*
* A footprint library is a directory `<Name>.pretty/` holding one
* `<Footprint>.kicad_mod` file per footprint; each file is a complete,
* self-contained `(footprint "<Name>" …)` s-expr document there is no
* inheritance (unlike symbols' `extends`), so no parent-bundling step. The
* library item name is the FILE name (without `.kicad_mod`), which is what
* KiCad uses as the footprint's lib id.
*
* Like symbols, we don't need a full parser: a brace-matching scanner that
* respects quoted strings (shared with `kicad-symdir`) is enough to walk the
* footprint's direct children and read `(descr …)`, `(tags …)`, and `(model …)`.
*
* Ported from the closed ingest pipeline so the GPL reference backend can build
* its own example-lib fixtures at dev time. The fork-version cap tracks the
* WASM fork that lives in this same repo.
*/
import { matchParen, unescape } from "./kicad-symdir.js";
export interface ParsedFootprint {
/** Item name (from the file name, e.g. "R_0402_1005Metric"). */
name: string;
description: string | null;
keywords: string | null;
/** 3D model reference paths from `(model "…")` forms (bytes never fetched). */
modelRefs: string[];
/** The fork-loadable body (version capped; see capFootprintVersionForFork). */
body: string;
}
/**
* Max board/footprint file-format version the WASM fork parses
* (SEXPR_BOARD_FILE_VERSION in pcbnew/pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.h).
* A `.kicad_mod` declaring a NEWER version is rejected up front with
* FUTURE_FORMAT_ERROR. The KiCad-10.0.3 footprint data declares a newer version
* (e.g. 20260206) but introduces NO new structural tokens over this fork (every
* token in the data is in the fork's pcb.keywords) so capping the version is
* sufficient and lossless, no token stripping (unlike the symbol side). Bump
* alongside the fork.
*/
export const FORK_MAX_BOARD_VERSION = 20251028;
/** Cap the `(version NNNN)` header so the fork doesn't reject it as future-format. */
export function capFootprintVersionForFork(body: string): string {
return body.replace(/\(\s*version\s+(\d+)\s*\)/, (m, v) =>
Number(v) > FORK_MAX_BOARD_VERSION ? `(version ${FORK_MAX_BOARD_VERSION})` : m,
);
}
/** Find [start, end) of the top-level `(footprint …)` block. */
function findTopFootprint(src: string): [number, number] {
const m = src.match(/\(\s*footprint\b/);
if (!m || m.index === undefined) {
throw new Error("no (footprint …) found");
}
return matchParen(src, m.index);
}
/**
* Parse one `.kicad_mod` document. `name` is the file-derived item name. Walks
* the footprint's direct children (depth 1) so a `descr`/`tags`/`model` word
* inside a quoted string can't be mistaken for a real token.
*/
export function parseFootprintFile(src: string, name: string): ParsedFootprint {
const [s, e] = findTopFootprint(src);
const block = src.slice(s, e);
let description: string | null = null;
let keywords: string | null = null;
const modelRefs: string[] = [];
let i = block.indexOf("(", 1); // first child form
while (i >= 0 && i < block.length) {
const [cs, ce] = matchParen(block, i);
const form = block.slice(cs, ce);
const d = form.match(/^\(\s*descr\s+"((?:[^"\\]|\\.)*)"/);
if (d && description === null) description = unescape(d[1]!);
const t = form.match(/^\(\s*tags\s+"((?:[^"\\]|\\.)*)"/);
if (t && keywords === null) keywords = unescape(t[1]!);
const mo = form.match(/^\(\s*model\s+"((?:[^"\\]|\\.)*)"/);
if (mo) modelRefs.push(unescape(mo[1]!));
i = block.indexOf("(", ce); // next sibling
}
return {
name,
description,
keywords,
modelRefs,
// Store the capped full document (the .kicad_mod IS the footprint s-expr).
body: capFootprintVersionForFork(src),
};
}

View file

@ -0,0 +1,206 @@
/**
* Parsing helpers for KiCad's unpacked symbol-library format (KiCad 10+).
*
* A symbol library is a directory `<Name>.kicad_symdir/` holding one
* `<Symbol>.kicad_sym` file per symbol; each file is a `(kicad_symbol_lib …)`
* document wrapping exactly one top-level `(symbol "<Name>" …)`. A derived
* symbol carries `(extends "<Parent>")`, with the parent in a sibling file
* so to serve a self-contained symbol we bundle the parent chain into one
* `kicad_symbol_lib` document.
*
* We don't need a full s-expr parser: a brace-matching scanner that respects
* quoted strings is enough to slice out balanced `(symbol …)` blocks and read
* top-level `(property "X" "value")` / `(extends "Parent")` fields.
*
* Ported from the closed ingest pipeline so the GPL reference backend can build
* its own example-lib fixtures at dev time (no closed repo needed). The
* fork-version caps below track the WASM fork that lives in this same repo.
*/
export interface ParsedSymbol {
/** Top-level symbol name, e.g. "Speaker_Ultrasound". */
name: string;
/** Parent name if this is a derived symbol (`extends`), else null. */
extends: string | null;
/** The full balanced `(symbol …)` block text, verbatim from the source. */
block: string;
description: string | null;
keywords: string | null;
/** Footprint filters (`ki_fp_filters`), whitespace-split, or null. */
fpFilters: string[] | null;
}
/** Skip from `i` (just after an opening quote) to the index past the close. */
export function endOfString(src: string, i: number): number {
// i points at the char after the opening `"`.
while (i < src.length) {
const c = src[i];
if (c === "\\") {
i += 2; // escaped char
continue;
}
if (c === '"') return i + 1;
i += 1;
}
return i;
}
/**
* Return [start, end) of the balanced parenthesised block that begins at
* `open` (which must index a `(`), respecting quoted strings.
*/
export function matchParen(src: string, open: number): [number, number] {
let depth = 0;
let i = open;
while (i < src.length) {
const c = src[i];
if (c === '"') {
i = endOfString(src, i + 1);
continue;
}
if (c === "(") depth += 1;
else if (c === ")") {
depth -= 1;
if (depth === 0) return [open, i + 1];
}
i += 1;
}
throw new Error("unbalanced parentheses in s-expr");
}
/** Find the first `(symbol "…"` block at the top level of a kicad_symbol_lib. */
function findTopSymbol(src: string): [number, number] {
// The lib wrapper is `(kicad_symbol_lib … (symbol "…" …) )`. The first
// `(symbol "` after the wrapper open is the top-level symbol.
const m = src.match(/\(\s*symbol\s+"/);
if (!m || m.index === undefined) {
throw new Error("no (symbol …) found");
}
return matchParen(src, m.index);
}
/**
* Read a top-level `(property "Key" "Value" …)` value from inside a symbol
* block. Only scans direct children (depth 1 within the block), so nested
* sub-symbol properties don't shadow the symbol's own.
*/
function topProperty(block: string, key: string): string | null {
// Children start after the `(symbol "Name"` head. Walk depth-1 forms.
let i = block.indexOf("(", 1); // first child form
while (i >= 0 && i < block.length) {
const [s, e] = matchParen(block, i);
const form = block.slice(s, e);
const pm = form.match(
/^\(\s*property\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"/,
);
if (pm && unescape(pm[1]!) === key) return unescape(pm[2]!);
// advance to next sibling
i = block.indexOf("(", e);
}
return null;
}
export function unescape(s: string): string {
return s.replace(/\\(.)/g, "$1");
}
/** Direct child `(extends "Parent")` of a symbol block, or null. */
function topExtends(block: string): string | null {
let i = block.indexOf("(", 1);
while (i >= 0 && i < block.length) {
const [s, e] = matchParen(block, i);
const form = block.slice(s, e);
const em = form.match(/^\(\s*extends\s+"((?:[^"\\]|\\.)*)"/);
if (em) return unescape(em[1]!);
i = block.indexOf("(", e);
}
return null;
}
/** Parse one unpacked `.kicad_sym` document. */
export function parseSymbolFile(src: string): ParsedSymbol {
const [s, e] = findTopSymbol(src);
const block = src.slice(s, e);
const nameMatch = block.match(/^\(\s*symbol\s+"((?:[^"\\]|\\.)*)"/);
if (!nameMatch) throw new Error("could not read symbol name");
const fpRaw = topProperty(block, "ki_fp_filters");
return {
name: unescape(nameMatch[1]!),
extends: topExtends(block),
block,
description: topProperty(block, "Description"),
keywords: topProperty(block, "ki_keywords"),
fpFilters: fpRaw ? fpRaw.split(/\s+/).filter(Boolean) : null,
};
}
/** Extract just the `(kicad_symbol_lib …` header forms (version/generator). */
export function libHeader(src: string): string {
const open = src.indexOf("(");
const headStart = src.indexOf("(", open + 1); // first child of kicad_symbol_lib
if (headStart < 0) return "";
// Collect child forms until the first (symbol …).
let i = headStart;
const parts: string[] = [];
while (i >= 0 && i < src.length) {
const [s, e] = matchParen(src, i);
const form = src.slice(s, e);
if (/^\(\s*symbol\b/.test(form)) break;
parts.push(form);
i = src.indexOf("(", e);
}
return parts.join("\n\t");
}
/**
* Symbol-level leaf tokens present in KiCad 10.0.3 libraries that our WASM fork
* (lib format 20250925) does not yet parse its `parseLibSymbol` switch has no
* case for them, so they trip `Expecting(...)` and abort the whole parse. They
* carry no geometry/connectivity, so dropping them is lossless for the editor.
* Remove this once the fork's symbol parser is bumped to 10.0.x.
*/
const FORK_UNSUPPORTED_SYMBOL_TOKENS = ["in_pos_files", "embedded_fonts"];
/**
* Max symbol-lib format version the WASM fork parses (SEXPR_SYMBOL_LIB_FILE_VERSION
* in eeschema/sch_file_versions.h). A lib declaring a NEWER version is rejected
* up front with FUTURE_FORMAT_ERROR, before any token parsing. The two stripped
* tokens above are exactly the symbol-level additions between this and the
* 10.0.3 data version, so once they're gone the body is valid at this version.
* Bump alongside the fork.
*/
const FORK_MAX_LIB_VERSION = 20250925;
const UNSUPPORTED_RE = new RegExp(
`^[\\t ]*\\(\\s*(?:${FORK_UNSUPPORTED_SYMBOL_TOKENS.join("|")})\\s+[^()]*\\)\\s*\\n?`,
"gm",
);
/** Drop leaf tokens the fork's parser can't handle (see above). */
export function sanitizeForFork(block: string): string {
return block.replace(UNSUPPORTED_RE, "");
}
/** Cap the `(version NNNN)` header so the fork doesn't reject it as future-format. */
export function capVersionForFork(header: string): string {
return header.replace(/\(\s*version\s+(\d+)\s*\)/, (m, v) =>
Number(v) > FORK_MAX_LIB_VERSION ? `(version ${FORK_MAX_LIB_VERSION})` : m,
);
}
/**
* Build a self-contained `kicad_symbol_lib` document from a primary symbol and
* its resolved parent chain. Parents are emitted before the derived symbol so
* the on-device parser resolves `extends` within the single document. Blocks
* are sanitized for the fork parser on the way out.
*/
export function buildSelfContainedLib(
header: string,
parentBlocks: string[],
primaryBlock: string,
): string {
const body = [...parentBlocks, primaryBlock]
.map((b) => sanitizeForFork(b).replace(/^/gm, "\t").trimStart())
.join("\n\t");
return `(kicad_symbol_lib\n\t${capVersionForFork(header)}\n\t${body}\n)\n`;
}

View file

@ -11,7 +11,7 @@
// ingestion/extractor) so the open reference server stays trivial. If LIBS_DIR
// is unset or empty, the lib endpoints simply report no libraries.
import { createReadStream } from "node:fs";
import { createReadStream, existsSync } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { Lib, LibItem } from "@pcbjam/shared";
@ -20,9 +20,17 @@ export interface LibsConfig {
dir: string | null;
}
/** Self-provisioned fixtures dir (see src/extract/ensure-example-libs.ts). */
const DEFAULT_LIBS_DIR = "./.libs";
export function libsConfig(): LibsConfig {
const dir = process.env.LIBS_DIR;
return { dir: dir ? path.resolve(process.cwd(), dir) : null };
if (dir) return { dir: path.resolve(process.cwd(), dir) };
// No explicit LIBS_DIR: fall back to the self-provisioned fixtures if present,
// so `pnpm dev`/`start` (which run ensure-example-libs first) serve origins
// out of the box. Absent ⇒ no origin libraries (the prior behaviour).
const fallback = path.resolve(process.cwd(), DEFAULT_LIBS_DIR);
return { dir: existsSync(fallback) ? fallback : null };
}
/** A lib id is its directory name; reject anything that isn't a plain segment. */

View file

@ -42,7 +42,11 @@ export default defineConfig({
},
},
server: {
port: 3048,
// Default :3048. The closed `pnpm dev:gpl` runs a second editor instance on
// :3049 (alongside the closed stack) via STANDALONE_PORT. strictPort so a
// busy port fails loudly instead of drifting onto another service's port.
port: Number(process.env.STANDALONE_PORT) || 3048,
strictPort: true,
// KiCad WASM is cross-origin-isolated (COOP/COEP); same-origin /wasm assets
// load fine. Keep these so SharedArrayBuffer/threads are available.
headers: {