diff --git a/apps/console/next.config.mjs b/apps/console/next.config.mjs index c7d8bc277..209dc6cc9 100644 --- a/apps/console/next.config.mjs +++ b/apps/console/next.config.mjs @@ -56,6 +56,9 @@ const nextConfig = { "/llms-full.txt": ["../../docs/agent-skills/**/*.md"], "/llms.txt": ["../../docs/agent-skills/**/*.md"], "/well-known/skills/**": ["../../docs/agent-skills/**/*.md"], + // The icon resolver reads this offline corpus by path at runtime rather + // than importing each SVG, so Next's standalone tracer cannot infer it. + "/*": ["../../node_modules/simple-icons/data/simple-icons.json", "../../node_modules/simple-icons/icons/*.svg"], }, outputFileTracingRoot: path.join(__dirname, "../.."), reactStrictMode: true, diff --git a/apps/console/public/connector-icons/NOTICE b/apps/console/public/connector-icons/NOTICE new file mode 100644 index 000000000..c92397292 --- /dev/null +++ b/apps/console/public/connector-icons/NOTICE @@ -0,0 +1,19 @@ +Copyright © 2026 The PDP-Connect Contributors +Licensed under the Apache License, Version 2.0. + +## Third-party connector brand icons + +Shipped connector manifests in `packages/polyfill-connectors/manifests/` each +declare a small brand glyph (`brand.icon`, under `manifests/icons/*.svg`) to +identify the third-party service the connector connects to (e.g. Amazon, +GitHub, Spotify). All product names, trademarks, and registered trademarks +referenced or depicted by these icons are the property of their respective +owners. These icons are used by the PDP-Connect project strictly to identify +the products and companies a connector integrates with; their use implies no +sponsorship, affiliation, or endorsement by the mark holder. + +A connector manifest with no legitimately available brand mark should not +carry an invented or unofficial logo. Consumers of this package's +`connector-index.json` render a deterministic monogram (the connector's +initial(s) and a color derived from its name) as the correct default for +that case — this is a normal, first-class fallback, not an error state. diff --git a/apps/console/public/connector-icons/PROVENANCE.md b/apps/console/public/connector-icons/PROVENANCE.md new file mode 100644 index 000000000..fd29c5c8b --- /dev/null +++ b/apps/console/public/connector-icons/PROVENANCE.md @@ -0,0 +1,9 @@ +# Connector icon provenance + +The console bundle uses the manifest-declared SVG files from +[PDP-Connect/data-connectors](https://github.com/PDP-Connect/data-connectors) +revision `23aee3aaeafad3a804d504e8614763f7845add60`. Their connector keys, canonical IDs, source paths, and SVG markup are recorded in the generated +`sanitized-assets.json` bundle under the console library. The bundle is checked +with the console SVG allowlist before it reaches the shared icon renderer. + +See [NOTICE](./NOTICE) for attribution and trademark terms. diff --git a/apps/console/scripts/sync-connector-icons.mjs b/apps/console/scripts/sync-connector-icons.mjs new file mode 100644 index 000000000..28d06e00e --- /dev/null +++ b/apps/console/scripts/sync-connector-icons.mjs @@ -0,0 +1,107 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { sanitizeSvg } from "@pdpp/brand-react/sanitize-svg"; +import { assertIconCorpusConsistent } from "../src/app/(console)/lib/connector-icon-corpus-checks.ts"; + +const REPOSITORY = "PDP-Connect/data-connectors"; +const REVISION = "23aee3aaeafad3a804d504e8614763f7845add60"; +// Intentional tripwire: a connector gaining or losing a declared icon changes +// the shipped bundle, so the pinned count is bumped together with REVISION. +const EXPECTED_ASSETS = 65; +const MANIFEST_PATH = /^connectors\/[^/]+\/manifest\.json$/u; +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const CONSOLE_DIR = path.resolve(SCRIPT_DIR, ".."); +const LIB_DIR = path.join(CONSOLE_DIR, "src/app/(console)/lib/connector-icons"); +const PUBLIC_DIR = path.join(CONSOLE_DIR, "public/connector-icons"); +const checkOnly = process.argv.includes("--check"); +const sourceRepo = process.argv.slice(2).find((arg) => arg !== "--check"); + +if (!sourceRepo) { + throw new Error("Pass the existing local data-connectors repository path; this script does not clone or fetch."); +} + +function git(...args) { + return execFileSync("git", ["-C", sourceRepo, ...args], { encoding: "utf8", maxBuffer: 1024 * 1024 }); +} + +if (git("rev-parse", `${REVISION}^{commit}`).trim() !== REVISION) { + throw new Error(`Local ${REPOSITORY} repository does not contain pinned revision ${REVISION}.`); +} + +const manifestPaths = git("ls-tree", "-r", "--name-only", REVISION, "--", "connectors/") + .split("\n") + .filter((file) => MANIFEST_PATH.test(file)); +const entries = []; +for (const manifestPath of manifestPaths) { + const manifest = JSON.parse(git("show", `${REVISION}:${manifestPath}`)); + const iconPath = manifest.brand?.icon; + if (typeof iconPath !== "string" || !iconPath.trim()) { + continue; + } + const parts = iconPath.split("/"); + if ( + iconPath.startsWith("/") || + iconPath.includes("\\") || + parts.some((part) => !part || part === "." || part === "..") + ) { + throw new Error(`Unsafe declared icon path in ${manifestPath}.`); + } + const sourcePath = path.posix.join(path.posix.dirname(manifestPath), iconPath); + const svg = git("show", `${REVISION}:${sourcePath}`).trim(); + if (sanitizeSvg(svg) !== svg) { + throw new Error(`Declared SVG failed the console allowlist: ${sourcePath}.`); + } + if (typeof manifest.connector_key !== "string" || typeof manifest.connector_id !== "string") { + throw new Error(`Manifest identity is incomplete: ${manifestPath}.`); + } + entries.push({ + background_color: manifest.brand.background_color, + connector_key: manifest.connector_key, + connector_id: manifest.connector_id, + display_name: manifest.display_name ?? manifest.source?.display?.name ?? manifest.connector_key, + path: sourcePath, + source_id: manifest.source?.id ?? null, + svg, + }); +} +entries.sort((left, right) => { + if (left.connector_key === right.connector_key) { + return 0; + } + return left.connector_key < right.connector_key ? -1 : 1; +}); +// The runtime indexes marks by every spelling of an identity, so a repeated or +// colliding identity, a free-form background, or a source whose entries +// disagree would make the shipped mark depend on file order. +assertIconCorpusConsistent(entries); +if (entries.length !== EXPECTED_ASSETS) { + throw new Error(`Pinned source produced ${entries.length} icon assets; expected ${EXPECTED_ASSETS}.`); +} +const serialized = `${JSON.stringify({ repo: REPOSITORY, revision: REVISION, entries }, null, 2)}\n`; +const notice = git("show", `${REVISION}:NOTICE`); +const provenance = `# Connector icon provenance\n\nThe console bundle uses the manifest-declared SVG files from\n[${REPOSITORY}](https://github.com/${REPOSITORY})\nrevision \`${REVISION}\`. Their connector keys, canonical IDs, source paths, and SVG markup are recorded in the generated\n\`sanitized-assets.json\` bundle under the console library. The bundle is checked\nwith the console SVG allowlist before it reaches the shared icon renderer.\n\nSee [NOTICE](./NOTICE) for attribution and trademark terms.\n`; +const outputs = [ + [path.join(LIB_DIR, "sanitized-assets.json"), serialized], + [path.join(LIB_DIR, "NOTICE"), notice], + [path.join(PUBLIC_DIR, "NOTICE"), notice], + [path.join(PUBLIC_DIR, "PROVENANCE.md"), provenance], +]; +if (checkOnly) { + for (const [file, expected] of outputs) { + if (readFileSync(file, "utf8") !== expected) { + throw new Error(`Generated icon output differs: ${file}. Run this script without --check to refresh it.`); + } + } + process.stdout.write(`Verified ${entries.length} icons from ${REVISION}.\n`); +} else { + for (const [file, contents] of outputs) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, contents); + } + process.stdout.write(`Wrote ${entries.length} icons from ${REVISION}.\n`); +} diff --git a/apps/console/src/app/(console)/components/connector-mark.tsx b/apps/console/src/app/(console)/components/connector-mark.tsx index 787fef280..3ad8646bd 100644 --- a/apps/console/src/app/(console)/components/connector-mark.tsx +++ b/apps/console/src/app/(console)/components/connector-mark.tsx @@ -2,17 +2,27 @@ // SPDX-License-Identifier: Apache-2.0 import { ConnectorIcon, type ConnectorIconLike } from "@pdpp/brand-react"; -import { resolveConnectorIcon } from "../lib/resolve-connector-icon.ts"; +import { createElement } from "react"; +import { resolveConnectorIcon, resolveSourceIcon } from "../lib/resolve-connector-icon.ts"; + +const TEXT_SPACING_CLASSES = { after: "mr-2", around: "mx-1" } as const; /** One connector identity mark for every console surface. */ export function ConnectorMark({ - className, + spacing, + connectorId, + sourceId, icon, name, }: { - className?: string; + /** Space between this mark and adjacent inline text; geometry belongs to the renderer. */ + spacing?: keyof typeof TEXT_SPACING_CLASSES; icon?: ConnectorIconLike | null; name: string; -}) { - return ; +} & ({ connectorId: string; sourceId?: never } | { sourceId: string; connectorId?: never })) { + return createElement(ConnectorIcon, { + className: spacing ? TEXT_SPACING_CLASSES[spacing] : undefined, + icon: sourceId === undefined ? resolveConnectorIcon(connectorId, icon) : resolveSourceIcon(sourceId, icon), + name, + }); } diff --git a/apps/console/src/app/(console)/components/source-setup-catalog.tsx b/apps/console/src/app/(console)/components/source-setup-catalog.tsx index ad54d5243..a0aa896f9 100644 --- a/apps/console/src/app/(console)/components/source-setup-catalog.tsx +++ b/apps/console/src/app/(console)/components/source-setup-catalog.tsx @@ -242,7 +242,7 @@ function ExistingSourceLinks({ >

- + {source.displayName}

@@ -488,7 +488,7 @@ function SourceSetupCard({ >

- +

{entry.displayName}

{/* Current support / blocked fact, kept distinct from the next action. */} {status.label ? ( diff --git a/apps/console/src/app/(console)/components/views/standing-overview.tsx b/apps/console/src/app/(console)/components/views/standing-overview.tsx index b4c4a7e98..aadc5e8f1 100644 --- a/apps/console/src/app/(console)/components/views/standing-overview.tsx +++ b/apps/console/src/app/(console)/components/views/standing-overview.tsx @@ -18,6 +18,7 @@ */ import { Endorse, Eyebrow, IcTimestamp, Monogram } from "@pdpp/brand-react"; import "@pdpp/brand-react/components.css"; +import { ConnectorMark } from "../connector-mark.tsx"; import type { StandingData } from "./standing-view-model.ts"; function HeroLine({ line }: { line: StandingData["hero"]["line"] }) { @@ -249,6 +250,9 @@ function AttentionBlock({ {section.rows.map((row) => (
+ {row.connectorId ? ( + + ) : null} {row.what} {row.why} diff --git a/apps/console/src/app/(console)/components/views/standing-view-model.ts b/apps/console/src/app/(console)/components/views/standing-view-model.ts index e0bf517f6..4282e5fcf 100644 --- a/apps/console/src/app/(console)/components/views/standing-view-model.ts +++ b/apps/console/src/app/(console)/components/views/standing-view-model.ts @@ -187,6 +187,7 @@ export interface LatelyView { } export interface AttentionRowView { + connectorId?: string; href: string; id: string; what: string; @@ -937,6 +938,7 @@ function sourceWorkRow(item: SourceWorkItem, hrefs: StandingHrefs): AttentionRow what = `${item.label} ${item.statusLabel}`; } return { + connectorId: item.connectorKey, href: hrefs.connection(item.routeId), id: item.id, what, diff --git a/apps/console/src/app/(console)/connect/browser-session/[connectorId]/launch/page.tsx b/apps/console/src/app/(console)/connect/browser-session/[connectorId]/launch/page.tsx index f5826a03e..2a199dbf5 100644 --- a/apps/console/src/app/(console)/connect/browser-session/[connectorId]/launch/page.tsx +++ b/apps/console/src/app/(console)/connect/browser-session/[connectorId]/launch/page.tsx @@ -67,7 +67,7 @@ export default async function BrowserSessionLaunchPage({ ]} description={ - + DataConnect is starting a secure browser session for {displayName}. } diff --git a/apps/console/src/app/(console)/connect/browser-session/[connectorId]/page.tsx b/apps/console/src/app/(console)/connect/browser-session/[connectorId]/page.tsx index 748f47129..7ed05402a 100644 --- a/apps/console/src/app/(console)/connect/browser-session/[connectorId]/page.tsx +++ b/apps/console/src/app/(console)/connect/browser-session/[connectorId]/page.tsx @@ -254,7 +254,7 @@ export default async function BrowserSessionConnectPage({ breadcrumbs={[{ href: "/sources", label: "Sources" }, { label: pageTitle }]} description={ - + {repairMode ? `Log in to ${displayName} in the secure browser to restore collection. Your existing records and history are preserved.` @@ -264,7 +264,7 @@ export default async function BrowserSessionConnectPage({ } title={ - + {pageTitle} } diff --git a/apps/console/src/app/(console)/connect/manual-upload/[connectorId]/page.tsx b/apps/console/src/app/(console)/connect/manual-upload/[connectorId]/page.tsx index fbc342317..429757ce8 100644 --- a/apps/console/src/app/(console)/connect/manual-upload/[connectorId]/page.tsx +++ b/apps/console/src/app/(console)/connect/manual-upload/[connectorId]/page.tsx @@ -126,13 +126,13 @@ export default async function ManualUploadConnectPage({ breadcrumbs={[{ href: "/sources", label: "Sources" }, { label: `Import ${setup.display_name}` }]} description={ - + Pick a supported export file. DataConnect validates it, imports it, and gives you a coverage receipt you can revisit. } title={ - + {targetConnectionId ? `Import another ${setup.display_name} file` : `Import ${setup.display_name}`} } diff --git a/apps/console/src/app/(console)/connect/provider-auth/[connectorId]/page.tsx b/apps/console/src/app/(console)/connect/provider-auth/[connectorId]/page.tsx index d8305febc..2f685e727 100644 --- a/apps/console/src/app/(console)/connect/provider-auth/[connectorId]/page.tsx +++ b/apps/console/src/app/(console)/connect/provider-auth/[connectorId]/page.tsx @@ -40,13 +40,13 @@ export default async function ProviderAuthPage({ ]} description={ - + Authorize this account in the provider's browser. The connection activates after authorization and account inventory succeed. } title={ - + Authorize {displayName} } diff --git a/apps/console/src/app/(console)/connect/static-secret/[connectorId]/page.tsx b/apps/console/src/app/(console)/connect/static-secret/[connectorId]/page.tsx index 13b9bf7d9..4635c0de5 100644 --- a/apps/console/src/app/(console)/connect/static-secret/[connectorId]/page.tsx +++ b/apps/console/src/app/(console)/connect/static-secret/[connectorId]/page.tsx @@ -163,13 +163,13 @@ export default async function StaticSecretConnectPage({ breadcrumbs={[{ href: "/sources", label: "Sources" }, { label: pageTitle }]} description={ - + {pageDescription} } title={ - + {pageTitle} } diff --git a/apps/console/src/app/(console)/connect/status/[connectionId]/page.tsx b/apps/console/src/app/(console)/connect/status/[connectionId]/page.tsx index 673345f7f..a75d28bd7 100644 --- a/apps/console/src/app/(console)/connect/status/[connectionId]/page.tsx +++ b/apps/console/src/app/(console)/connect/status/[connectionId]/page.tsx @@ -716,13 +716,13 @@ export default async function ConnectionSetupStatusPage({ breadcrumbs={[{ href: "/sources", label: "Sources" }, { label: "Setup status" }]} description={ - + Durable status for the account or import you just submitted. Bookmark it and come back any time. } title={ - + {title} } diff --git a/apps/console/src/app/(console)/device-exporters/page.tsx b/apps/console/src/app/(console)/device-exporters/page.tsx index 26a134d6c..6167cfb60 100644 --- a/apps/console/src/app/(console)/device-exporters/page.tsx +++ b/apps/console/src/app/(console)/device-exporters/page.tsx @@ -156,7 +156,7 @@ function BrowserBoundEnrollmentNotice({ connectorIcon, connectorId }: { connecto surface="human" title={ - + Browser setup not available yet } @@ -308,7 +308,7 @@ function SourceInstanceCard({

- + {sourceLabel(source)}

diff --git a/apps/console/src/app/(console)/explore/explore-canvas.tsx b/apps/console/src/app/(console)/explore/explore-canvas.tsx index 8ffb1d40c..71a895df9 100644 --- a/apps/console/src/app/(console)/explore/explore-canvas.tsx +++ b/apps/console/src/app/(console)/explore/explore-canvas.tsx @@ -1588,7 +1588,7 @@ function StreamDoorLink({ }); return ( - See all '{query}' records in {door.displayName} → + See all '{query}' records in {door.displayName} → ); } @@ -1610,7 +1610,7 @@ function StreamSeeAllLink({ const totalLabel = typeof link.total === "number" ? ` - ${link.total.toLocaleString()} records` : ""; return ( - + {link.displayName} - {humanizeFieldLabel(link.stream)} {totalLabel} - See all @@ -1742,7 +1742,7 @@ function ConnectionFacets({ excluded={excluded.includes(c.connectionId)} key={c.connectionId} label={c.displayName} - leading={} + leading={} on={selected.includes(c.connectionId)} onToggle={() => onToggle(c.connectionId)} onToggleExclude={() => onToggleExclude(c.connectionId)} @@ -1782,7 +1782,7 @@ function SourceFacetGroup({

- + {group.displayName} {/* The source total is the SAME count KIND as each stream — loaded rows @@ -2016,7 +2016,8 @@ function FeedRow({ `title` makes the full name recoverable on hover/long-press. */} @@ -2168,11 +2169,14 @@ function BurstRow({
{loaded.toLocaleString()}{" "} - + {rep?.connectorId ? ( + + ) : null} {streamLabel} {" "}in view
diff --git a/apps/console/src/app/(console)/explore/page.tsx b/apps/console/src/app/(console)/explore/page.tsx index edffd5f43..d45a93014 100644 --- a/apps/console/src/app/(console)/explore/page.tsx +++ b/apps/console/src/app/(console)/explore/page.tsx @@ -40,6 +40,7 @@ import { dashboardRoutes } from "@pdpp/operator-ui/components/views/routes"; import { assembleExplorerData } from "@pdpp/operator-ui/explore/explore-data-assembler"; import { RecordroomShellWithPalette } from "@/app/(console)/components/recordroom-shell-with-palette.tsx"; import { ServerUnreachable } from "../components/server-unreachable.tsx"; +import { buildConnectorIconMap } from "../lib/connector-icon-map.ts"; import { liveDashboardDataSource } from "../lib/data-source.ts"; import { getRsInternalUrl, ReferenceServerUnreachableError } from "../lib/owner-token.ts"; import { listConnectorManifests } from "../lib/rs-client.ts"; @@ -128,12 +129,8 @@ export default async function RecordsExplorerPage({ try { const data = await assembleExplorerData(params, liveDashboardDataSource, getRsInternalUrl()); - const connectorIcons = Object.fromEntries( - (await listConnectorManifests().catch(() => [])).flatMap((manifest) => [ - [manifest.connector_id, manifest.icon] as const, - ...(manifest.connector_key ? ([[manifest.connector_key, manifest.icon]] as const) : []), - ]) - ); + const manifests = await listConnectorManifests().catch(() => []); + const connectorIcons = await buildConnectorIconMap(manifests, []); const order = data.supportsTimelineDirection && requestedOrder === "oldest" ? "oldest" : "newest"; // Relationships for the inspected record come from declared metadata via the // SAME `records/lib/relationships.ts` helpers the records detail page uses — diff --git a/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.tsx b/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.tsx index 678e257d6..3d04abd49 100644 --- a/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.tsx +++ b/apps/console/src/app/(console)/grants/approvals/[approvalId]/approval-review.tsx @@ -136,7 +136,8 @@ function SingleArtifactFacts({ artifact, connectorIcons }: { artifact: SingleCon
{artifact.source.kind === "connector" ? ( @@ -203,7 +204,8 @@ function BatchArtifactFacts({ artifact, connectorIcons }: { artifact: BatchConse
{source.source.kind === "connector" ? ( diff --git a/apps/console/src/app/(console)/grants/packages/[packageId]/page.tsx b/apps/console/src/app/(console)/grants/packages/[packageId]/page.tsx index 94174bb6f..97125e114 100644 --- a/apps/console/src/app/(console)/grants/packages/[packageId]/page.tsx +++ b/apps/console/src/app/(console)/grants/packages/[packageId]/page.tsx @@ -295,7 +295,8 @@ function ChildRow({
{connectorId ? ( diff --git a/apps/console/src/app/(console)/grants/page.tsx b/apps/console/src/app/(console)/grants/page.tsx index a8080894c..6b810c4a4 100644 --- a/apps/console/src/app/(console)/grants/page.tsx +++ b/apps/console/src/app/(console)/grants/page.tsx @@ -209,7 +209,7 @@ function GrantRow({
{connectorId ? ( - + ) : null} {grantRowLabel(grant)} diff --git a/apps/console/src/app/(console)/grants/pending-approval-row.tsx b/apps/console/src/app/(console)/grants/pending-approval-row.tsx index b45a3aa35..38fc49b89 100644 --- a/apps/console/src/app/(console)/grants/pending-approval-row.tsx +++ b/apps/console/src/app/(console)/grants/pending-approval-row.tsx @@ -39,7 +39,7 @@ export function PendingApprovalRow({
- {connectorId ? : null} + {connectorId ? : null} {technicalClientCaption(approval.client_id) ?? "client —"} {approval.grant_preview?.source ? ` · source ${formatSourceForDisplay(approval.grant_preview.source)}` : ""} {previewStreams.length ? ` · streams ${previewStreams.join(", ")}` : ""} diff --git a/apps/console/src/app/(console)/lib/connector-display-mark.cross-surface.test.ts b/apps/console/src/app/(console)/lib/connector-display-mark.cross-surface.test.ts new file mode 100644 index 000000000..4353875ef --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-display-mark.cross-surface.test.ts @@ -0,0 +1,67 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * One connector, every surface. The real Amazon manifest names a white glyph + * file (`brand.icon`) and an orange `brand.background_color`. A white glyph + * with no backdrop is invisible on a light surface, so the setup pages, the + * summary pages and a console with no manifests on disk must paint the same + * backdrop. + */ + +import assert from "node:assert/strict"; +import { mock, test } from "node:test"; +import React, { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +// The vendored renderer relies on the classic JSX runtime. +(globalThis as { React?: typeof React }).React = React; + +// rs-client reaches `server-only`, which throws outside a server component. +mock.module("server-only", { namedExports: {} }); + +const AMAZON = "https://registry.pdpp.dev/connectors/amazon"; +const imports = Promise.all([ + import("../components/connector-mark.tsx"), + import("../sources/lib/relationships.ts"), + import("./connector-icon-map.ts"), + import("./rs-client.ts"), +]); + +function mark( + ConnectorMark: (typeof import("../components/connector-mark.tsx"))["ConnectorMark"], + icon: Parameters[0]["icon"] +): string { + return renderToStaticMarkup(createElement(ConnectorMark, { connectorId: AMAZON, icon, name: "Amazon" })); +} + +test("setup, summary and manifest-less surfaces paint the same backdrop behind a white glyph", async () => { + const [{ ConnectorMark }, { findManifestForConnectorId }, { buildConnectorIconMap }, { listConnectorManifests }] = + await imports; + const manifests = await listConnectorManifests(); + const manifest = findManifestForConnectorId(manifests, AMAZON); + assert.ok(manifest, "the installed connector package ships the Amazon manifest"); + assert.equal(manifest.brand?.background_color, "#FF9900"); + assert.equal(manifest.resolvedIconSource, "manifest", "the glyph came from brand.icon, not the bundled asset"); + + // Setup pages pass the manifest icon; summary pages read the icon map; a + // console with no manifests on disk passes nothing and uses the bundled mark. + const setup = mark(ConnectorMark, manifest.icon); + const summary = mark(ConnectorMark, (await buildConnectorIconMap(manifests, []))[AMAZON]); + const withoutManifests = mark(ConnectorMark, undefined); + + assert.match(setup, /background-color:#FF9900/i); + assert.match(setup, /fill="#FFFFFF"/i, "the white glyph is kept, not forced to currentColor"); + assert.equal(setup, summary); + assert.equal(setup, withoutManifests); +}); + +test("for every installed manifest the summary map and the setup mark are the same mark", async () => { + const [, , { buildConnectorIconMap }, { listConnectorManifests }] = await imports; + const manifests = await listConnectorManifests(); + assert.ok(manifests.length > 0); + const icons = await buildConnectorIconMap(manifests, []); + for (const manifest of manifests) { + assert.deepEqual(icons[manifest.connector_id], manifest.icon ?? undefined, manifest.connector_id); + } +}); diff --git a/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.test.ts b/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.test.ts new file mode 100644 index 000000000..0356c97e1 --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.test.ts @@ -0,0 +1,78 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { assertIconCorpusConsistent } from "./connector-icon-corpus-checks.ts"; +import iconCorpus from "./connector-icons/sanitized-assets.json" with { type: "json" }; + +const SVG = ''; +const FOO_BAR_COLLISION = /foo[-_]bar/u; +const FOO = /foo/u; +const BACKGROUND = /background/u; +const SOURCE = /source/u; +const OTHER_SVG = ''; + +function entry(key: string, overrides: Record = {}) { + return { + background_color: "#112233", + connector_id: `https://registry.pdpp.dev/connectors/${key}`, + connector_key: key, + source_id: null as string | null, + svg: SVG, + ...overrides, + }; +} + +test("the checked-in corpus is consistent", () => { + assert.doesNotThrow(() => assertIconCorpusConsistent(iconCorpus.entries)); +}); + +test("two entries whose identities differ only by _/- spelling are rejected", () => { + assert.throws(() => assertIconCorpusConsistent([entry("foo-bar"), entry("foo_bar")]), FOO_BAR_COLLISION); +}); + +test("a registry-prefixed id that spells another entry's key is rejected", () => { + const first = entry("foo"); + const second = entry("other", { connector_id: "https://registry.pdpp.dev/connectors/foo" }); + assert.throws(() => assertIconCorpusConsistent([first, second]), FOO); +}); + +test("an exact duplicate key is still rejected", () => { + assert.throws(() => assertIconCorpusConsistent([entry("foo"), entry("foo")]), FOO); +}); + +test("a background that is not a hex color is rejected", () => { + for (const bad of ["transparent", "red", "#12", "#fff;x:y"]) { + assert.throws(() => assertIconCorpusConsistent([entry("foo", { background_color: bad })]), BACKGROUND, bad); + } + assert.doesNotThrow(() => assertIconCorpusConsistent([entry("foo", { background_color: undefined })])); + assert.doesNotThrow(() => assertIconCorpusConsistent([entry("foo", { background_color: "#fff" })])); +}); + +test("a source id shared by entries with different marks is rejected", () => { + const source = "https://registry.pdpp.dev/sources/foo"; + assert.throws( + () => + assertIconCorpusConsistent([ + entry("foo", { source_id: source }), + entry("foo-live", { source_id: source, svg: OTHER_SVG }), + ]), + SOURCE + ); + assert.throws( + () => + assertIconCorpusConsistent([ + entry("foo", { source_id: source }), + entry("foo-live", { background_color: "#445566", source_id: source }), + ]), + SOURCE + ); +}); + +test("a source id shared by entries with the same mark is accepted", () => { + const source = "https://registry.pdpp.dev/sources/foo"; + assert.doesNotThrow(() => + assertIconCorpusConsistent([entry("foo", { source_id: source }), entry("foo-live", { source_id: source })]) + ); +}); diff --git a/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.ts b/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.ts new file mode 100644 index 000000000..0e5b1cd31 --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-icon-corpus-checks.ts @@ -0,0 +1,65 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { hexColorOrNull } from "@pdpp/brand-react/hex-color"; +import { connectorIconIdentityKeys } from "./connector-icon-identity.ts"; + +interface CorpusEntryLike { + readonly background_color?: string | null; + readonly connector_id: string; + readonly connector_key: string; + readonly source_id?: string | null; + readonly svg: string; +} + +/** + * Fail when the pinned icon corpus could make a shipped mark depend on file + * order or carry a free-form style value. The runtime indexes marks by every + * accepted spelling of an identity, so the check runs on those spellings, not + * on the raw strings: + * - no two entries may claim one normalized identity (`foo_bar`/`foo-bar`, + * or a registry-prefixed id that spells another entry's key); + * - `background_color` is a hex color when present; + * - entries sharing a `source_id` carry the same svg and background, since + * the source lookup would otherwise resolve to no brand. + */ +export function assertIconCorpusConsistent(entries: readonly CorpusEntryLike[]): void { + const identityOwner = new Map(); + const sourceMark = new Map(); + for (const entry of entries) { + assertHexBackground(entry); + claimIdentities(entry, identityOwner); + if (entry.source_id) { + assertSameSourceMark(entry, entry.source_id, sourceMark); + } + } +} + +function assertHexBackground(entry: CorpusEntryLike): void { + const background = entry.background_color; + if (background !== undefined && background !== null && hexColorOrNull(background) === null) { + throw new Error(`Icon ${entry.connector_key} has a background_color that is not a hex color.`); + } +} + +function claimIdentities(entry: CorpusEntryLike, owners: Map): void { + for (const identity of [entry.connector_id, entry.connector_key]) { + for (const key of connectorIconIdentityKeys(identity)) { + const owner = owners.get(key); + if (owner !== undefined && owner !== entry) { + throw new Error(`Icons ${owner.connector_key} and ${entry.connector_key} claim the same identity "${key}".`); + } + owners.set(key, entry); + } + } +} + +function assertSameSourceMark(entry: CorpusEntryLike, sourceId: string, first: Map): void { + const previous = first.get(sourceId); + if (previous && (previous.svg !== entry.svg || previous.background_color !== entry.background_color)) { + throw new Error( + `Icons ${previous.connector_key} and ${entry.connector_key} share source ${sourceId} with different marks.` + ); + } + first.set(sourceId, previous ?? entry); +} diff --git a/apps/console/src/app/(console)/lib/connector-icon-identity.ts b/apps/console/src/app/(console)/lib/connector-icon-identity.ts new file mode 100644 index 000000000..88b5a53dc --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-icon-identity.ts @@ -0,0 +1,27 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +const URI_SCHEME = /^[a-z][a-z\d+.-]*:/iu; +const REGISTRY_PREFIX = "https://registry.pdpp.dev/connectors/"; +const TRAILING_SLASH = /\/$/u; + +/** Preserve the first-party registry/key aliases; unrelated URLs remain exact identities. */ +export function connectorIconIdentityKeys(connectorId: string): readonly string[] { + const value = connectorId.trim(); + if (!value) { + return []; + } + const keys = new Set([value]); + let localKey: string | null = value; + if (value.startsWith(REGISTRY_PREFIX)) { + localKey = value.slice(REGISTRY_PREFIX.length).replace(TRAILING_SLASH, ""); + } else if (URI_SCHEME.test(value)) { + localKey = null; + } + if (localKey !== null) { + const canonical = localKey.replaceAll("_", "-"); + keys.add(canonical); + keys.add(canonical.replaceAll("-", "_")); + } + return [...keys]; +} diff --git a/apps/console/src/app/(console)/lib/connector-icon-map.test.ts b/apps/console/src/app/(console)/lib/connector-icon-map.test.ts new file mode 100644 index 000000000..08f17a595 --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-icon-map.test.ts @@ -0,0 +1,199 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; +import { buildConnectorIconMap, catalogEntryIcon } from "./connector-icon-map.ts"; +import iconCorpus from "./connector-icons/sanitized-assets.json" with { type: "json" }; +import { resolveConnectorIcon } from "./resolve-connector-icon.ts"; +import { resolveManifestDisplayMark } from "./resolve-manifest-display-mark.ts"; + +const UNSAFE_ONLOAD = /onload/iu; +const GITHUB_SVG = iconCorpus.entries.find((entry) => entry.connector_key === "github")?.svg; +assert.ok(GITHUB_SVG); + +const INLINE_MANIFEST_MARK = { + color: "#123456", + kind: "inline_svg", + svg: GITHUB_SVG, +} as const; +const INLINE_TEMPLATE_MARK = { + color: "#654321", + kind: "inline_svg", + svg: GITHUB_SVG, +} as const; + +test("projects pinned upstream marks by exact connector key and canonical ID", async () => { + const icons = await buildConnectorIconMap( + [], + [{ connector_id: "https://registry.pdpp.dev/connectors/github", connector_key: "github" }] + ); + + assert.ok(icons.github?.svg?.startsWith(" { + const icons = await buildConnectorIconMap([], []); + assert.deepEqual(icons.apple_photos, icons["apple-photos"]); +}); + +test("preserves the valid inline owner-template mark over the pinned asset", async () => { + const icons = await buildConnectorIconMap( + [], + [{ connector_id: "github", connector_key: "github", icon: INLINE_TEMPLATE_MARK }] + ); + + assert.deepEqual(icons.github, INLINE_TEMPLATE_MARK); +}); + +test("a template mark beats a manifest's fallback mark but not the manifest's own", async () => { + const bundled = await resolveManifestDisplayMark({ connector_id: "github", connector_key: "github" }, "/nonexistent"); + assert.equal(bundled.source, "bundled"); + const withFallback = await buildConnectorIconMap( + [{ connector_id: "github", connector_key: "github", icon: bundled.icon, resolvedIconSource: bundled.source }], + [{ connector_id: "github", connector_key: "github", icon: INLINE_TEMPLATE_MARK }] + ); + assert.deepEqual(withFallback.github, INLINE_TEMPLATE_MARK); + + const own = await buildConnectorIconMap( + [{ connector_id: "github", connector_key: "github", icon: INLINE_MANIFEST_MARK, resolvedIconSource: "manifest" }], + [{ connector_id: "github", connector_key: "github", icon: INLINE_TEMPLATE_MARK }] + ); + assert.deepEqual(own.github, INLINE_MANIFEST_MARK); +}); + +test("a manifest and a template that disagree yield the same mark on every surface", async () => { + const manifest = { connector_id: "github", connector_key: "github", icon: INLINE_MANIFEST_MARK } as const; + // Setup pages render the resolved manifest mark; summary and add pages read the map. + const setup = (await resolveManifestDisplayMark(manifest, "/nonexistent")).icon; + const withTemplate = await buildConnectorIconMap( + [manifest], + [{ connector_id: "github", connector_key: "github", icon: INLINE_TEMPLATE_MARK }] + ); + const withoutTemplate = await buildConnectorIconMap([manifest], []); + + assert.deepEqual(setup, withTemplate.github); + assert.deepEqual(setup, withoutTemplate.github); + assert.equal(withTemplate.github?.color, INLINE_MANIFEST_MARK.color); +}); + +test("preserves an explicitly supplied safe inline manifest mark over the pinned asset", async () => { + const icons = await buildConnectorIconMap( + [{ connector_id: "github", connector_key: "github", icon: INLINE_MANIFEST_MARK }], + [] + ); + + assert.deepEqual(icons.github, INLINE_MANIFEST_MARK); +}); + +test("rejects unsafe inline SVG and keeps the pinned safe asset", async () => { + const icons = await buildConnectorIconMap( + [ + { + connector_id: "github", + connector_key: "github", + icon: { kind: "inline_svg", svg: '' }, + }, + ], + [] + ); + + assert.ok(icons.github?.svg?.startsWith(" { + const icons = await buildConnectorIconMap([], []); + for (const entry of iconCorpus.entries) { + const icon = icons[entry.connector_key]; + assert.equal(icon?.svg, entry.svg, entry.connector_key); + assert.equal(icon?.backgroundColor, entry.background_color, entry.connector_key); + assert.equal(icons[entry.connector_id], icon, entry.connector_key); + } +}); + +test("an iconless owner template preserves a custom manifest mark", async () => { + const icons = await buildConnectorIconMap( + [{ connector_id: "github", connector_key: "github", icon: INLINE_MANIFEST_MARK }], + [{ connector_id: "github", connector_key: "github" }] + ); + assert.deepEqual(icons.github, INLINE_MANIFEST_MARK); +}); + +test("a catalog entry keeps its own mark when the map does not index its key", async () => { + const icons = await buildConnectorIconMap([], []); + assert.deepEqual( + catalogEntryIcon({ connectorKey: "not-in-the-map", icon: INLINE_TEMPLATE_MARK }, icons), + INLINE_TEMPLATE_MARK + ); + assert.equal(catalogEntryIcon({ connectorKey: "not-in-the-map" }, icons), null); + assert.deepEqual(catalogEntryIcon({ connectorKey: "github", icon: INLINE_TEMPLATE_MARK }, icons), icons.github); +}); + +test("a catalog entry's own unsafe mark is rejected when the map misses its key", async () => { + const icons = await buildConnectorIconMap([], []); + const unsafe = { + kind: "inline_svg", + svg: '', + }; + assert.equal(catalogEntryIcon({ connectorKey: "not-in-the-map", icon: unsafe }, icons), null); + assert.equal( + catalogEntryIcon( + { connectorKey: "not-in-the-map", icon: { ...INLINE_TEMPLATE_MARK, backgroundColor: "red;x:y" } }, + icons + )?.backgroundColor, + undefined + ); +}); + +test("two manifests whose identities collide on _/- spelling get no mark from that layer", async () => { + const warn = mock.method(console, "warn", () => undefined); + try { + const mark = (svg: string) => ({ kind: "inline_svg", svg }) as const; + const icons = await buildConnectorIconMap( + [ + { + connector_id: "foo-bar", + connector_key: "foo-bar", + icon: mark(''), + }, + { + connector_id: "foo_bar", + connector_key: "foo_bar", + icon: mark(''), + }, + ] as never, + [] + ); + assert.equal(icons["foo-bar"], undefined, "the later manifest must not silently take the earlier one's identity"); + assert.equal(icons.foo_bar, undefined); + assert.ok(warn.mock.calls.length > 0, "the collision is reported"); + } finally { + warn.mock.restore(); + } +}); + +test("two manifests that resolve to the same mark under one identity are not a collision", async () => { + const warn = mock.method(console, "warn", () => undefined); + try { + const icons = await buildConnectorIconMap( + [ + { connector_id: "foo-bar", connector_key: "foo-bar", icon: INLINE_MANIFEST_MARK }, + { connector_id: "foo_bar", connector_key: "foo_bar", icon: INLINE_MANIFEST_MARK }, + ] as never, + [] + ); + assert.deepEqual(icons["foo-bar"], INLINE_MANIFEST_MARK); + assert.equal(warn.mock.calls.length, 0); + } finally { + warn.mock.restore(); + } +}); + +test("the map's bundled layer is the resolver's bundled corpus, identity for identity", async () => { + const icons = await buildConnectorIconMap([], []); + for (const entry of iconCorpus.entries) { + assert.deepEqual(icons[entry.connector_key], resolveConnectorIcon(entry.connector_id), entry.connector_key); + } +}); diff --git a/apps/console/src/app/(console)/lib/connector-icon-map.ts b/apps/console/src/app/(console)/lib/connector-icon-map.ts new file mode 100644 index 000000000..03143f1ee --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-icon-map.ts @@ -0,0 +1,101 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ConnectorIconLike } from "@pdpp/brand-react"; +import { connectorLookupKey } from "./connector-install-presentation.ts"; +import { + bundledIconIdentities, + hasConnectorIcon, + indexConnectorIcon, + validatedConnectorIcon, +} from "./resolve-connector-icon.ts"; +import { resolveConnectorIconFromSimpleIcons } from "./resolve-connector-icon-simple-icons.ts"; +import { type DisplayMarkLayers, pickDisplayMark } from "./resolve-manifest-display-mark.ts"; +import type { ConnectorManifest, OwnerConnectorTemplate } from "./rs-client.ts"; + +/** Icons indexed by the exact server-projected connector identity. */ +export type ConnectorIconMap = Readonly>; + +function layerForSource( + layers: Record>, + source: ConnectorManifest["resolvedIconSource"] +): Map { + if (source === "bundled") { + return layers.bundled; + } + return source === "simple-icons" ? layers.generic : layers.manifest; +} + +/** + * A display projection; it never adds or changes a connector manifest. Each + * source of marks fills one layer, and `pickDisplayMark` (the same function + * `resolveManifestDisplayMark` uses) chooses per identity, so the map cannot + * order marks differently from the setup pages. + */ +export async function buildConnectorIconMap( + manifests: readonly ConnectorManifest[], + templates: readonly OwnerConnectorTemplate[] +): Promise { + const layers = { + bundled: bundledIconIdentities(), + generic: new Map(), + manifest: new Map(), + template: new Map(), + } satisfies Record>; + // Manifest marks are already normalized (resolveManifestDisplayMark). Which + // layer one belongs to follows the source it resolved from. + for (const manifest of manifests) { + indexConnectorIcon( + layerForSource(layers, manifest.resolvedIconSource), + [manifest.connector_id, manifest.connector_key], + validatedConnectorIcon(manifest.icon) + ); + } + for (const template of templates) { + indexConnectorIcon( + layers.template, + [template.connector_id, template.connector_key], + validatedConnectorIcon(template.icon) + ); + } + // Resolve only identities no layer covers yet, so the fallback cannot + // overwrite an approved mark through an alias. + const covered = (ids: readonly (string | null | undefined)[]) => + Object.values(layers).some((layer) => hasConnectorIcon(layer, ids)); + const missing = templates.filter( + (template) => template.connector_key && !covered([template.connector_id, template.connector_key]) + ); + const fallbacks = await Promise.all( + missing.map(async (template) => ({ + identities: [template.connector_id, template.connector_key], + icon: await resolveConnectorIconFromSimpleIcons(template.connector_key ?? ""), + })) + ); + for (const fallback of fallbacks) { + indexConnectorIcon(layers.generic, fallback.identities, fallback.icon); + } + const icons = new Map(); + for (const key of new Set(Object.values(layers).flatMap((layer) => [...layer.keys()]))) { + const { icon } = pickDisplayMark({ + bundled: layers.bundled.get(key), + generic: layers.generic.get(key), + manifest: layers.manifest.get(key), + template: layers.template.get(key), + }); + if (icon) { + icons.set(key, icon); + } + } + return Object.fromEntries(icons); +} + +/** + * The mark for a catalog entry: the map's choice, else the mark the entry + * already carries. A key the map does not index must not discard that mark. + */ +export function catalogEntryIcon( + entry: { readonly connectorKey: string; readonly icon?: ConnectorIconLike | null }, + icons: ConnectorIconMap +): ConnectorIconLike | null { + return icons[connectorLookupKey(entry.connectorKey)] ?? validatedConnectorIcon(entry.icon) ?? null; +} diff --git a/apps/console/src/app/(console)/lib/connector-icons/NOTICE b/apps/console/src/app/(console)/lib/connector-icons/NOTICE new file mode 100644 index 000000000..c92397292 --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-icons/NOTICE @@ -0,0 +1,19 @@ +Copyright © 2026 The PDP-Connect Contributors +Licensed under the Apache License, Version 2.0. + +## Third-party connector brand icons + +Shipped connector manifests in `packages/polyfill-connectors/manifests/` each +declare a small brand glyph (`brand.icon`, under `manifests/icons/*.svg`) to +identify the third-party service the connector connects to (e.g. Amazon, +GitHub, Spotify). All product names, trademarks, and registered trademarks +referenced or depicted by these icons are the property of their respective +owners. These icons are used by the PDP-Connect project strictly to identify +the products and companies a connector integrates with; their use implies no +sponsorship, affiliation, or endorsement by the mark holder. + +A connector manifest with no legitimately available brand mark should not +carry an invented or unofficial logo. Consumers of this package's +`connector-index.json` render a deterministic monogram (the connector's +initial(s) and a color derived from its name) as the correct default for +that case — this is a normal, first-class fallback, not an error state. diff --git a/apps/console/src/app/(console)/lib/connector-icons/sanitized-assets.json b/apps/console/src/app/(console)/lib/connector-icons/sanitized-assets.json new file mode 100644 index 000000000..c78f24281 --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-icons/sanitized-assets.json @@ -0,0 +1,591 @@ +{ + "repo": "PDP-Connect/data-connectors", + "revision": "23aee3aaeafad3a804d504e8614763f7845add60", + "entries": [ + { + "background_color": "#FF9900", + "connector_key": "amazon", + "connector_id": "https://registry.pdpp.dev/connectors/amazon", + "display_name": "Amazon", + "path": "connectors/amazon/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/amazon", + "svg": "" + }, + { + "background_color": "#D97757", + "connector_key": "anthropic", + "connector_id": "https://registry.pdpp.dev/connectors/anthropic", + "display_name": "Claude (Anthropic)", + "path": "connectors/anthropic/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/claude", + "svg": "" + }, + { + "background_color": "#D97757", + "connector_key": "anthropic-live", + "connector_id": "https://registry.pdpp.dev/connectors/anthropic-live", + "display_name": "Claude (Live Session)", + "path": "connectors/anthropic_live/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/claude", + "svg": "" + }, + { + "background_color": "#8E8E93", + "connector_key": "apple-contacts", + "connector_id": "https://registry.pdpp.dev/connectors/apple-contacts", + "display_name": "Apple Contacts", + "path": "connectors/apple_contacts/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/apple_contacts", + "svg": "" + }, + { + "background_color": "#FF2D55", + "connector_key": "apple-health", + "connector_id": "https://registry.pdpp.dev/connectors/apple-health", + "display_name": "Apple Health", + "path": "connectors/apple_health/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/apple_health", + "svg": "" + }, + { + "background_color": "#FA633E", + "connector_key": "apple-photos", + "connector_id": "https://registry.pdpp.dev/connectors/apple-photos", + "display_name": "Apple Photos", + "path": "connectors/apple_photos/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/apple_photos", + "svg": "" + }, + { + "background_color": "#000000", + "connector_key": "character-ai", + "connector_id": "https://registry.pdpp.dev/connectors/character-ai", + "display_name": "Character.AI", + "path": "connectors/character_ai/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/character_ai", + "svg": "" + }, + { + "background_color": "#117ACA", + "connector_key": "chase", + "connector_id": "https://registry.pdpp.dev/connectors/chase", + "display_name": "Chase", + "path": "connectors/chase/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/chase", + "svg": "" + }, + { + "background_color": "#10A37F", + "connector_key": "chatgpt", + "connector_id": "https://registry.pdpp.dev/connectors/chatgpt", + "display_name": "ChatGPT", + "path": "connectors/chatgpt/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/chatgpt", + "svg": "" + }, + { + "background_color": "#D97757", + "connector_key": "claude-code", + "connector_id": "https://registry.pdpp.dev/connectors/claude-code", + "display_name": "Claude Code", + "path": "connectors/claude_code/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/claude_code", + "svg": "" + }, + { + "background_color": "#10A37F", + "connector_key": "codex", + "connector_id": "https://registry.pdpp.dev/connectors/codex", + "display_name": "OpenAI Codex CLI", + "path": "connectors/codex/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/codex", + "svg": "" + }, + { + "background_color": "#5865F2", + "connector_key": "discord-browser", + "connector_id": "https://registry.pdpp.dev/connectors/discord-browser", + "display_name": "Discord (Browser Sign-In)", + "path": "connectors/discord_browser/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/discord", + "svg": "" + }, + { + "background_color": "#FF3008", + "connector_key": "doordash", + "connector_id": "https://registry.pdpp.dev/connectors/doordash", + "display_name": "DoorDash", + "path": "connectors/doordash/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/doordash", + "svg": "" + }, + { + "background_color": "#0866FF", + "connector_key": "facebook", + "connector_id": "https://registry.pdpp.dev/connectors/facebook", + "display_name": "Facebook", + "path": "connectors/facebook/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/facebook", + "svg": "" + }, + { + "background_color": "#00B0B9", + "connector_key": "fitbit", + "connector_id": "https://registry.pdpp.dev/connectors/fitbit", + "display_name": "Fitbit", + "path": "connectors/fitbit/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/fitbit", + "svg": "" + }, + { + "background_color": "#000000", + "connector_key": "garmin-browser", + "connector_id": "https://registry.pdpp.dev/connectors/garmin-browser", + "display_name": "Garmin (Browser Sign-In)", + "path": "connectors/garmin_browser/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/garmin_browser", + "svg": "" + }, + { + "background_color": "#181717", + "connector_key": "github", + "connector_id": "https://registry.pdpp.dev/connectors/github", + "display_name": "GitHub", + "path": "connectors/github/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/github", + "svg": "" + }, + { + "background_color": "#181717", + "connector_key": "github-browser", + "connector_id": "https://registry.pdpp.dev/connectors/github-browser", + "display_name": "GitHub (browser session)", + "path": "connectors/github_browser/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/github", + "svg": "" + }, + { + "background_color": "#EA4335", + "connector_key": "gmail", + "connector_id": "https://registry.pdpp.dev/connectors/gmail", + "display_name": "Gmail", + "path": "connectors/gmail/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/gmail", + "svg": "" + }, + { + "background_color": "#4285F4", + "connector_key": "google-calendar", + "connector_id": "https://registry.pdpp.dev/connectors/google-calendar", + "display_name": "Google Calendar", + "path": "connectors/google_calendar/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/google_calendar", + "svg": "" + }, + { + "background_color": "#34A853", + "connector_key": "google-contacts", + "connector_id": "https://registry.pdpp.dev/connectors/google-contacts", + "display_name": "Google Contacts", + "path": "connectors/google_contacts/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/google_contacts", + "svg": "" + }, + { + "background_color": "#4285F4", + "connector_key": "google-maps", + "connector_id": "https://registry.pdpp.dev/connectors/google-maps", + "display_name": "Google Maps Timeline Import", + "path": "connectors/google_maps/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/google_maps", + "svg": "" + }, + { + "background_color": "#4285F4", + "connector_key": "google-maps-data-portability", + "connector_id": "https://registry.pdpp.dev/connectors/google-maps-data-portability", + "display_name": "Google Maps Data Portability", + "path": "connectors/google_maps_data_portability/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/google_maps_data_portability", + "svg": "" + }, + { + "background_color": "#1A73E8", + "connector_key": "google-messages", + "connector_id": "https://registry.pdpp.dev/connectors/google-messages", + "display_name": "Google Messages (via gmcli)", + "path": "connectors/google_messages/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/google_messages", + "svg": "" + }, + { + "background_color": "#4285F4", + "connector_key": "google-takeout", + "connector_id": "https://registry.pdpp.dev/connectors/google-takeout", + "display_name": "Google Takeout", + "path": "connectors/google_takeout/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/google_takeout", + "svg": "" + }, + { + "background_color": "#00AFF0", + "connector_key": "groupme", + "connector_id": "https://registry.pdpp.dev/connectors/groupme", + "display_name": "GroupMe", + "path": "connectors/groupme/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/groupme", + "svg": "" + }, + { + "background_color": "#E1251B", + "connector_key": "heb", + "connector_id": "https://registry.pdpp.dev/connectors/heb", + "display_name": "H-E-B", + "path": "connectors/heb/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/heb", + "svg": "" + }, + { + "background_color": "#FF3B30", + "connector_key": "ical", + "connector_id": "https://registry.pdpp.dev/connectors/ical", + "display_name": "Calendar (iCal)", + "path": "connectors/ical/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/ical", + "svg": "" + }, + { + "background_color": "#FFD426", + "connector_key": "icloud-notes", + "connector_id": "https://registry.pdpp.dev/connectors/icloud-notes", + "display_name": "iCloud Notes", + "path": "connectors/icloud_notes/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/icloud_notes", + "svg": "" + }, + { + "background_color": "#30D158", + "connector_key": "imessage", + "connector_id": "https://registry.pdpp.dev/connectors/imessage", + "display_name": "iMessage (macOS)", + "path": "connectors/imessage/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/imessage", + "svg": "" + }, + { + "background_color": "#1F2322", + "connector_key": "instinct", + "connector_id": "https://registry.pdpp.dev/connectors/instinct", + "display_name": "Instinct", + "path": "connectors/instinct/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/instinct", + "svg": "" + }, + { + "background_color": "#00A4DC", + "connector_key": "jellyfin", + "connector_id": "https://registry.pdpp.dev/connectors/jellyfin", + "display_name": "Jellyfin", + "path": "connectors/jellyfin/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/jellyfin", + "svg": "" + }, + { + "background_color": "#0A66C2", + "connector_key": "linkedin", + "connector_id": "https://registry.pdpp.dev/connectors/linkedin", + "display_name": "LinkedIn", + "path": "connectors/linkedin/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/linkedin", + "svg": "" + }, + { + "background_color": "#625DF5", + "connector_key": "loom", + "connector_id": "https://registry.pdpp.dev/connectors/loom", + "display_name": "Loom", + "path": "connectors/loom/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/loom", + "svg": "" + }, + { + "background_color": "#E1306C", + "connector_key": "meta", + "connector_id": "https://registry.pdpp.dev/connectors/meta", + "display_name": "Instagram (Meta)", + "path": "connectors/meta/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/instagram", + "svg": "" + }, + { + "background_color": "#0467DF", + "connector_key": "muse", + "connector_id": "https://registry.pdpp.dev/connectors/muse", + "display_name": "Muse (Meta)", + "path": "connectors/muse/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/muse", + "svg": "" + }, + { + "background_color": "#E50914", + "connector_key": "netflix-export", + "connector_id": "https://registry.pdpp.dev/connectors/netflix-export", + "display_name": "Netflix Export", + "path": "connectors/netflix_export/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/netflix_export", + "svg": "" + }, + { + "background_color": "#000000", + "connector_key": "notion", + "connector_id": "https://registry.pdpp.dev/connectors/notion", + "display_name": "Notion", + "path": "connectors/notion/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/notion", + "svg": "" + }, + { + "background_color": "#2F4A73", + "connector_key": "oura", + "connector_id": "https://registry.pdpp.dev/connectors/oura", + "display_name": "Oura", + "path": "connectors/oura/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/oura", + "svg": "" + }, + { + "background_color": "#2F4A73", + "connector_key": "oura-browser", + "connector_id": "https://registry.pdpp.dev/connectors/oura-browser", + "display_name": "Oura (Browser Sign-In)", + "path": "connectors/oura_browser/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/oura", + "svg": "" + }, + { + "background_color": "#1FB8CD", + "connector_key": "perplexity", + "connector_id": "https://registry.pdpp.dev/connectors/perplexity", + "display_name": "Perplexity (Browser Sign-In)", + "path": "connectors/perplexity/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/perplexity", + "svg": "" + }, + { + "background_color": "#EF3F56", + "connector_key": "pocket", + "connector_id": "https://registry.pdpp.dev/connectors/pocket", + "display_name": "Pocket", + "path": "connectors/pocket/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/pocket", + "svg": "" + }, + { + "background_color": "#FF4500", + "connector_key": "reddit", + "connector_id": "https://registry.pdpp.dev/connectors/reddit", + "display_name": "Reddit", + "path": "connectors/reddit/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/reddit", + "svg": "" + }, + { + "background_color": "#95BF47", + "connector_key": "shopify", + "connector_id": "https://registry.pdpp.dev/connectors/shopify", + "display_name": "Shopify Shop", + "path": "connectors/shopify/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/shop", + "svg": "" + }, + { + "background_color": "#3A76F0", + "connector_key": "signal", + "connector_id": "https://registry.pdpp.dev/connectors/signal", + "display_name": "Signal Desktop", + "path": "connectors/signal/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/signal", + "svg": "" + }, + { + "background_color": "#4A154B", + "connector_key": "slack", + "connector_id": "https://registry.pdpp.dev/connectors/slack", + "display_name": "Slack", + "path": "connectors/slack/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/slack", + "svg": "" + }, + { + "background_color": "#4A154B", + "connector_key": "slack-browser", + "connector_id": "https://registry.pdpp.dev/connectors/slack-browser", + "display_name": "Slack (Browser Sign-In)", + "path": "connectors/slack_browser/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/slack", + "svg": "" + }, + { + "background_color": "#1ED760", + "connector_key": "spotify", + "connector_id": "https://registry.pdpp.dev/connectors/spotify", + "display_name": "Spotify", + "path": "connectors/spotify/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/spotify", + "svg": "" + }, + { + "background_color": "#1B2838", + "connector_key": "steam", + "connector_id": "https://registry.pdpp.dev/connectors/steam", + "display_name": "Steam", + "path": "connectors/steam/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/steam", + "svg": "" + }, + { + "background_color": "#FC4C02", + "connector_key": "strava", + "connector_id": "https://registry.pdpp.dev/connectors/strava", + "display_name": "Strava", + "path": "connectors/strava/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/strava", + "svg": "" + }, + { + "background_color": "#FC4C02", + "connector_key": "strava-browser", + "connector_id": "https://registry.pdpp.dev/connectors/strava-browser", + "display_name": "Strava (Browser Sign-In)", + "path": "connectors/strava_browser/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/strava", + "svg": "" + }, + { + "background_color": "#000000", + "connector_key": "tiktok", + "connector_id": "https://registry.pdpp.dev/connectors/tiktok", + "display_name": "TikTok (Browser Sign-In)", + "path": "connectors/tiktok/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/tiktok", + "svg": "" + }, + { + "background_color": "#000000", + "connector_key": "twitter-archive", + "connector_id": "https://registry.pdpp.dev/connectors/twitter-archive", + "display_name": "Twitter/X Archive", + "path": "connectors/twitter_archive/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/twitter_archive", + "svg": "" + }, + { + "background_color": "#000000", + "connector_key": "uber", + "connector_id": "https://registry.pdpp.dev/connectors/uber", + "display_name": "Uber", + "path": "connectors/uber/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/uber", + "svg": "" + }, + { + "background_color": "#12395B", + "connector_key": "usaa", + "connector_id": "https://registry.pdpp.dev/connectors/usaa", + "display_name": "USAA", + "path": "connectors/usaa/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/usaa", + "svg": "" + }, + { + "background_color": "#3D95CE", + "connector_key": "venmo", + "connector_id": "https://registry.pdpp.dev/connectors/venmo", + "display_name": "Venmo", + "path": "connectors/venmo/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/venmo", + "svg": "" + }, + { + "background_color": "#25D366", + "connector_key": "whatsapp", + "connector_id": "https://registry.pdpp.dev/connectors/whatsapp", + "display_name": "WhatsApp (chat export)", + "path": "connectors/whatsapp/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/whatsapp", + "svg": "" + }, + { + "background_color": "#25D366", + "connector_key": "whatsapp-web", + "connector_id": "https://registry.pdpp.dev/connectors/whatsapp-web", + "display_name": "WhatsApp (web)", + "path": "connectors/whatsapp_web/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/whatsapp_web", + "svg": "" + }, + { + "background_color": "#046F46", + "connector_key": "wholefoods", + "connector_id": "https://registry.pdpp.dev/connectors/wholefoods", + "display_name": "Whole Foods", + "path": "connectors/wholefoods/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/wholefoods", + "svg": "" + }, + { + "background_color": "#000000", + "connector_key": "whoop", + "connector_id": "https://registry.pdpp.dev/connectors/whoop", + "display_name": "WHOOP", + "path": "connectors/whoop/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/whoop", + "svg": "" + }, + { + "background_color": "#000000", + "connector_key": "whoop-browser", + "connector_id": "https://registry.pdpp.dev/connectors/whoop-browser", + "display_name": "WHOOP (Browser Sign-In)", + "path": "connectors/whoop_browser/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/whoop_browser", + "svg": "" + }, + { + "background_color": "#000000", + "connector_key": "x-browser", + "connector_id": "https://registry.pdpp.dev/connectors/x-browser", + "display_name": "X (Browser Sign-In)", + "path": "connectors/x_browser/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/x", + "svg": "" + }, + { + "background_color": "#3B5EDA", + "connector_key": "ynab", + "connector_id": "https://registry.pdpp.dev/connectors/ynab", + "display_name": "YNAB", + "path": "connectors/ynab/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/ynab", + "svg": "" + }, + { + "background_color": "#FF0000", + "connector_key": "youtube", + "connector_id": "https://registry.pdpp.dev/connectors/youtube", + "display_name": "YouTube", + "path": "connectors/youtube/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/youtube", + "svg": "" + }, + { + "background_color": "#FF0000", + "connector_key": "youtube-takeout", + "connector_id": "https://registry.pdpp.dev/connectors/youtube-takeout", + "display_name": "YouTube Takeout", + "path": "connectors/youtube_takeout/icon.svg", + "source_id": "https://registry.pdpp.dev/sources/youtube", + "svg": "" + } + ] +} diff --git a/apps/console/src/app/(console)/lib/resolve-connector-icon-path.ts b/apps/console/src/app/(console)/lib/resolve-connector-icon-path.ts index a599ff773..0f141604d 100644 --- a/apps/console/src/app/(console)/lib/resolve-connector-icon-path.ts +++ b/apps/console/src/app/(console)/lib/resolve-connector-icon-path.ts @@ -4,7 +4,7 @@ import { readFile } from "node:fs/promises"; import { resolve, sep } from "node:path"; import type { ConnectorIconLike } from "@pdpp/brand-react"; -import { sanitizeSvg } from "./sanitize-svg.ts"; +import { sanitizeSvg } from "@pdpp/brand-react/sanitize-svg"; /** * Server-only counterpart to resolve-connector-icon.ts's inline_svg @@ -17,10 +17,10 @@ import { sanitizeSvg } from "./sanitize-svg.ts"; * the resolved, sanitized SVG into the same inline_svg shape * resolveConnectorIcon already renders. * - * This is icon-resolution LAYER 1 (bundled-with-the-manifest, checked - * first) of the three-layer resolver: layer 1 here, layer 2 in - * resolve-connector-icon-simple-icons.ts, layer 3 (deterministic monogram) - * already implemented by @pdpp/brand-react's ConnectorIcon fallback. + * This reads the file a manifest's `brand.icon` names, which is a manifest- + * owned mark. Where it sits among the other sources of a mark (the manifest's + * inline icon, bundled marks, simple-icons, the monogram) is decided only by + * `pickDisplayMark` in resolve-manifest-display-mark.ts. */ /** diff --git a/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.test.ts b/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.test.ts index b909fd824..5062feea9 100644 --- a/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.test.ts +++ b/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.test.ts @@ -2,9 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; +import { cpSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; import { resolveConnectorIconFromSimpleIcons } from "./resolve-connector-icon-simple-icons.ts"; +const FIGMA_TITLE = /Figma<\/title>/; + test("resolves a known brand by connector key (exact slug match)", async () => { const icon = await resolveConnectorIconFromSimpleIcons("github"); assert.ok(icon); @@ -28,3 +33,27 @@ test("returns null for an empty or blank connector key", async () => { assert.equal(await resolveConnectorIconFromSimpleIcons(""), null); assert.equal(await resolveConnectorIconFromSimpleIcons(" "), null); }); + +// Next's standalone tracer copies only the traced files (outputFileTracingIncludes +// names data/simple-icons.json and icons/*.svg), never the package's own +// package.json. The lookup must not require a file the build does not ship. +test("resolves from a standalone-style layout that ships no package.json", async () => { + const source = join(process.cwd(), "..", "..", "node_modules", "simple-icons"); + const root = mkdtempSync(join(tmpdir(), "standalone-layout-")); + const previous = process.cwd(); + try { + const pkg = join(root, "node_modules", "simple-icons"); + mkdirSync(join(pkg, "data"), { recursive: true }); + mkdirSync(join(pkg, "icons"), { recursive: true }); + cpSync(join(source, "data", "simple-icons.json"), join(pkg, "data", "simple-icons.json")); + cpSync(join(source, "icons", "figma.svg"), join(pkg, "icons", "figma.svg")); + mkdirSync(join(root, "apps", "console"), { recursive: true }); + process.chdir(join(root, "apps", "console")); + const icon = await resolveConnectorIconFromSimpleIcons("figma"); + assert.ok(icon, "a traced standalone package must still resolve"); + assert.match(icon.svg ?? "", FIGMA_TITLE); + } finally { + process.chdir(previous); + rmSync(root, { force: true, recursive: true }); + } +}); diff --git a/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.ts b/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.ts index e19463977..4a9a9cc4c 100644 --- a/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.ts +++ b/apps/console/src/app/(console)/lib/resolve-connector-icon-simple-icons.ts @@ -5,77 +5,79 @@ import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { ConnectorIconLike } from "@pdpp/brand-react"; -import { sanitizeSvg } from "./sanitize-svg.ts"; +import { sanitizeSvg } from "@pdpp/brand-react/sanitize-svg"; /** - * Icon-resolution LAYER 2: a vendored, offline lookup into the simple-icons - * npm package (CC0-licensed SVG markup, ~3,300 brand icons), used only when - * a connector's manifest declares no bundled icon of its own (layer 1 in - * resolve-connector-icon-path.ts). This exists for connectors added AFTER - * today's fix — every manifest currently shipped already has a bundled icon, - * so layer 1 alone covers the reported bug. + * The `generic` layer of `pickDisplayMark` (resolve-manifest-display-mark.ts): + * an offline lookup into the installed simple-icons npm package (CC0-licensed + * SVG markup, ~3,400 brand icons), used only when neither the manifest nor + * the bundled corpus supplies a mark. This exists for connectors added AFTER + * the bundled corpus was generated. * * Offline/build-time only: this reads a file the npm package already * installed on disk. It never fetches from a network at render time and * never calls a third-party logo CDN (logo.dev and similar are explicitly - * out of scope — see the manifests/icons NOTICE for why). + * out of scope — see the connector-icons NOTICE for the attribution terms). * * simple-icons' SVG markup is CC0 (public domain), but its LICENSE is * explicit that CC0 does not waive trademark rights, and it runs a formal * brand-owner takedown channel. Rendering one of its icons here is the same - * nominative-fair-use posture as the vendored layer-1 icons: identification - * only, no implied endorsement. See manifests/icons/NOTICE.md. + * nominative-fair-use posture as the bundled icons: identification only, no + * implied endorsement. See connector-icons/NOTICE. */ const PACKAGE_NAME = "simple-icons"; -const INSTALLED_PACKAGE_ROOT_CANDIDATES = [ - join(process.cwd(), "node_modules", PACKAGE_NAME), - join(process.cwd(), "..", "..", "node_modules", PACKAGE_NAME), -]; +/** What the lookup reads; the build's file tracer copies exactly these (next.config.mjs). */ +const SLUG_INDEX = join("data", "simple-icons.json"); interface SimpleIconEntry { slug: string; title: string; } -let cachedPackageRoot: string | null | undefined; -let cachedSlugs: Set<string> | null | undefined; +// Keyed by cwd, because the candidate roots are relative to it: Next's +// standalone server.js chdirs into apps/console, and a test may chdir too. +const packageRootByCwd = new Map<string, string | null>(); +const slugsByRoot = new Map<string, Set<string>>(); function resolvePackageRoot(): string | null { - if (cachedPackageRoot !== undefined) { - return cachedPackageRoot; + const cwd = process.cwd(); + const cached = packageRootByCwd.get(cwd); + if (cached !== undefined) { + return cached; } - for (const candidate of INSTALLED_PACKAGE_ROOT_CANDIDATES) { - if (existsSync(join(candidate, "package.json"))) { - cachedPackageRoot = candidate; - return candidate; - } - } - cachedPackageRoot = null; - return null; + const root = + [join(cwd, "node_modules", PACKAGE_NAME), join(cwd, "..", "..", "node_modules", PACKAGE_NAME)].find((candidate) => + existsSync(join(candidate, SLUG_INDEX)) + ) ?? null; + packageRootByCwd.set(cwd, root); + return root; } -/** The set of every valid simple-icons slug, loaded once and cached. */ +/** The set of every valid simple-icons slug, loaded once per package root and cached. */ function loadKnownSlugs(packageRoot: string): Set<string> { - if (cachedSlugs !== undefined && cachedSlugs !== null) { - return cachedSlugs; + const cached = slugsByRoot.get(packageRoot); + if (cached) { + return cached; } + let slugs: Set<string>; try { - const raw = readFileSync(join(packageRoot, "data", "simple-icons.json"), "utf8"); - const entries = JSON.parse(raw) as SimpleIconEntry[]; - cachedSlugs = new Set(entries.map((entry) => entry.slug)); + const raw = readFileSync(join(packageRoot, SLUG_INDEX), "utf8"); + slugs = new Set((JSON.parse(raw) as SimpleIconEntry[]).map((entry) => entry.slug)); } catch { - cachedSlugs = new Set(); + slugs = new Set(); } - return cachedSlugs; + slugsByRoot.set(packageRoot, slugs); + return slugs; } /** * Normalize a connector key to the slug shape simple-icons uses: lowercase, * digits and letters only. Matches simple-icons' own slug algorithm (a * lowercased title with non-alphanumeric characters stripped), so this is a - * direct lookup rather than a fuzzy guess — verified against every - * currently-shipped connector key with no false positives. + * direct lookup rather than a fuzzy guess. Separators are dropped, so + * `foo-bar` and `foobar` name the same slug: a connector whose key only + * coincides with another brand's slug would show that brand's mark. */ function slugFromConnectorKey(connectorKey: string): string { return connectorKey.toLowerCase().replace(/[^a-z0-9]/g, ""); @@ -86,7 +88,7 @@ function slugFromConnectorKey(connectorKey: string): string { * connector key (e.g. "claude-code" -> slug "claudecode"). Returns null * (never throws) when simple-icons is not installed, the key has no known * slug match, the icon file is missing, or its content fails sanitizeSvg — - * any of which falls through to the deterministic Monogram (layer 3). + * any of which falls through to the deterministic Monogram. */ export async function resolveConnectorIconFromSimpleIcons(connectorKey: string): Promise<ConnectorIconLike | null> { if (typeof connectorKey !== "string" || connectorKey.trim().length === 0) { @@ -97,7 +99,7 @@ export async function resolveConnectorIconFromSimpleIcons(connectorKey: string): return null; } const slug = slugFromConnectorKey(connectorKey); - if (!slug || !loadKnownSlugs(packageRoot).has(slug)) { + if (!(slug && loadKnownSlugs(packageRoot).has(slug))) { return null; } let raw: string; diff --git a/apps/console/src/app/(console)/lib/resolve-connector-icon.test.ts b/apps/console/src/app/(console)/lib/resolve-connector-icon.test.ts index 888cf2a41..8effedfca 100644 --- a/apps/console/src/app/(console)/lib/resolve-connector-icon.test.ts +++ b/apps/console/src/app/(console)/lib/resolve-connector-icon.test.ts @@ -2,19 +2,163 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; import test from "node:test"; -import { resolveConnectorIcon } from "./resolve-connector-icon.ts"; +import { fileURLToPath } from "node:url"; +import postcss from "postcss"; +import React, { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import ts from "typescript"; +import { ConnectorMark } from "../components/connector-mark.tsx"; +import { connectorIconIdentityKeys } from "./connector-icon-identity.ts"; +import corpus from "./connector-icons/sanitized-assets.json" with { type: "json" }; +import { resolveConnectorIcon, resolveSourceIcon } from "./resolve-connector-icon.ts"; -test("resolveConnectorIcon accepts a non-empty inline SVG", () => { +// The repository test harness transpiles source JSX with the classic runtime. +Object.assign(globalThis, { React }); + +test("resolveConnectorIcon resolves every bundled mark by connector key and stable registry ID", () => { + for (const entry of corpus.entries) { + const byKey = resolveConnectorIcon(entry.connector_key); + const byId = resolveConnectorIcon(entry.connector_id); + assert.equal(byKey?.kind, "inline_svg", entry.connector_key); + assert.equal(byId?.kind, "inline_svg", entry.connector_id); + assert.equal(byKey?.svg, entry.svg, entry.connector_key); + assert.equal(byId?.svg, entry.svg, entry.connector_id); + } +}); + +test("known connector key aliases accept underscore and hyphen spellings", () => { + const hyphen = resolveConnectorIcon("apple-contacts"); + assert.ok(hyphen); + assert.equal(resolveConnectorIcon("apple_contacts")?.svg, hyphen.svg); +}); + +test("safe supplied icon wins and unsafe SVG falls back to the genuine bundled mark", () => { + const custom = { kind: "inline_svg" as const, svg: '<svg viewBox="0 0 1 1"><path d="M0 0" /></svg>' }; + assert.equal(resolveConnectorIcon("amazon", custom)?.svg, custom.svg); + const unsafe = { kind: "inline_svg" as const, svg: '<svg onload="alert(1)"><path d="M0 0" /></svg>' }; + assert.equal(resolveConnectorIcon("amazon", unsafe)?.svg, resolveConnectorIcon("amazon")?.svg); +}); + +test("unknown IDs and arbitrary registry origins do not borrow a mark by basename", () => { + assert.equal(resolveConnectorIcon("not-a-known-connector"), null); + assert.equal(resolveConnectorIcon("https://attacker.example/connectors/amazon"), null); + assert.equal(resolveConnectorIcon(""), null); +}); + +test("ConnectorMark SSR uses stable connector identity when no optional icon was supplied", () => { + const markup = renderToStaticMarkup( + createElement(ConnectorMark, { connectorId: "amazon", name: "Custom account label" }) + ); + assert.ok(markup.includes("<svg")); + assert.ok(markup.includes('viewBox="0 0 24 24"')); + assert.equal(markup.includes("Custom account label"), false); +}); + +test("ConnectorMark keeps the genuine initials fallback for an unknown connector ID", () => { + const markup = renderToStaticMarkup( + createElement(ConnectorMark, { connectorId: "unknown-connector", name: "Example Source" }) + ); + assert.equal(markup.includes("<svg"), false); + assert.ok(markup.includes('data-initials="ES"')); +}); + +test("preserves first-party registry aliases without borrowing brands from unrelated URLs", () => { assert.deepEqual( - resolveConnectorIcon({ color: "#111", kind: "inline_svg", svg: " <svg /> " }), - { color: "#111", kind: "inline_svg", svg: "<svg />" } + resolveConnectorIcon("https://registry.pdpp.dev/connectors/apple_contacts/"), + resolveConnectorIcon("apple-contacts") + ); + assert.equal(resolveConnectorIcon("https://unrelated.example/connectors/apple-contacts"), null); +}); + +test("Core source display uses declared provenance without conflating connector identity", () => { + const sourceId = corpus.entries.find((entry) => entry.connector_key === "amazon")?.source_id; + assert.ok(sourceId); + const connector = resolveConnectorIcon("amazon"); + assert.deepEqual(resolveSourceIcon(sourceId), connector); + assert.equal(resolveConnectorIcon(sourceId), null, "Core source identity is not an operational connector ID"); + assert.equal(resolveSourceIcon("https://unrelated.example/sources/amazon"), null); + const markup = renderToStaticMarkup(createElement(ConnectorMark, { sourceId, name: "Personal source" })); + assert.ok(markup.includes("<svg")); +}); + +test("non-hierarchical URI identities never acquire local-key aliases", () => { + const underscored = "urn:vendor:foo_bar"; + const hyphenated = "urn:vendor:foo-bar"; + assert.equal(new URL(underscored).protocol, "urn:"); + assert.deepEqual(connectorIconIdentityKeys(underscored), [underscored]); + assert.deepEqual(connectorIconIdentityKeys(hyphenated), [hyphenated]); +}); + +const SOURCE_FILE = /\.(?:[cm]?[jt]s|[jt]sx)$/u; + +// Geometry is a product-wide renderer decision. Parsing every caller catches +// future pages too; a snapshot of today's two reported rows would miss them. +test("product connector marks cannot bypass shared geometry through local rendering or CSS", () => { + const root = fileURLToPath(new URL("../../../", import.meta.url)); + const wrapper = path.join("app", "(console)", "components", "connector-mark.tsx"); + const violations: string[] = []; + for (const relative of readdirSync(root, { recursive: true })) { + if (typeof relative !== "string" || relative.includes(".test.")) { + continue; + } + const filename = path.join(root, relative); + if (relative.endsWith(".css")) { + postcss.parse(readFileSync(filename, "utf8")).walkRules((rule) => { + if (rule.selector.includes(".pdpp-connector-icon")) { + violations.push(`${relative}: icon CSS belongs to the shared renderer`); + } + }); + continue; + } + if (!SOURCE_FILE.test(relative) || relative === wrapper) { + continue; + } + const source = ts.createSourceFile( + filename, + readFileSync(filename, "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement)) { + continue; + } + const bindings = statement.importClause?.namedBindings; + if ( + bindings && + ts.isNamedImports(bindings) && + bindings.elements.some((binding) => (binding.propertyName ?? binding.name).text === "ConnectorIcon") + ) { + violations.push(`${relative}: render ConnectorMark instead of importing ConnectorIcon`); + } + } + } + assert.deepEqual(violations, []); +}); + +test("product connector mark API reserves geometry and exposes only text spacing", () => { + type Overrides = Extract<keyof Parameters<typeof ConnectorMark>[0], "className" | "style" | "size">; + const acceptsNoGeometryOverrides: Overrides extends never ? true : false = true; + assert.equal(acceptsNoGeometryOverrides, true); + const markup = renderToStaticMarkup( + createElement(ConnectorMark, { connectorId: "claude-code", name: "Account", spacing: "after" }) ); + assert.ok(markup.includes('class="pdpp-connector-icon mr-2"')); }); -test("resolveConnectorIcon returns null for a missing or unusable mark", () => { - assert.equal(resolveConnectorIcon(null), null); - assert.equal(resolveConnectorIcon(undefined), null); - assert.equal(resolveConnectorIcon({ kind: "inline_svg", svg: " " }), null); - assert.equal(resolveConnectorIcon({ kind: "external_url", svg: "https://example.test/logo.svg" }), null); +test("no two bundled connectors answer to the same identity with different marks", () => { + const owners = new Map<string, string>(); + for (const entry of corpus.entries) { + for (const identity of [entry.connector_id, entry.connector_key]) { + for (const key of connectorIconIdentityKeys(identity)) { + const previous = owners.get(key); + assert.ok(previous === undefined || previous === entry.connector_key, `${key} is claimed by ${previous} and ${entry.connector_key}`); + owners.set(key, entry.connector_key); + } + } + } }); diff --git a/apps/console/src/app/(console)/lib/resolve-connector-icon.ts b/apps/console/src/app/(console)/lib/resolve-connector-icon.ts index 42a5f1eb6..ef84a8812 100644 --- a/apps/console/src/app/(console)/lib/resolve-connector-icon.ts +++ b/apps/console/src/app/(console)/lib/resolve-connector-icon.ts @@ -2,23 +2,147 @@ // SPDX-License-Identifier: Apache-2.0 import type { ConnectorIconLike } from "@pdpp/brand-react"; +import { hexColorOrNull } from "@pdpp/brand-react/hex-color"; +import { sanitizeSvg } from "@pdpp/brand-react/sanitize-svg"; +import { connectorIconIdentityKeys } from "./connector-icon-identity.ts"; +import iconCorpus from "./connector-icons/sanitized-assets.json" with { type: "json" }; + +type CorpusEntry = (typeof iconCorpus.entries)[number]; + +/** Validate an SVG mark at the shared display boundary. */ +export function validatedConnectorIcon( + icon: ConnectorIconLike | null | undefined, + declaredBackground?: string | null +): ConnectorIconLike | null { + if (icon?.kind !== "inline_svg" || typeof icon.svg !== "string") { + return null; + } + const svg = sanitizeSvg(icon.svg); + if (!svg) { + return null; + } + const color = hexColorOrNull(icon.color); + const backgroundColor = hexColorOrNull(icon.backgroundColor ?? declaredBackground); + return { ...(color ? { color } : {}), ...(backgroundColor ? { backgroundColor } : {}), kind: "inline_svg", svg }; +} + +function corpusIcon(entry: CorpusEntry): ConnectorIconLike | null { + return validatedConnectorIcon({ backgroundColor: entry.background_color, kind: "inline_svg", svg: entry.svg }); +} + +const BUNDLED_ICONS_BY_IDENTITY = new Map<string, ConnectorIconLike>(); +const BUNDLED_ICONS_BY_SOURCE = new Map<string, ConnectorIconLike | null>(); +for (const entry of iconCorpus.entries) { + const icon = corpusIcon(entry); + if (!icon) { + continue; + } + if (entry.source_id) { + const previous = BUNDLED_ICONS_BY_SOURCE.get(entry.source_id); + const conflicts = + previous !== undefined && + (previous === null || previous.svg !== icon.svg || previous.backgroundColor !== icon.backgroundColor); + BUNDLED_ICONS_BY_SOURCE.set(entry.source_id, conflicts ? null : icon); + } + for (const identity of [entry.connector_id, entry.connector_key]) { + for (const key of connectorIconIdentityKeys(identity)) { + BUNDLED_ICONS_BY_IDENTITY.set(key, icon); + } + } +} + +/** A copy of the bundled corpus indexed by every accepted identity spelling, for callers that layer it. */ +export function bundledIconIdentities(): Map<string, ConnectorIconLike> { + return new Map(BUNDLED_ICONS_BY_IDENTITY); +} + +/** Resolve an explicit safe icon first, then a bundled mark for the exact connector identity. */ +export function resolveConnectorIcon( + connectorId: string, + icon?: ConnectorIconLike | null, + declaredBackground?: string | null +): ConnectorIconLike | null { + const supplied = validatedConnectorIcon(icon, declaredBackground); + if (supplied) { + return supplied; + } + for (const key of connectorIconIdentityKeys(connectorId)) { + const bundled = BUNDLED_ICONS_BY_IDENTITY.get(key); + if (bundled) { + return bundled; + } + } + return null; +} + +const conflictedKeysByLayer = new WeakMap<Map<string, ConnectorIconLike>, Set<string>>(); + +function sameMark(left: ConnectorIconLike, right: ConnectorIconLike): boolean { + return left.svg === right.svg && left.color === right.color && left.backgroundColor === right.backgroundColor; +} /** - * Resolve the one icon shape shared by manifests, owner templates, and view - * models. Invalid or absent marks intentionally return null so the shared - * ConnectorMark can render the design-system monogram fallback. - * - * Manifests declare their brand glyph as a relative path string - * (`brand.icon`, e.g. "icons/amazon.svg"), not inline markup. This - * validator only ever sees the `inline_svg` shape because - * resolve-connector-icon-path.ts (server-only) resolves that path to - * sanitized SVG content in listConnectorManifests(), before the manifest - * reaches this file — which is imported by connector-mark.tsx, a "use - * client" component, and so must never itself touch the filesystem. + * Add a resolved mark under every accepted explicit connector spelling. + * Two different marks claiming one spelling in the same layer (for example + * `foo_bar` and `foo-bar`) is ambiguous: neither wins, the spelling gets no + * mark from this layer, and the collision is logged once. */ -export function resolveConnectorIcon(icon: ConnectorIconLike | null | undefined): ConnectorIconLike | null { - if (icon?.kind !== "inline_svg" || typeof icon.svg !== "string" || icon.svg.trim().length === 0) { - return null; +export function indexConnectorIcon( + icons: Map<string, ConnectorIconLike>, + connectorIds: readonly (string | null | undefined)[], + icon: ConnectorIconLike | null +): void { + if (!icon) { + return; + } + let conflicted = conflictedKeysByLayer.get(icons); + for (const connectorId of connectorIds) { + if (!connectorId) { + continue; + } + for (const key of connectorIconIdentityKeys(connectorId)) { + const existing = icons.get(key); + if (conflicted?.has(key)) { + continue; + } + if (existing && !sameMark(existing, icon)) { + conflicted ??= new Set(); + conflictedKeysByLayer.set(icons, conflicted); + conflicted.add(key); + icons.delete(key); + console.warn( + `Connector icon identity "${key}" is claimed by two different marks in one layer; showing neither.` + ); + continue; + } + icons.set(key, icon); + } + } +} + +/** True when a map already contains a mark for one of these exact identities or spellings. */ +export function hasConnectorIcon( + icons: ReadonlyMap<string, ConnectorIconLike>, + connectorIds: readonly (string | null | undefined)[] +): boolean { + return connectorIds.some( + (connectorId) => connectorId && connectorIconIdentityKeys(connectorId).some((key) => icons.has(key)) + ); +} + +/** Core source IDs stay separate from connector IDs. This display-only lookup + * uses declared source provenance first. Only when no corpus entry declares + * the ID does it try an EXACT connector identity (key or canonical ID) for + * records that carry the connector key as their source ID; it never matches + * a partial or case-folded name. A source ID whose declarations conflict + * resolves to no brand, and is deliberately not rescued by that fallback. */ +export function resolveSourceIcon(sourceId: string, icon?: ConnectorIconLike | null): ConnectorIconLike | null { + const supplied = validatedConnectorIcon(icon); + if (supplied) { + return supplied; + } + if (BUNDLED_ICONS_BY_SOURCE.has(sourceId)) { + return BUNDLED_ICONS_BY_SOURCE.get(sourceId) ?? null; } - return { ...icon, svg: icon.svg.trim() }; + return BUNDLED_ICONS_BY_IDENTITY.get(sourceId) ?? null; } diff --git a/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.test.ts b/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.test.ts new file mode 100644 index 000000000..12a1897ee --- /dev/null +++ b/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.test.ts @@ -0,0 +1,106 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { resolveManifestDisplayMark } from "./resolve-manifest-display-mark.ts"; + +const WHITE_GLYPH = '<svg viewBox="0 0 24 24" fill="#FFFFFF"><path d="M1 2h20v20z"/></svg>'; +const INLINE_GLYPH = '<svg viewBox="0 0 24 24"><path d="M3 3h18v18z"/></svg>'; + +async function withDir(run: (dir: string) => Promise<void>): Promise<void> { + const dir = await mkdtemp(join(tmpdir(), "display-mark-")); + try { + await mkdir(join(dir, "icons")); + await writeFile(join(dir, "icons", "white.svg"), WHITE_GLYPH); + await run(dir); + } finally { + await rm(dir, { force: true, recursive: true }); + } +} + +test("a path-resolved glyph takes the declared brand backdrop", async () => { + await withDir(async (dir) => { + const mark = await resolveManifestDisplayMark( + { brand: { background_color: "#FF9900", icon: "icons/white.svg" }, connector_id: "zz-new", connector_key: "zz-new" }, + dir + ); + assert.deepEqual(mark, { + icon: { backgroundColor: "#FF9900", kind: "inline_svg", svg: WHITE_GLYPH }, + source: "manifest", + }); + }); +}); + +test("an inline mark wins over brand.icon, and its own backdrop wins over the declared one", async () => { + await withDir(async (dir) => { + const mark = await resolveManifestDisplayMark( + { + brand: { background_color: "#FF9900", icon: "icons/white.svg" }, + connector_id: "zz-new", + icon: { backgroundColor: "#112233", kind: "inline_svg", svg: INLINE_GLYPH }, + }, + dir + ); + assert.deepEqual(mark.icon, { backgroundColor: "#112233", kind: "inline_svg", svg: INLINE_GLYPH }); + const declaredOnly = await resolveManifestDisplayMark( + { brand: { background_color: "#FF9900" }, connector_id: "zz-new", icon: { kind: "inline_svg", svg: INLINE_GLYPH } }, + dir + ); + assert.equal(declaredOnly.icon?.backgroundColor, "#FF9900"); + }); +}); + +test("a declared backdrop that is not a six-digit hex colour is dropped", async () => { + await withDir(async (dir) => { + const mark = await resolveManifestDisplayMark( + { brand: { background_color: "url(javascript:0)", icon: "icons/white.svg" }, connector_id: "zz-new" }, + dir + ); + assert.equal(mark.icon?.backgroundColor, undefined); + assert.equal(mark.icon?.svg, WHITE_GLYPH); + }); +}); + +test("without an own mark, the bundled mark for the identity is used with its own backdrop", async () => { + await withDir(async (dir) => { + const mark = await resolveManifestDisplayMark( + { brand: { background_color: "#000001" }, connector_id: "https://registry.pdpp.dev/connectors/amazon", connector_key: "amazon" }, + dir + ); + assert.equal(mark.source, "bundled"); + assert.equal(mark.icon?.backgroundColor, "#FF9900"); + }); +}); + +test("a bundled mark outranks the generic simple-icons mark for the same key", async () => { + await withDir(async (dir) => { + const mark = await resolveManifestDisplayMark({ connector_id: "github", connector_key: "github" }, dir); + assert.equal(mark.source, "bundled"); + }); +}); + +test("simple-icons is used only when nothing else matches, and nothing leaves the monogram", async () => { + await withDir(async (dir) => { + const generic = await resolveManifestDisplayMark({ connector_id: "figma", connector_key: "figma" }, dir); + assert.equal(generic.source, "simple-icons"); + assert.ok(generic.icon?.svg); + assert.deepEqual(await resolveManifestDisplayMark({ connector_id: "zz-unknown" }, dir), { + icon: null, + source: undefined, + }); + }); +}); + +test("an unreadable brand.icon falls through to the bundled mark", async () => { + await withDir(async (dir) => { + const mark = await resolveManifestDisplayMark( + { brand: { icon: "icons/missing.svg" }, connector_id: "amazon", connector_key: "amazon" }, + dir + ); + assert.equal(mark.source, "bundled"); + }); +}); diff --git a/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.ts b/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.ts new file mode 100644 index 000000000..239f63308 --- /dev/null +++ b/apps/console/src/app/(console)/lib/resolve-manifest-display-mark.ts @@ -0,0 +1,83 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ConnectorIconLike } from "@pdpp/brand-react"; +import { resolveConnectorIcon, validatedConnectorIcon } from "./resolve-connector-icon.ts"; +import { resolveConnectorIconFromManifestPath } from "./resolve-connector-icon-path.ts"; +import { resolveConnectorIconFromSimpleIcons } from "./resolve-connector-icon-simple-icons.ts"; +import type { ConnectorManifest } from "./rs-client.ts"; + +type DisplayMarkSource = NonNullable<ConnectorManifest["resolvedIconSource"]>; + +/** Where a candidate display mark came from, strongest first. */ +export interface DisplayMarkLayers { + /** The mark the manifest itself owns: its inline `icon` or its `brand.icon` file. */ + readonly manifest?: ConnectorIconLike | null; + /** The mark the server projected on the owner connector template. */ + readonly template?: ConnectorIconLike | null; + /** The bundled mark for the connector identity (`sanitized-assets.json`). */ + readonly bundled?: ConnectorIconLike | null; + /** The offline simple-icons mark for `connector_key`. */ + readonly generic?: ConnectorIconLike | null; +} + +const DISPLAY_MARK_ORDER = ["manifest", "template", "bundled", "generic"] as const; + +/** + * The one precedence rule for a connector's display mark, first hit wins: + * 1. `manifest`: a mark the manifest owns; + * 2. `template`: the server-projected template mark, used when the console + * has no manifest mark of its own (the server copy is not normalized); + * 3. `bundled`: the shipped mark for the connector identity; + * 4. `generic`: the offline simple-icons mark; + * 5. nothing, which renders the deterministic monogram. + * Every surface reaches a mark through this function, so no two surfaces can + * order the same layers differently. + */ +export function pickDisplayMark<L extends keyof DisplayMarkLayers>( + layers: Pick<DisplayMarkLayers, L> +): { icon: ConnectorIconLike | null; layer: L | undefined } { + // `layers` holds only the keys the caller supplied, so a layer the caller + // never passes can never be reported as the source. + const supplied: DisplayMarkLayers = layers; + for (const layer of DISPLAY_MARK_ORDER) { + const icon = supplied[layer]; + if (icon) { + return { icon, layer: layer as L }; + } + } + return { icon: null, layer: undefined }; +} + +/** + * The one place a connector manifest becomes a display mark: sanitized SVG, a + * validated backdrop, and where it came from. Every surface renders the result + * (`ConnectorManifest.icon`) as is, so none of them re-derives a backdrop. + * Layers are ordered by `pickDisplayMark`; a manifest alone has no template. + * Marks from the manifest take their backdrop from the mark itself, else from + * `brand.background_color`. Bundled and simple-icons marks carry their own. + */ +export async function resolveManifestDisplayMark( + manifest: Pick<ConnectorManifest, "brand" | "connector_id" | "connector_key" | "icon">, + manifestsDir: string +): Promise<{ icon: ConnectorIconLike | null; source: DisplayMarkSource | undefined }> { + const declaredBackground = manifest.brand?.background_color; + const inline = validatedConnectorIcon(manifest.icon, declaredBackground); + const fromPath = + !inline && manifest.brand?.icon + ? validatedConnectorIcon( + await resolveConnectorIconFromManifestPath(manifestsDir, manifest.brand.icon), + declaredBackground + ) + : null; + const owned = inline ?? fromPath; + const bundled = + resolveConnectorIcon(manifest.connector_id) ?? + (manifest.connector_key ? resolveConnectorIcon(manifest.connector_key) : null); + const generic = + !(owned || bundled) && manifest.connector_key + ? await resolveConnectorIconFromSimpleIcons(manifest.connector_key) + : null; + const { icon, layer } = pickDisplayMark({ bundled, generic, manifest: owned }); + return { icon, source: layer === "generic" ? "simple-icons" : layer }; +} diff --git a/apps/console/src/app/(console)/lib/rs-client.ts b/apps/console/src/app/(console)/lib/rs-client.ts index 3b182ac07..dccb2454d 100644 --- a/apps/console/src/app/(console)/lib/rs-client.ts +++ b/apps/console/src/app/(console)/lib/rs-client.ts @@ -16,12 +16,11 @@ */ import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; +import type { ConnectorIconLike } from "@pdpp/brand-react"; import { findManifestForConnectorId } from "../sources/lib/relationships.ts"; import type { OwnerConnectorTemplateLike } from "./connection-catalog.ts"; import { resolveInstalledConnectorManifestsDir } from "./connector-manifests-dir.ts"; import { isActiveConnectorRunSummaryStatus } from "./connector-run-summary-status.ts"; -import { resolveConnectorIconFromManifestPath } from "./resolve-connector-icon-path.ts"; -import { resolveConnectorIconFromSimpleIcons } from "./resolve-connector-icon-simple-icons.ts"; import { getOwnerToken, getRsInternalUrl, @@ -29,6 +28,7 @@ import { ResourceServerHttpError, } from "./owner-token.ts"; import { type CanonicalReadWarning, extractReadWarnings } from "./read-envelope.ts"; +import { resolveManifestDisplayMark } from "./resolve-manifest-display-mark.ts"; import type { RefAcquisitionCoverageSummary, RefCollectionReportEntry, @@ -171,11 +171,9 @@ export interface ConnectorManifest { connector_key?: string; display_name?: string; /** Optional manifest-declared brand glyph; absent renders the Monogram fallback (see ConnectorIcon). */ - icon?: { - color?: string | null; - kind?: string | null; - svg?: string | null; - } | null; + icon?: ConnectorIconLike | null; + /** Server-side enrichment provenance; never read from or written to a manifest. */ + resolvedIconSource?: "manifest" | "bundled" | "simple-icons"; /** * Shipped manifests declare their brand glyph here as a relative path * under the connector-manifests directory (e.g. "icons/amazon.svg"), not @@ -184,6 +182,7 @@ export interface ConnectorManifest { * single `icon` (inline_svg) shape. */ brand?: { + background_color?: string | null; icon?: string | null; } | null; name?: string; @@ -817,21 +816,11 @@ export async function listConnectorManifests(): Promise<ConnectorManifest[]> { try { const raw = await readFile(join(dir, file), "utf8"); const manifest = JSON.parse(raw) as ConnectorManifest; - // Three-layer icon resolution (first hit wins), so every reader - // downstream keeps consuming the single `icon` (inline_svg) shape it - // already knows: - // 1. Bundled: the manifest's own `brand.icon` relative path (e.g. - // "icons/amazon.svg"), shipped with every connector today. - // 2. Vendored: a same-slug lookup into the offline simple-icons - // corpus, for a connector added later with no bundled icon. - // 3. Deterministic Monogram — @pdpp/brand-react's ConnectorIcon - // fallback when `icon` is left unset here. - if (!manifest.icon && manifest.brand?.icon) { - manifest.icon = await resolveConnectorIconFromManifestPath(dir, manifest.brand.icon); - } - if (!manifest.icon && manifest.connector_key) { - manifest.icon = await resolveConnectorIconFromSimpleIcons(manifest.connector_key); - } + // `icon` leaves here as the one display mark every surface renders + // (sanitized SVG, validated backdrop); see resolveManifestDisplayMark. + const mark = await resolveManifestDisplayMark(manifest, dir); + manifest.icon = mark.icon; + manifest.resolvedIconSource = mark.source; return manifest; } catch { return null; diff --git a/apps/console/src/app/(console)/lib/sanitize-svg.test.ts b/apps/console/src/app/(console)/lib/sanitize-svg.test.ts deleted file mode 100644 index 0611e4d34..000000000 --- a/apps/console/src/app/(console)/lib/sanitize-svg.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright The PDP-Connect Contributors -// SPDX-License-Identifier: Apache-2.0 - -import assert from "node:assert/strict"; -import test from "node:test"; -import { sanitizeSvg } from "./sanitize-svg.ts"; - -test("accepts a minimal bare svg", () => { - assert.equal(sanitizeSvg('<svg viewBox="0 0 24 24"><path d="M1 2" /></svg>'), '<svg viewBox="0 0 24 24"><path d="M1 2" /></svg>'); -}); - -test("accepts our shipped-manifest shape (aria-hidden, fill on root)", () => { - const svg = '<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#FFFFFF"><path d="M1 2" /></svg>'; - assert.equal(sanitizeSvg(svg), svg); -}); - -test("accepts the simple-icons shape (role, title)", () => { - const svg = '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub'; - assert.equal(sanitizeSvg(svg), svg); -}); - -test("rejects a script element", () => { - assert.equal(sanitizeSvg(""), null); -}); - -test("rejects an event-handler attribute", () => { - assert.equal(sanitizeSvg(''), null); -}); - -test("rejects an external reference via image href", () => { - assert.equal(sanitizeSvg(''), null); -}); - -test("rejects a javascript: or data: or url() attribute value", () => { - assert.equal(sanitizeSvg(''), null); -}); - -test("rejects a disallowed element", () => { - assert.equal(sanitizeSvg('
x
'), null); -}); - -test("rejects markup without a bare svg root", () => { - assert.equal(sanitizeSvg(''), null); -}); - -test("rejects non-SVG text", () => { - assert.equal(sanitizeSvg("just some text, not markup"), null); -}); - -test("rejects empty content", () => { - assert.equal(sanitizeSvg(""), null); - assert.equal(sanitizeSvg(" "), null); -}); - -test("rejects oversized content", () => { - const huge = `${"".repeat(1000)}`; - assert.equal(sanitizeSvg(huge), null); -}); diff --git a/apps/console/src/app/(console)/schedules/schedule-row.tsx b/apps/console/src/app/(console)/schedules/schedule-row.tsx index 827606dc7..6d534bce8 100644 --- a/apps/console/src/app/(console)/schedules/schedule-row.tsx +++ b/apps/console/src/app/(console)/schedules/schedule-row.tsx @@ -261,7 +261,7 @@ export function ScheduleRow({ connectorIcon, summary, runsHref }: ScheduleRowPro className="pdpp-body relative font-medium text-foreground before:absolute before:-inset-3 before:content-[''] hover:underline" href={recordsHref} > - + {displayName}
{connectorKey}
diff --git a/apps/console/src/app/(console)/sources/[connector]/[stream]/[recordKey]/page.tsx b/apps/console/src/app/(console)/sources/[connector]/[stream]/[recordKey]/page.tsx index db67a791a..45eab3890 100644 --- a/apps/console/src/app/(console)/sources/[connector]/[stream]/[recordKey]/page.tsx +++ b/apps/console/src/app/(console)/sources/[connector]/[stream]/[recordKey]/page.tsx @@ -239,7 +239,7 @@ export default async function RecordDetailPage({ description={ <> - + {sourceLabel} {ts.label} diff --git a/apps/console/src/app/(console)/sources/[connector]/[stream]/health/page.tsx b/apps/console/src/app/(console)/sources/[connector]/[stream]/health/page.tsx index 20b8b830a..33a0a5313 100644 --- a/apps/console/src/app/(console)/sources/[connector]/[stream]/health/page.tsx +++ b/apps/console/src/app/(console)/sources/[connector]/[stream]/health/page.tsx @@ -89,6 +89,7 @@ export default async function StreamHealthPage({ return ( - + Source {sourceLabel} } @@ -283,11 +287,13 @@ function StreamHealthReport({ // to the records segment error boundary. function StreamHealthUnavailable({ connectionId, + connectorId, connectorIcon, sourceLabel, streamName, }: { connectionId: string; + connectorId: string; connectorIcon: ConnectorManifest["icon"]; sourceLabel: string; streamName: string; @@ -303,7 +309,7 @@ function StreamHealthUnavailable({ ]} description={ - + Source {sourceLabel} } diff --git a/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx b/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx index a3bb1cbc4..e5c3a54fc 100644 --- a/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx +++ b/apps/console/src/app/(console)/sources/[connector]/[stream]/page.tsx @@ -457,7 +457,7 @@ export default async function StreamPage({ count={headerCount} description={ - + Source {sourceLabel} diff --git a/apps/console/src/app/(console)/sources/[connector]/page.tsx b/apps/console/src/app/(console)/sources/[connector]/page.tsx index b653295bc..fec3f69c5 100644 --- a/apps/console/src/app/(console)/sources/[connector]/page.tsx +++ b/apps/console/src/app/(console)/sources/[connector]/page.tsx @@ -846,7 +846,7 @@ function ConnectorPageView({ } title={ - + {displayName} diff --git a/apps/console/src/app/(console)/sources/add/page.tsx b/apps/console/src/app/(console)/sources/add/page.tsx index fe9136530..eb769b5d6 100644 --- a/apps/console/src/app/(console)/sources/add/page.tsx +++ b/apps/console/src/app/(console)/sources/add/page.tsx @@ -15,6 +15,7 @@ import type { ConnectorInstallSnapshot } from "../../lib/connector-install-contr import type { ConnectorInstallLifecycle } from "../../lib/connector-install-presentation.ts"; import { buildConnectorInstallLifecycleByConnector } from "../../lib/connector-install-presentation.ts"; import { getConnectorInstallSnapshot } from "../../lib/connector-install-client.ts"; +import { buildConnectorIconMap, catalogEntryIcon } from "../../lib/connector-icon-map.ts"; import { ReferenceServerUnreachableError } from "../../lib/owner-token.ts"; import { listConnectorManifests, listOwnerConnectorTemplates } from "../../lib/rs-client.ts"; import { isDeterministicSourcesReadError } from "../read-error-classification.ts"; @@ -64,13 +65,11 @@ export default async function AddSourcePage({ searchParams }: { searchParams: Pr listOwnerConnectorTemplates(), getConnectorInstallSnapshot(), ]); - connectorIcons = Object.fromEntries( - manifests.flatMap((manifest) => [ - [manifest.connector_id, manifest.icon] as const, - ...(manifest.connector_key ? ([[manifest.connector_key, manifest.icon]] as const) : []), - ]) - ); - catalog = buildOwnerConnectorCatalog(manifests, templates, installSnapshot?.catalog ?? []); + connectorIcons = await buildConnectorIconMap(manifests, templates); + catalog = buildOwnerConnectorCatalog(manifests, templates, installSnapshot?.catalog ?? []).map((entry) => ({ + ...entry, + icon: catalogEntryIcon(entry, connectorIcons), + })); installLifecycleByConnector = installSnapshot ? buildConnectorInstallLifecycleByConnector(installSnapshot.catalog, installSnapshot.status) : null; diff --git a/apps/console/src/app/(console)/sources/page.tsx b/apps/console/src/app/(console)/sources/page.tsx index 26508299c..9ee6c7274 100644 --- a/apps/console/src/app/(console)/sources/page.tsx +++ b/apps/console/src/app/(console)/sources/page.tsx @@ -36,6 +36,7 @@ import { } from "../components/connector-summary-page.tsx"; import { isPagedRequest, parseConnectorSummaryPageState } from "../components/connector-summary-pager.ts"; import { ServerUnreachable } from "../components/server-unreachable.tsx"; +import { buildConnectorIconMap } from "../lib/connector-icon-map.ts"; import { isActiveConnectorRunSummaryStatus } from "../lib/connector-run-summary-status.ts"; import { liveDashboardDataSource } from "../lib/data-source.ts"; import { getReferencePublicOrigin, ReferenceServerUnreachableError } from "../lib/owner-token.ts"; @@ -117,11 +118,16 @@ export default async function RecordsIndexPage({ } let manifests: Awaited>; + let connectorIcons: Awaited>; let page: Awaited>; try { - const [pageResult, connectorManifests] = await Promise.all([fetchSourcesPage(pageState), listConnectorManifests()]); + const [pageResult, connectorManifests] = await Promise.all([ + fetchSourcesPage(pageState), + listConnectorManifests(), + ]); page = pageResult; manifests = connectorManifests; + connectorIcons = await buildConnectorIconMap(manifests, []); } catch (err) { if (err instanceof ReferenceServerUnreachableError) { return ( @@ -145,7 +151,7 @@ export default async function RecordsIndexPage({ const summaries = [...page.items]; const runtimeAdvisory: SourcesRuntimeAdvisory | null = buildSourcesRuntimeAdvisory(page.runtime); - const instances = toSourcesView(summaries, { manifests }); + const instances = toSourcesView(summaries, { connectorIcons, manifests }); // The poller is mounted unconditionally; `running` (derived from any active // run) only selects the fast vs. idle cadence. Named `runningCount` to match // the records-poller mount invariant. diff --git a/apps/console/src/app/(console)/sources/sources-view-model.ts b/apps/console/src/app/(console)/sources/sources-view-model.ts index 548cf568d..f638a255d 100644 --- a/apps/console/src/app/(console)/sources/sources-view-model.ts +++ b/apps/console/src/app/(console)/sources/sources-view-model.ts @@ -28,6 +28,7 @@ */ import type { FusedSourceStatus, StreamManifestEntry } from "@pdpp/display"; +import type { ConnectorIconLike } from "@pdpp/brand-react"; import { deriveSourceDisplayNameFallback, formatConnectorNameForDisplay, @@ -593,7 +594,11 @@ function sourceScopeForSummary(summary: RefConnectorSummary, revoked: boolean): export function toSourceInstanceView( summary: RefConnectorSummary, - options: { fallbackDisambiguator?: string | null; manifests?: readonly SourceManifestLike[] } = {} + options: { + connectorIcons?: Readonly>; + fallbackDisambiguator?: string | null; + manifests?: readonly SourceManifestLike[]; + } = {} ): SourceInstanceView { const connectorId = summary.connector_id; const connectionId = summary.connection_id; @@ -727,7 +732,7 @@ export function toSourceInstanceView( connectorNotInstalled, detailHref: sourceDetailHrefFor(routeId, summary), displayName, - icon: manifest?.icon ?? null, + icon: options.connectorIcons?.[connectorId] ?? manifest?.icon ?? null, id: routeId, sourceScope, isLocalDevicePush, @@ -874,7 +879,10 @@ export function collapseSetupFailedSources(instances: readonly SourceInstanceVie */ export function toSourcesView( summaries: RefConnectorSummary[], - options: { manifests?: readonly SourceManifestLike[] } = {} + options: { + connectorIcons?: Readonly>; + manifests?: readonly SourceManifestLike[]; + } = {} ): SourceInstanceView[] { const visibleSummaries = summaries; const fallbackCountByConnector = new Map(); diff --git a/apps/console/src/app/(console)/sources/sources-view.css b/apps/console/src/app/(console)/sources/sources-view.css index 7fd98d1b4..5582350b8 100644 --- a/apps/console/src/app/(console)/sources/sources-view.css +++ b/apps/console/src/app/(console)/sources/sources-view.css @@ -75,10 +75,6 @@ a.rr-s-item { min-width: 0; } -.rr-s-item__icon { - flex-shrink: 0; -} - .rr-s-item__name { /* The bold human name is the clear primary identity on row 1. */ overflow: hidden; diff --git a/apps/console/src/app/(console)/sources/sources-view.tsx b/apps/console/src/app/(console)/sources/sources-view.tsx index e024d5559..458e0a489 100644 --- a/apps/console/src/app/(console)/sources/sources-view.tsx +++ b/apps/console/src/app/(console)/sources/sources-view.tsx @@ -394,7 +394,7 @@ function InstanceListItem({ const inner = ( <> - + {instance.displayName} {/* Keep list rows comparable: connector kind lives in the selected detail diff --git a/apps/console/src/app/(console)/syncs/[runId]/page.tsx b/apps/console/src/app/(console)/syncs/[runId]/page.tsx index e3e5b64ff..2db5c04ed 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/page.tsx +++ b/apps/console/src/app/(console)/syncs/[runId]/page.tsx @@ -218,11 +218,14 @@ export default async function RunDetailPage({ // Keep the label visible even when this timeline window has no connection id. const connectorLabel = ( <> - + {connectorId ? ( + + ) : null} {connectorId} ); diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx b/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx index c764c521b..c6a8fabbd 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/page.tsx @@ -310,7 +310,7 @@ function RunEndedSurface({

run {statusLabel}

- {connector ? : null} + {connector ? : null} {title}

{description}

@@ -416,7 +416,7 @@ function UnavailableStreamSurface({ connector, runId }: { connector: ConnectorCo {connector ? ( <> - + {connector.displayName} {" "} needs browser control. Keep this page open while the secure browser prepares. The page updates diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx index c222b2e40..504b1c647 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx @@ -1577,6 +1577,7 @@ export function StreamSurface({
- {connectorName ? ( + {connectorName && connectorId ? (

- + {connectorName}

) : null} @@ -5951,7 +5954,7 @@ export function ResolvedSurface({ connector, runId }: { connector: ConnectorCont >

- + {connector ? : null} {subject} is back on it.

@@ -5998,7 +6001,7 @@ function UnsupportedSurface({ > {connector ? (

- + {connector.displayName}

) : null} diff --git a/apps/console/src/app/(console)/syncs/page.tsx b/apps/console/src/app/(console)/syncs/page.tsx index 8069890ba..6d256e46d 100644 --- a/apps/console/src/app/(console)/syncs/page.tsx +++ b/apps/console/src/app/(console)/syncs/page.tsx @@ -37,6 +37,7 @@ import { import { isPagedRequest, parseConnectorSummaryPageState } from "../components/connector-summary-pager.ts"; import { LivePoller } from "../components/live-poller.tsx"; import { ServerUnreachable } from "../components/server-unreachable.tsx"; +import { buildConnectorIconMap } from "../lib/connector-icon-map.ts"; import { ReferenceServerUnreachableError } from "../lib/owner-token.ts"; import { type ListResponse, listConnectorSummaries, listRuns, type RunSummary } from "../lib/ref-client.ts"; import { listConnectorManifests } from "../lib/rs-client.ts"; @@ -140,12 +141,12 @@ export default async function RunsPage({ searchParams }: { searchParams: Promise const pageState = parseConnectorSummaryPageState(params); let runsResult: ListResponse; let connectorsPage: Awaited>; - let connectorManifests: Awaited>; + let connectorIcons: Awaited>; try { - [runsResult, connectorsPage, connectorManifests] = await Promise.all([ + [runsResult, connectorsPage, connectorIcons] = await Promise.all([ listRuns(runListQuery(params)), fetchSyncsConnectorsPage(pageState), - listConnectorManifests().catch(() => []), + listConnectorManifests().catch(() => []).then((manifests) => buildConnectorIconMap(manifests, [])), ]); } catch (err) { if (err instanceof ReferenceServerUnreachableError) { @@ -172,13 +173,6 @@ export default async function RunsPage({ searchParams }: { searchParams: Promise }); const liveRunCount = runsResult.data.filter(isLiveRun).length; - const connectorIcons = Object.fromEntries( - connectorManifests.flatMap((manifest) => [ - [manifest.connector_id, manifest.icon] as const, - ...(manifest.connector_key ? ([[manifest.connector_key, manifest.icon]] as const) : []), - ]) - ); - return ( 0} /> diff --git a/apps/console/src/app/(console)/syncs/syncs-view.tsx b/apps/console/src/app/(console)/syncs/syncs-view.tsx index 873c88507..a24072abd 100644 --- a/apps/console/src/app/(console)/syncs/syncs-view.tsx +++ b/apps/console/src/app/(console)/syncs/syncs-view.tsx @@ -159,7 +159,7 @@ function FailureCardPanel({ card, connectorIcons }: { card: FailureCard; connect

- + {card.name} — {summary.triggerLabel}

{summary.prose}

@@ -211,7 +211,7 @@ function PendingSetupCardPanel({ card, connectorIcons }: { card: PendingSetupCar

- + {card.name} — {card.statusLabel}

{card.what}

@@ -299,7 +299,7 @@ function DuplicateSyncGroupPanel({ group, connectorIcons }: { group: DuplicateSy