pcbjam/web/backend/src/libs.ts
Gergő Törcsvári c2e1214fd1
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>
2026-06-16 16:28:21 +02:00

129 lines
4.2 KiB
TypeScript

// Minimal reference libraries serving for the @pcbjam/shared libs contract.
//
// Serves PRE-BUILT self-contained symbol bodies from a directory (LIBS_DIR),
// laid out as:
//
// <LIBS_DIR>/<LibName>/index.json { items: [{kind,name,description,keywords}], ... }
// <LIBS_DIR>/<LibName>/<Symbol>.kicad_sym a complete kicad_symbol_lib s-expr
// <LIBS_DIR>/<LibName>/LICENSE.md attribution (CC-BY-SA travels with data)
//
// This backend does NO parsing — bodies are produced upstream (the closed
// 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, existsSync } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type { Lib, LibItem } from "@pcbjam/shared";
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;
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. */
function safeLibDir(root: string, lib: string): string | null {
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(lib)) return null;
return path.join(root, lib);
}
/** Per-kind body file extension (one file per item), or null for unknown kinds. */
export function extForKind(kind: string): string | null {
if (kind === "symbol") return ".kicad_sym";
if (kind === "footprint") return ".kicad_mod";
return null;
}
interface IndexFile {
items?: { kind: string; name: string; description?: string | null; keywords?: string | null }[];
description?: string | null;
}
async function readIndex(dir: string): Promise<IndexFile | null> {
try {
return JSON.parse(await fs.readFile(path.join(dir, "index.json"), "utf8"));
} catch {
return null;
}
}
export async function listLibs(
cfg: LibsConfig,
kind?: string,
): Promise<Lib[]> {
if (!cfg.dir) return [];
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(cfg.dir, { withFileTypes: true });
} catch {
return [];
}
const libs: Lib[] = [];
for (const e of entries) {
if (!e.isDirectory() || e.name.startsWith(".")) continue;
const idx = await readIndex(path.join(cfg.dir, e.name));
// Filter origins by item kind when requested (a footprint tool shouldn't
// list symbol-only origins, and vice versa).
if (kind && !(idx?.items ?? []).some((i) => i.kind === kind)) continue;
libs.push({
id: e.name,
name: e.name,
type: "origin",
description: idx?.description ?? null,
itemCount: idx?.items?.length ?? undefined,
});
}
libs.sort((a, b) => a.name.localeCompare(b.name));
return libs;
}
export async function listLibItems(
cfg: LibsConfig,
lib: string,
): Promise<LibItem[] | null> {
if (!cfg.dir) return null;
const dir = safeLibDir(cfg.dir, lib);
if (!dir) return null;
const idx = await readIndex(dir);
if (!idx) return null;
return (idx.items ?? []).map((i) => ({
kind: i.kind,
name: i.name,
description: i.description ?? null,
keywords: i.keywords ?? null,
}));
}
/** Resolve the on-disk body file for one item, or null (guarded). */
export function itemBodyPath(
cfg: LibsConfig,
lib: string,
kind: string,
name: string,
): string | null {
const ext = extForKind(kind);
if (!cfg.dir || !ext) return null;
const dir = safeLibDir(cfg.dir, lib);
if (!dir) return null;
// Item names allow a wide charset but never path separators.
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
return null;
}
return path.join(dir, `${name}${ext}`);
}
export function streamBody(absPath: string) {
return createReadStream(absPath);
}