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:
parent
54731fe8ba
commit
c2e1214fd1
10 changed files with 687 additions and 7 deletions
|
|
@ -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
7
web/backend/.gitignore
vendored
Normal 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/
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
122
web/backend/src/extract/ensure-example-libs.ts
Normal file
122
web/backend/src/extract/ensure-example-libs.ts
Normal 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.");
|
||||
});
|
||||
217
web/backend/src/extract/extract-libs.ts
Normal file
217
web/backend/src/extract/extract-libs.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
97
web/backend/src/extract/kicad-pretty.ts
Normal file
97
web/backend/src/extract/kicad-pretty.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
206
web/backend/src/extract/kicad-symdir.ts
Normal file
206
web/backend/src/extract/kicad-symdir.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue