diff --git a/web/README.md b/web/README.md index f5fb8fc..9bb5204 100644 --- a/web/README.md +++ b/web/README.md @@ -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 diff --git a/web/backend/.env.example b/web/backend/.env.example index 277d97d..dd6d64f 100644 --- a/web/backend/.env.example +++ b/web/backend/.env.example @@ -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 diff --git a/web/backend/.gitignore b/web/backend/.gitignore new file mode 100644 index 0000000..f99eb48 --- /dev/null +++ b/web/backend/.gitignore @@ -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/ diff --git a/web/backend/package.json b/web/backend/package.json index 1df1fad..b7cb0b7 100644 --- a/web/backend/package.json +++ b/web/backend/package.json @@ -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" }, diff --git a/web/backend/src/extract/ensure-example-libs.ts b/web/backend/src/extract/ensure-example-libs.ts new file mode 100644 index 0000000..cbb6913 --- /dev/null +++ b/web/backend/src/extract/ensure-example-libs.ts @@ -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 `/.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 +// (`.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 { + 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 { + 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 { + 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 { + // 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 { + 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."); +}); diff --git a/web/backend/src/extract/extract-libs.ts b/web/backend/src/extract/extract-libs.ts new file mode 100644 index 0000000..d5a5db8 --- /dev/null +++ b/web/backend/src/extract/extract-libs.ts @@ -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 \ + * --footprints-src \ + * --out + * + * Either source may be omitted to extract just the other kind. Layout written: + * //index.json + .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 = { + 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 = { + 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 (`.kicad_symdir/`). */ + symbolsSrc?: string; + /** Path to a kicad-footprints checkout (`.pretty/`). */ + footprintsSrc?: string; + /** Output LIBS_DIR to (re)create. */ + out: string; +} + +async function writeLib( + out: string, + lib: string, + items: IndexItem[], + license: Uint8Array | null, +): Promise { + 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 { + const chain: ParsedSymbol[] = []; + const seen = new Set([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 { + 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 { + 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 { + const out = arg("out"); + if (!out) throw new Error("--out 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); + }); +} diff --git a/web/backend/src/extract/kicad-pretty.ts b/web/backend/src/extract/kicad-pretty.ts new file mode 100644 index 0000000..420c19a --- /dev/null +++ b/web/backend/src/extract/kicad-pretty.ts @@ -0,0 +1,97 @@ +/** + * Parsing helpers for KiCad's footprint-library format (`.pretty` directories). + * + * A footprint library is a directory `.pretty/` holding one + * `.kicad_mod` file per footprint; each file is a complete, + * self-contained `(footprint "" …)` 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), + }; +} diff --git a/web/backend/src/extract/kicad-symdir.ts b/web/backend/src/extract/kicad-symdir.ts new file mode 100644 index 0000000..64dbff7 --- /dev/null +++ b/web/backend/src/extract/kicad-symdir.ts @@ -0,0 +1,206 @@ +/** + * Parsing helpers for KiCad's unpacked symbol-library format (KiCad 10+). + * + * A symbol library is a directory `.kicad_symdir/` holding one + * `.kicad_sym` file per symbol; each file is a `(kicad_symbol_lib …)` + * document wrapping exactly one top-level `(symbol "" …)`. A derived + * symbol carries `(extends "")`, 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`; +} diff --git a/web/backend/src/libs.ts b/web/backend/src/libs.ts index 0b87021..7042f95 100644 --- a/web/backend/src/libs.ts +++ b/web/backend/src/libs.ts @@ -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. */ diff --git a/web/standalone/vite.config.ts b/web/standalone/vite.config.ts index daff644..701d5a8 100644 --- a/web/standalone/vite.config.ts +++ b/web/standalone/vite.config.ts @@ -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: {