diff --git a/periscope/src/frontend/src/app/opengraph-image.tsx b/periscope/src/frontend/src/app/opengraph-image.tsx new file mode 100644 index 0000000..dbb010e --- /dev/null +++ b/periscope/src/frontend/src/app/opengraph-image.tsx @@ -0,0 +1,76 @@ +import { ImageResponse } from "next/og"; +import { SITE_TAGLINE, SITE_URL } from "@/lib/site"; + +export const alt = `Periscope — ${SITE_TAGLINE}`; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +const HOST = SITE_URL.replace(/^https?:\/\//, ""); + +export default function OpenGraphImage() { + return new ImageResponse( + ( +
+
+ + + + + + + + +
+ Periscope +
+
+
+
+ Ship hardware that works the first time. +
+
+ Datasheet-grounded schematic review. Catches the errors that would otherwise + surface at bring-up. +
+
+
+
+ KiCad · Altium · OrCAD · Cadence · Siemens · EasyEDA · EAGLE +
+
{HOST}
+
+
+ ), + { ...size }, + ); +} diff --git a/periscope/src/frontend/src/app/twitter-image.tsx b/periscope/src/frontend/src/app/twitter-image.tsx new file mode 100644 index 0000000..76d5636 --- /dev/null +++ b/periscope/src/frontend/src/app/twitter-image.tsx @@ -0,0 +1 @@ +export { default, alt, size, contentType } from "./opengraph-image"; diff --git a/periscope/src/frontend/src/components/legal/changelog-timeline.tsx b/periscope/src/frontend/src/components/legal/changelog-timeline.tsx new file mode 100644 index 0000000..67419be --- /dev/null +++ b/periscope/src/frontend/src/components/legal/changelog-timeline.tsx @@ -0,0 +1,179 @@ +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { PeriscopeMark } from "@/components/brand/periscope-mark"; +import { OPERATOR_NAME } from "@/lib/site"; + +type Kind = "New" | "Improved" | "Fixed"; + +type Release = { + version: string; + date: string; + title?: string; + description?: string; + items: { tag: Kind; text: string }[]; +}; + +function parseReleases(md: string): Release[] { + const out: Release[] = []; + let current: Release | null = null; + let blurb: string[] = []; + + const flush = () => { + if (!current) return; + const description = blurb.join(" ").trim(); + if (description) current.description = description; + out.push(current); + current = null; + blurb = []; + }; + + for (const raw of md.split("\n")) { + const line = raw.trimEnd(); + if (line.startsWith("## ")) { + flush(); + const bits = line.slice(3).split(" — "); + current = { + version: bits[0]?.trim() ?? "", + date: bits[1]?.trim() ?? "", + title: bits[2]?.trim() || undefined, + items: [], + }; + continue; + } + if (!current) continue; + if (line.startsWith("- ")) { + const rest = line.slice(2); + const tagged = rest.match(/^\[(New|Improved|Fixed)\]\s*(.+)$/); + if (tagged) current.items.push({ tag: tagged[1] as Kind, text: tagged[2] }); + else current.items.push({ tag: "Improved", text: rest }); + continue; + } + if (current.items.length === 0 && line.trim() && !line.startsWith("#")) { + blurb.push(line.trim()); + } + } + flush(); + return out; +} + +function prettyDate(iso: string): string { + const d = new Date(`${iso}T00:00:00Z`); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }); +} + +const KIND_CLASS: Record = { + New: "border-emerald-500/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + Improved: "border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400", + Fixed: "border-orange-500/40 bg-orange-500/10 text-orange-600 dark:text-orange-400", +}; + +function KindPill({ tag }: { tag: Kind }) { + return ( + + {tag} + + ); +} + +export function ChangelogTimeline({ content }: { content: string }) { + const releases = parseReleases(content); + return ( +
+
+
+ + + Back to home + + + + Periscope + +
+
+
+
+

Changelog

+

What's new in Periscope.

+
+
    + {releases.map((entry, i) => { + const last = i === releases.length - 1; + return ( +
  1. + + {!last && ( + + )} +
    + {prettyDate(entry.date)} + {entry.version && ( + <> + + · + + v{entry.version} + + )} +
    + {entry.title && ( +

    {entry.title}

    + )} + {entry.description && ( +

    + {entry.description} +

    + )} +
      + {entry.items.map((item, j) => ( +
    • + + + {item.text} + +
    • + ))} +
    +
  2. + ); + })} +
+
+
+
+
+ + Changelog + + + Privacy Policy + + + Terms of Service + +
+ + © {new Date().getFullYear()} {OPERATOR_NAME} + +
+
+
+ ); +} diff --git a/periscope/src/frontend/src/components/legal/file-guide-page.tsx b/periscope/src/frontend/src/components/legal/file-guide-page.tsx new file mode 100644 index 0000000..4b9f0e4 --- /dev/null +++ b/periscope/src/frontend/src/components/legal/file-guide-page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useMemo } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { LegalPageShell, MarkdownContent } from "@/components/legal/legal-page"; + +const EXPORT_H2 = "## Exporting from your EDA tool"; + +type ToolTab = { name: string; body: string }; + +function splitGuide(content: string): { before: string; tabs: ToolTab[]; after: string } { + const marker = `\n${EXPORT_H2}\n`; + const at = content.indexOf(marker); + if (at === -1) return { before: content, tabs: [], after: "" }; + + const before = content.slice(0, at).trimEnd(); + const rest = content.slice(at + marker.length); + const next = rest.search(/\n## [^\n]/); + const exportBody = next === -1 ? rest : rest.slice(0, next); + const after = next === -1 ? "" : rest.slice(next).trimStart(); + + const chunks = exportBody.split(/\n### /); + const intro = chunks.shift()?.trim() ?? ""; + const tabs: ToolTab[] = chunks.map((chunk) => { + const nl = chunk.indexOf("\n"); + const name = (nl === -1 ? chunk : chunk.slice(0, nl)).trim(); + const body = nl === -1 ? "" : chunk.slice(nl + 1).trim(); + return { name, body }; + }); + return { + before: intro ? `${before}\n\n${EXPORT_H2}\n\n${intro}` : `${before}\n\n${EXPORT_H2}`, + tabs, + after, + }; +} + +function slug(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export function FileGuidePage({ content }: { content: string }) { + const guide = useMemo(() => splitGuide(content), [content]); + if (guide.tabs.length === 0) { + return ( + + {content} + + ); + } + const first = slug(guide.tabs[0].name); + return ( + + {guide.before} + + + {guide.tabs.map((tab) => ( + + {tab.name} + + ))} + + {guide.tabs.map((tab) => ( + + {tab.body} + + ))} + + {guide.after ? {guide.after} : null} + + ); +} diff --git a/periscope/src/frontend/src/components/legal/legal-page.tsx b/periscope/src/frontend/src/components/legal/legal-page.tsx new file mode 100644 index 0000000..26d8c89 --- /dev/null +++ b/periscope/src/frontend/src/components/legal/legal-page.tsx @@ -0,0 +1,147 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import Markdown from "react-markdown"; +import { ArrowLeft } from "lucide-react"; +import { PeriscopeMark } from "@/components/brand/periscope-mark"; +import { OPERATOR_NAME } from "@/lib/site"; + +function nodeText(node: ReactNode): string { + if (node == null || typeof node === "boolean") return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(nodeText).join(""); + if (typeof node === "object" && "props" in node) { + return nodeText((node as { props: { children?: ReactNode } }).props.children); + } + return ""; +} + +function headingId(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .trim() + .replace(/\s+/g, "-"); +} + +export function MarkdownContent({ children }: { children: string }) { + return ( + ( +

{children}

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + p: ({ children }) => ( +

{children}

+ ), + ul: ({ children }) => ( + + ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => {children}, + a: ({ href, children }) => ( + + {children} + + ), + code: ({ className, children, ...props }) => { + const text = nodeText(children); + const block = text.includes("\n") || Boolean(className); + if (block) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children }) => ( +
    +            {children}
    +          
    + ), + }} + > + {children} +
    + ); +} + +export function LegalPageShell({ children }: { children: ReactNode }) { + return ( +
    +
    +
    + + + Back to home + + + + Periscope + +
    +
    +
    {children}
    +
    +
    +
    + + Changelog + + + Privacy Policy + + + Terms of Service + +
    + + © {new Date().getFullYear()} {OPERATOR_NAME} + +
    +
    +
    + ); +} + +export function LegalPage({ content }: { content: string }) { + return ( + + {content} + + ); +} diff --git a/tests/test_periscope_frontend_legal_rewrite.py b/tests/test_periscope_frontend_legal_rewrite.py new file mode 100644 index 0000000..6b6e9b3 --- /dev/null +++ b/tests/test_periscope_frontend_legal_rewrite.py @@ -0,0 +1,62 @@ +"""Legal shells and Open Graph images live under periscope/src.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "periscope" / "src" / "frontend" / "src" + + +def _text(rel: str) -> str: + path = SRC / rel + assert path.is_file(), rel + return path.read_text(encoding="utf-8") + + +def test_legal_and_og_are_src(): + for rel in ( + "components/legal/legal-page.tsx", + "components/legal/changelog-timeline.tsx", + "components/legal/file-guide-page.tsx", + "app/opengraph-image.tsx", + "app/twitter-image.tsx", + ): + head = _text(rel)[:400] + assert "Native Periscope overlay" not in head + + +def test_legal_page_exports(): + text = _text("components/legal/legal-page.tsx") + assert "export function MarkdownContent" in text + assert "export function LegalPageShell" in text + assert "export function LegalPage" in text + assert "OPERATOR_NAME" in text + assert "PeriscopeMark" in text + + +def test_changelog_timeline_parses_tagged_items(): + text = _text("components/legal/changelog-timeline.tsx") + assert "export function ChangelogTimeline" in text + assert "function parseReleases" in text + assert "New|Improved|Fixed" in text + assert "What's new in Periscope." in text + + +def test_file_guide_splits_on_eda_export_heading(): + text = _text("components/legal/file-guide-page.tsx") + assert "export function FileGuidePage" in text + assert "## Exporting from your EDA tool" in text + assert "function splitGuide" in text + assert 'from "@/components/ui/tabs"' in text + + +def test_opengraph_contract(): + og = _text("app/opengraph-image.tsx") + assert 'from "next/og"' in og + assert "export const size = { width: 1200, height: 630 }" in og + assert 'export const contentType = "image/png"' in og + assert "SITE_URL" in og + tw = _text("app/twitter-image.tsx") + assert 'from "./opengraph-image"' in tw + assert "export { default, alt, size, contentType }" in tw