diff --git a/periscope/src/frontend/scripts/sync-version.mjs b/periscope/src/frontend/scripts/sync-version.mjs new file mode 100644 index 0000000..add5e57 --- /dev/null +++ b/periscope/src/frontend/scripts/sync-version.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** Stamp APP_VERSION from the latest changelog heading into version.ts and package.json. */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const changelog = join(root, "content", "changelog.md"); +const versionFile = join(root, "src", "lib", "version.ts"); +const pkgFile = join(root, "package.json"); + +const text = readFileSync(changelog, "utf-8"); +const hit = text.match( + /^##\s+(\d+\.\d+\.\d+)(?:\s+[—-]\s+(\d{4}-\d{2}-\d{2}))?/m, +); +if (!hit) { + console.error(`[sync-version] no ## X.Y.Z heading in ${changelog}`); + process.exit(1); +} + +const version = hit[1]; +const date = hit[2] ?? ""; +const generated = `/** Stamped from content/changelog.md by scripts/sync-version.mjs. */ +export const APP_VERSION = "${version}"; +export const APP_VERSION_DATE = "${date}"; +`; + +let tsDirty = true; +try { + tsDirty = readFileSync(versionFile, "utf-8") !== generated; +} catch { + tsDirty = true; +} +if (tsDirty) writeFileSync(versionFile, generated); + +const pkg = JSON.parse(readFileSync(pkgFile, "utf-8")); +const pkgDirty = pkg.version !== version; +if (pkgDirty) { + pkg.version = version; + writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + "\n"); +} + +if (tsDirty || pkgDirty) { + const bits = [ + tsDirty ? "version.ts" : "", + pkgDirty ? "package.json" : "", + ].filter(Boolean); + console.log(`[sync-version] Synced to ${version} (${bits.join(" + ")}).`); +} else { + console.log(`[sync-version] Already at ${version}.`); +} diff --git a/periscope/src/frontend/src/lib/report-export.ts b/periscope/src/frontend/src/lib/report-export.ts new file mode 100644 index 0000000..60e30e0 --- /dev/null +++ b/periscope/src/frontend/src/lib/report-export.ts @@ -0,0 +1,60 @@ +/** Client-side findings workbook (.xlsx) for a finished review. */ + +import * as XLSX from "xlsx"; +import type { DesignGraph, Finding, ValidationReport } from "./types"; +import { sortFindings } from "./utils"; + +const COLUMNS = [ + "Designator", + "MPN", + "ID", + "Severity", + "Title", + "Description", + "Recommendation", + "Source", + "Net", + "Pins", + "Rule", +] as const; + +const WIDTHS = [12, 20, 12, 10, 44, 60, 50, 24, 16, 16, 14]; + +function sourceCell(f: Finding): string { + if (f.source && f.source !== "review") return "Automated check"; + const who = f.source_designator || f.designator; + return f.source_page ? `${who} datasheet p.${f.source_page}` : `${who} datasheet`; +} + +function fileStem(name: string): string { + const slug = name.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + return slug || "report"; +} + +export function exportReportToExcel( + report: ValidationReport, + graph: DesignGraph, + projectName?: string, +): void { + const body = sortFindings(report.findings).map((f) => [ + f.designator, + f.mpn || graph.components[f.designator]?.mpn || "", + f.finding_id ?? "", + f.status, + f.finding, + f.why ?? "", + f.recommendation ?? "", + sourceCell(f), + f.net ?? "", + (f.pins ?? []).join(", "), + f.rule_id ?? "", + ]); + const sheet = XLSX.utils.aoa_to_sheet([COLUMNS, ...body]); + sheet["!cols"] = WIDTHS.map((wch) => ({ wch })); + const book = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(book, sheet, "Findings"); + XLSX.writeFile( + book, + `${fileStem(projectName || report.project || "report")}-findings.xlsx`, + ); +} diff --git a/periscope/src/frontend/src/lib/site.ts b/periscope/src/frontend/src/lib/site.ts new file mode 100644 index 0000000..2b8c512 --- /dev/null +++ b/periscope/src/frontend/src/lib/site.ts @@ -0,0 +1,67 @@ +/** Public identity of this Periscope instance (not Faradworks). */ + +import type { Metadata } from "next"; + +export const SITE_URL = ( + process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/$/, "") || + "https://periscope.michelebigi.it" +); + +export const SITE_NAME = "Periscope"; + +export const SITE_DESCRIPTION = + "Periscope reviews your schematic against every datasheet and catches the errors that would otherwise surface at bring-up. Works with KiCad, Altium, OrCAD, Cadence, Siemens, EasyEDA, and EAGLE."; + +export const SITE_TAGLINE = "Agentic schematic validation"; + +export const OPERATOR_NAME = "Michele Bigi"; + +export const CONTACT_EMAIL = "mikbigi@gmail.com"; + +/** Unset: this operator has no X/Twitter handle. */ +export const TWITTER_HANDLE: string | undefined = undefined; + +export function pageMetadata({ + title, + description, + path, +}: { + title: string; + description: string; + path: string; +}): Metadata { + const url = path.startsWith("/") ? path : `/${path}`; + const fullTitle = `${title} · ${SITE_NAME}`; + const twitter: NonNullable = { + card: "summary_large_image", + title: fullTitle, + description, + images: ["/twitter-image"], + }; + if (TWITTER_HANDLE) { + twitter.site = TWITTER_HANDLE; + twitter.creator = TWITTER_HANDLE; + } + return { + title, + description, + alternates: { canonical: url }, + openGraph: { + type: "website", + siteName: SITE_NAME, + locale: "en_US", + url, + title: fullTitle, + description, + images: [ + { + url: "/opengraph-image", + width: 1200, + height: 630, + alt: `${SITE_NAME} — ${SITE_TAGLINE}`, + }, + ], + }, + twitter, + }; +} diff --git a/periscope/src/frontend/src/lib/version.ts b/periscope/src/frontend/src/lib/version.ts new file mode 100644 index 0000000..d3cfc78 --- /dev/null +++ b/periscope/src/frontend/src/lib/version.ts @@ -0,0 +1,3 @@ +/** Stamped from content/changelog.md by scripts/sync-version.mjs. */ +export const APP_VERSION = "2.52.0"; +export const APP_VERSION_DATE = "2026-09-20"; diff --git a/tests/test_periscope_frontend_site_rewrite.py b/tests/test_periscope_frontend_site_rewrite.py new file mode 100644 index 0000000..f19fbd0 --- /dev/null +++ b/tests/test_periscope_frontend_site_rewrite.py @@ -0,0 +1,47 @@ +"""Native frontend site/version/export modules live under periscope/src.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "periscope" / "src" / "frontend" + + +def test_site_version_export_are_src(): + for rel in ( + "src/lib/site.ts", + "src/lib/version.ts", + "src/lib/report-export.ts", + "scripts/sync-version.mjs", + ): + path = SRC / rel + assert path.is_file(), rel + assert "Native Periscope overlay" not in path.read_text(encoding="utf-8")[:400] + + +def test_site_exports(): + text = (SRC / "src/lib/site.ts").read_text(encoding="utf-8") + for name in ( + "SITE_URL", + "SITE_NAME", + "SITE_DESCRIPTION", + "SITE_TAGLINE", + "OPERATOR_NAME", + "CONTACT_EMAIL", + "TWITTER_HANDLE", + "pageMetadata", + ): + assert name in text + + +def test_report_export_entry(): + text = (SRC / "src/lib/report-export.ts").read_text(encoding="utf-8") + assert "export function exportReportToExcel" in text + assert "Findings" in text + + +def test_version_constants(): + text = (SRC / "src/lib/version.ts").read_text(encoding="utf-8") + assert "export const APP_VERSION" in text + assert "export const APP_VERSION_DATE" in text