diff --git a/scripts/lib/sbom/parser.js b/scripts/lib/sbom/parser.js index 5594da0e9..04a757285 100644 --- a/scripts/lib/sbom/parser.js +++ b/scripts/lib/sbom/parser.js @@ -86,9 +86,9 @@ function normaliseDottedTag(tag) { } /** - * Build the stream-prefixed cache key used in FeedItems.tsx. - * extractReleaseTag() in FeedItems produces: stable-YYYYMMDD, gts-YYYYMMDD, - * etc. + * Build the stream-prefixed cache key read by FirehoseFeed.tsx. + * sbomKeyForRelease() in src/utils/firehoseHelpers.ts looks up + * stable-YYYYMMDD, stable-daily-YYYYMMDD, etc. */ function buildCacheKey(streamPrefix, dateStr) { return `${streamPrefix}-${dateStr}`; diff --git a/scripts/sbom-parser.test.js b/scripts/sbom-parser.test.js index e3b9627e1..ddd70e505 100644 --- a/scripts/sbom-parser.test.js +++ b/scripts/sbom-parser.test.js @@ -87,7 +87,7 @@ test("normaliseDottedTag leaves unrelated streams alone", () => { assert.equal(normaliseDottedTag("gts-20260331"), "gts-20260331"); }); -test("buildCacheKey matches the key FeedItems.tsx derives", () => { +test("buildCacheKey matches the key sbomKeyForRelease derives", () => { assert.equal(buildCacheKey("stable", "20260331"), "stable-20260331"); }); diff --git a/src/components/FeedItems.module.css b/src/components/FeedItems.module.css deleted file mode 100644 index 57dade0ee..000000000 --- a/src/components/FeedItems.module.css +++ /dev/null @@ -1,499 +0,0 @@ -.feedContainer { - margin-bottom: 2rem; - border: 1px solid var(--ifm-color-emphasis-300); - border-radius: 8px; - padding: 1.5rem; - background: var(--ifm-background-color); -} - -.feedTitle { - margin-top: 0; - margin-bottom: 1rem; - color: var(--ifm-color-emphasis-800); - font-size: 1.25rem; - font-weight: 600; - border-bottom: 2px solid var(--ifm-color-primary); - padding-bottom: 0.5rem; -} - -.feedList { - list-style: none; - padding: 0; - margin: 0; -} - -.feedItem { - margin-bottom: 1rem; - border: 1px solid var(--ifm-color-emphasis-200); - border-radius: 6px; - background: var(--ifm-color-emphasis-100); - transition: all 0.2s ease; -} - -.feedItem:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - transform: translateY(-2px); - border-color: var(--ifm-color-primary); -} - -.feedItemLink { - display: block; - padding: 1rem; - text-decoration: none; - color: inherit; - border-radius: 6px; -} - -.feedItemLink:hover { - text-decoration: none; - color: inherit; -} - -/* For non-clickable items, add padding directly to content */ -.feedItem > .feedItemContent { - padding: 1rem; -} - -.feedItem:last-child { - margin-bottom: 0; -} - -.feedItemContent { - display: flex; - flex-direction: column; - gap: 0.5rem; - position: relative; - overflow: hidden; -} - -.cardArtwork { - position: absolute; - right: 0.25rem; - bottom: 0.25rem; - width: 132px; - height: 132px; - background-size: contain; - background-repeat: no-repeat; - background-position: bottom right; - pointer-events: none; - z-index: 0; -} - -.cardArtworkMain { - background-image: - linear-gradient(135deg, rgba(0, 87, 184, 0.03), rgba(0, 87, 184, 0.09)), - url("/img/characters/bluefin-small.webp"); - opacity: 0.16; -} - -.feedItemHeader { - display: flex; - align-items: baseline; - gap: 0.6rem; - flex-wrap: wrap; -} - -.contributorsRow { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - position: relative; - z-index: 1; -} - -.contributorsLabel { - font-size: 0.76rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--ifm-color-emphasis-600); - font-weight: 700; -} - -.contributorsAvatars { - display: flex; - align-items: center; -} - -.contributorAvatar { - width: 24px; - height: 24px; - border-radius: 999px; - overflow: hidden; - border: 1px solid var(--ifm-color-emphasis-300); - background: var(--ifm-color-emphasis-200); - margin-left: -6px; -} - -.contributorAvatar:first-child { - margin-left: 0; -} - -.contributorAvatarImage { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -.contributorAvatarFallback { - width: 100%; - height: 100%; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 0.62rem; - font-weight: 700; - color: var(--ifm-color-emphasis-700); -} - -.contributorOverflow { - margin-left: 0.35rem; - font-size: 0.72rem; - color: var(--ifm-color-emphasis-700); - font-weight: 600; -} - -.feedLabel { - display: inline-block; - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.05em; - padding: 0.2em 0.55em; - border-radius: 3px; - white-space: nowrap; - flex-shrink: 0; -} - -.feedLabelBluefin { - background: #0057b8; - color: #ffffff; -} - -.copyButton { - display: inline-flex; - align-items: center; - justify-content: center; - background: none; - border: 1px solid var(--ifm-color-emphasis-300); - border-radius: 4px; - padding: 0.1em 0.4em; - font-size: 0.8rem; - color: var(--ifm-color-emphasis-500); - cursor: pointer; - opacity: 0; - transition: - opacity 0.15s ease, - color 0.15s ease, - border-color 0.15s ease; - flex-shrink: 0; - line-height: 1; - vertical-align: middle; -} - -.feedItem:hover .copyButton, -.copyButton:focus-visible { - opacity: 1; -} - -.copyButton:hover { - color: var(--ifm-color-primary); - border-color: var(--ifm-color-primary); - background: none; -} - -.feedItemTitle { - margin: 0; - font-size: 1.25rem; - font-weight: 500; - color: var(--ifm-color-primary); -} - -.feedItemDate { - font-size: 0.95rem; - color: var(--ifm-color-emphasis-600); - font-style: italic; - position: relative; - z-index: 1; -} - -.feedItemAuthor { - font-size: 0.95rem; - color: var(--ifm-color-emphasis-700); - font-weight: 500; -} - -.feedItemDescription { - font-size: 0.95rem; - color: var(--ifm-color-emphasis-700); - line-height: 1.4; - margin-top: 0.5rem; -} - -.feedItemDescription p { - margin: 0.5rem 0; -} - -.feedItemDescription p:first-child { - margin-top: 0; -} - -.feedItemDescription p:last-child { - margin-bottom: 0; -} - -.noItems { - color: var(--ifm-color-emphasis-600); - font-style: italic; - text-align: center; - padding: 1rem; -} - -.error { - color: var(--ifm-color-danger); - font-weight: 500; - text-align: center; - padding: 1rem; - background: var(--ifm-color-danger-contrast-background); - border-radius: 4px; - border: 1px solid var(--ifm-color-danger-contrast-border); -} - -.executiveSummary { - font-size: 0.85rem; - color: var(--ifm-color-emphasis-600); - line-height: 1.3; - margin-top: 0.3rem; - padding: 0.4rem 0.6rem; - background: var(--ifm-color-emphasis-100); - border-left: 3px solid var(--ifm-color-primary-lighter); - border-radius: 0 4px 4px 0; - list-style-type: none; -} - -.versionChange { - display: list-item; - white-space: nowrap; -} - -.versionChange strong { - color: var(--ifm-color-emphasis-800); - font-weight: 500; -} - -.commitList { - display: flex; - flex-direction: column; - gap: 0.25rem; - font-size: 0.82rem; - font-family: var(--ifm-font-family-monospace); - background: var(--ifm-color-emphasis-100); - border-left: 3px solid var(--ifm-color-emphasis-300); - border-radius: 0 4px 4px 0; - padding: 0.4rem 0.6rem; -} - -.commitRow { - display: flex; - align-items: baseline; - gap: 0.5rem; - flex-wrap: wrap; - line-height: 1.4; -} - -.commitHash { - font-weight: 600; - color: var(--ifm-color-primary); - white-space: nowrap; - flex-shrink: 0; - text-decoration: none; -} - -.commitHash:hover { - text-decoration: underline; -} - -.commitSubject { - color: var(--ifm-color-emphasis-800); - flex: 1; - min-width: 0; - font-family: var(--ifm-font-family-base); - font-size: 0.82rem; -} - -.commitSubject a { - color: var(--ifm-color-primary); -} - -.commitAuthor { - color: var(--ifm-color-emphasis-500); - white-space: nowrap; - flex-shrink: 0; - font-family: var(--ifm-font-family-base); - font-size: 0.78rem; -} - -.releaseSummaryBlock { - background: var(--ifm-color-emphasis-100); - border-left: 3px solid var(--ifm-color-primary); - border-radius: 0 4px 4px 0; - padding: 0.45rem 0.65rem; - position: relative; - z-index: 1; -} - -.releaseSummaryTitle { - font-size: 0.8rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.03em; - margin-bottom: 0.25rem; - color: var(--ifm-color-primary); -} - -.releaseSummaryGrid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 0.3rem 0.65rem; - font-size: 0.82rem; - color: var(--ifm-color-emphasis-800); -} - -.headsUpBlock { - background: color-mix(in srgb, var(--ifm-color-warning) 22%, transparent); - border-left: 4px solid var(--ifm-color-warning-dark); - box-shadow: inset 0 0 0 1px - color-mix(in srgb, var(--ifm-color-warning) 30%, transparent); - border-radius: 0 4px 4px 0; - padding: 0.45rem 0.65rem; - position: relative; - z-index: 1; -} - -.headsUpTitle { - font-size: 0.82rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.03em; - margin-bottom: 0.25rem; - color: color-mix(in srgb, var(--ifm-color-warning-darkest) 75%, black); -} - -.headsUpList { - margin: 0; - padding-left: 1rem; - font-size: 0.8rem; - color: var(--ifm-color-emphasis-800); -} - -.headsUpList li { - margin: 0.1rem 0; -} - -.supplyChainBlock { - background: var(--ifm-color-emphasis-100); - border-left: 3px solid #157347; - border-radius: 0 4px 4px 0; - padding: 0.45rem 0.65rem; - position: relative; - z-index: 1; -} - -.supplyChainTitle { - font-size: 0.8rem; - font-weight: 700; - color: #157347; - margin-bottom: 0.2rem; - text-transform: uppercase; - letter-spacing: 0.03em; -} - -.supplyChainList { - margin: 0; - padding-left: 1rem; - font-size: 0.8rem; - color: var(--ifm-color-emphasis-800); -} - -.supplyChainList li { - margin: 0.1rem 0; -} - -.supplyChainList code { - font-size: 0.78rem; -} - -.supplyChainEmpty { - margin: 0; - font-size: 0.8rem; - color: var(--ifm-color-emphasis-600); - font-style: italic; -} - -.supplyChainLinks { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; - margin-top: 0.35rem; -} - -.actionLinkButton { - appearance: none; - border: 1px solid var(--ifm-color-emphasis-300); - border-radius: 4px; - background: var(--ifm-background-color); - padding: 0.15rem 0.45rem; - font-size: 0.8rem; - color: var(--ifm-color-primary); - cursor: pointer; -} - -.actionLinkButton:hover { - border-color: var(--ifm-color-primary); - background: var(--ifm-color-primary-lightest); - color: var(--ifm-color-primary-dark); -} - -.contributorAvatarLink { - display: inline-flex; - text-decoration: none; -} - -.contributorAvatarLink:hover .contributorAvatar, -.contributorAvatarLink:focus-visible .contributorAvatar { - border-color: var(--ifm-color-primary); - transform: translateY(-1px); -} - -.contributorAvatar { - transition: - border-color 0.15s ease, - transform 0.15s ease; -} - -/* Responsive design */ -@media (max-width: 768px) { - .feedContainer { - padding: 1rem; - } - - .feedItem { - padding: 0.75rem; - } - - .feedItemTitle { - font-size: 0.9rem; - } - - .feedItemDate, - .feedItemAuthor, - .feedItemDescription { - font-size: 0.8rem; - } - - .executiveSummary { - font-size: 0.75rem; - padding: 0.3rem 0.5rem; - } -} diff --git a/src/components/FeedItems.tsx b/src/components/FeedItems.tsx deleted file mode 100644 index ef6819013..000000000 --- a/src/components/FeedItems.tsx +++ /dev/null @@ -1,683 +0,0 @@ -import React, { useState, useCallback, useMemo } from "react"; -import useStoredFeed from "@theme/useStoredFeed"; -import styles from "./FeedItems.module.css"; -import sbomData from "@site/static/data/sbom-attestations-frontend.json"; -import firehoseAppsData from "@site/static/data/firehose-apps.json"; -import type { SbomAttestationsData } from "../types/sbom"; -import type { FirehoseApp, FirehoseData } from "../types/firehose"; -import { sanitizeHtml } from "../utils/sanitizeHtml"; -import { - getSupplyChainLinks, - extractVersionSummary, - getSbomRelease, - getPreviousRelease, - computePackageDiff, - extractMajorVersionBumps, - buildReleaseSummary, -} from "../utils/sbomRelease"; -import type { - SupplyChainLinks, - VersionChange, - MajorVersionBump, - ReleaseSummary, -} from "../utils/sbomRelease"; - -// Cast the imported JSON to its proper type -const sbomCache = sbomData as unknown as SbomAttestationsData; - -// Small inline copy button — renders a clipboard icon, shows a tick for 1.5s after copy -const CopyButton: React.FC<{ text: string }> = ({ text }) => { - const [copied, setCopied] = useState(false); - const handleCopy = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - navigator.clipboard.writeText(text).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }, - [text], - ); - return ( - - ); -}; - -interface FeedItemsProps { - feedId: string; - title: string; - maxItems?: number; - showDescription?: boolean; - filter?: (item: FeedItem) => boolean; -} - -interface CombinedFeedItemsProps { - feeds: Array<{ - feedId: string; - label: string; - filter?: (item: FeedItem) => boolean; - }>; - title: string; - maxItems?: number; - showDescription?: boolean; -} - -// Local type definitions that match src/types/theme.d.ts -// These must be kept in sync with the module declaration -interface FeedItem { - title: string; - link: - | string - | { href?: string } - | Array<{ - href?: string; - rel?: string; - $?: { href?: string; type?: string }; - }>; - description?: string; - pubDate?: string; - updated?: string; - guid?: string; - id?: string; - author?: string | { name?: string }; - content?: { value?: string } | string; -} - -interface ParsedFeed { - // RSS feed structure - rss?: { - channel?: { - item?: FeedItem | FeedItem[]; - }; - }; - // Alternative RSS structure - channel?: { - item?: FeedItem | FeedItem[]; - }; - // Atom feed structure - feed?: { - entry?: FeedItem | FeedItem[]; - }; -} - -// Helper function to format date in long form -const formatLongDate = (dateString: string): string => { - const date = new Date(dateString); - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "long", - day: "numeric", - }); -}; - -const ActionLinkButton: React.FC<{ label: string; url: string }> = ({ - label, - url, -}) => ( - { - e.preventDefault(); - e.stopPropagation(); - window.open(url, "_blank", "noopener,noreferrer"); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - window.open(url, "_blank", "noopener,noreferrer"); - } - }} - > - {label} - -); - -// ─── Firehose: NVIDIA version only (not available in SBOM) ─────────────────── - -const FIREHOSE_APP_ID_BY_FEED_ID: Record = { - bluefinReleases: "bluefin-os-stable", -}; - -const FIREHOSE_OS_APP_LOOKUP: Record = (() => { - const firehose = firehoseAppsData as unknown as FirehoseData; - const lookup: Record = {}; - for (const app of firehose.apps || []) { - if (app.packageType === "os") lookup[app.id] = app; - } - return lookup; -})(); - -const _normalizeReleaseDate = (value?: string | null): string | null => { - if (!value) return null; - const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/); - if (!match) return null; - return `${match[1]}-${match[2]}-${match[3]}`; -}; - -/** - * Get NVIDIA driver version from firehose data (not present in SBOM). - * NVIDIA ships as an akmod built outside the image and is not in Syft scans. - */ -const getNvidiaVersionFromFirehose = ( - feedId: string, - _title: string, -): string | null => { - const appId = FIREHOSE_APP_ID_BY_FEED_ID[feedId]; - if (!appId) return null; - const app = FIREHOSE_OS_APP_LOOKUP[appId]; - if (!app?.osInfo?.majorPackages) return null; - const majorPackages = app.osInfo.majorPackages; - return ( - majorPackages.NVIDIA || majorPackages.Nvidia || majorPackages.nvidia || null - ); -}; - -// ─── Feed parsing helpers ──────────────────────────────────────────────────── - -// Helper function to determine if a feed should show executive summaries -const isReleaseFeed = (feedId: string): boolean => { - return feedId === "bluefinReleases"; -}; - -// Helper to extract items from a raw parsed feed -const extractItems = (feedData: ParsedFeed): FeedItem[] => { - if (feedData?.rss?.channel?.item) { - return Array.isArray(feedData.rss.channel.item) - ? feedData.rss.channel.item - : [feedData.rss.channel.item]; - } else if (feedData?.channel?.item) { - return Array.isArray(feedData.channel.item) - ? feedData.channel.item - : [feedData.channel.item]; - } else if (feedData?.feed?.entry) { - return Array.isArray(feedData.feed.entry) - ? feedData.feed.entry - : [feedData.feed.entry]; - } - return []; -}; - -// Helper to resolve the URL for an item given its feedId -const resolveItemLink = (item: FeedItem, feedId: string): string => { - let itemLink = ""; - if (typeof item.link === "string" && item.link) { - itemLink = item.link; - } else if (item.link && typeof item.link === "object") { - if (Array.isArray(item.link)) { - const htmlLink = - item.link.find((l) => l.$ && l.$.type === "text/html") || - item.link.find((l) => l.rel === "alternate") || - item.link[0]; - itemLink = htmlLink?.href || htmlLink?.$?.href || ""; - } else { - itemLink = (item.link as { href?: string }).href || ""; - } - } - if (!itemLink && item.id && typeof item.id === "string") { - const idMatch = item.id.match( - /^tag:github\.com,\d+:Repository\/(\d+)\/(.+)$/, - ); - if (idMatch) { - const [, , tag] = idMatch; - if (feedId === "bluefinReleases") { - itemLink = `https://github.com/ublue-os/bluefin/releases/tag/${tag}`; - } - } else { - const discussionMatch = item.id.match( - /^tag:github\.com,\d+:Discussion\/(\d+)$/, - ); - if ( - discussionMatch && - (feedId === "bluefinDiscussions" || feedId === "bluefinAnnouncements") - ) { - itemLink = `https://github.com/ublue-os/bluefin/discussions/${discussionMatch[1]}`; - } - } - } - return itemLink; -}; - -// Helper function to format release titles for better readability -const formatReleaseTitle = (title: string, feedId: string): string => { - if (feedId === "bluefinReleases") { - // For stable releases: Keep "stable-" prefix, strip ": Stable" text, simplify Fedora version - if (title.startsWith("stable-")) { - return title.replace( - /^(stable-[^:]+): Stable \(F(\d+)\.\d+, (#[^)]+)\)$/, - "$1 (F$2 $3)", - ); - } - } - return title; -}; - -// ─── Derived release data from SBOM ───────────────────────────────────────── - -interface DerivedReleaseData { - versionSummary: VersionChange[]; - supplyChainLinks: SupplyChainLinks; - majorVersionBumps: MajorVersionBump[]; - releaseSummary: ReleaseSummary | null; -} - -/** - * Compute all release-specific derived data from the SBOM cache. - * This replaces the previous firehose-based computation for package diffs. - */ -const computeDerivedReleaseData = ( - feedId: string, - title: string, -): DerivedReleaseData => { - const nvidiaVersion = getNvidiaVersionFromFirehose(feedId, title); - const versionSummary = extractVersionSummary( - sbomCache, - title, - feedId, - nvidiaVersion, - ); - const supplyChainLinks = getSupplyChainLinks(sbomCache, title, feedId); - - // Compute package diff from consecutive SBOM releases - const currentRelease = getSbomRelease(sbomCache, feedId, title); - const previousRelease = getPreviousRelease(sbomCache, feedId, title); - const diff = computePackageDiff( - currentRelease?.packageVersions ?? null, - previousRelease?.packageVersions ?? null, - ); - const majorVersionBumps = extractMajorVersionBumps(diff); - const releaseSummary = buildReleaseSummary(diff, majorVersionBumps); - - return { - versionSummary, - supplyChainLinks, - majorVersionBumps, - releaseSummary, - }; -}; - -// ─── FeedItems Component ───────────────────────────────────────────────────── - -const FeedItems: React.FC = ({ - feedId, - title, - maxItems = 5, - showDescription = false, - filter, -}) => { - try { - const feedData: ParsedFeed | null = useStoredFeed(feedId); - - let items: FeedItem[] = feedData ? extractItems(feedData) : []; - - // Apply filter if provided - if (filter) { - items = items.filter(filter); - } - - // Limit items to maxItems - const displayItems = items.slice(0, maxItems); - - if (displayItems.length === 0) { - return ( -
-

{title}

-

No items available

-
- ); - } - - return ( -
-

{title}

- -
- ); - } catch (error) { - console.error(`Error loading feed ${feedId}:`, error); - return ( -
-

{title}

-

Error loading feed data

-
- ); - } -}; - -// ─── CombinedFeedItems Component ───────────────────────────────────────────── - -// CombinedFeedItems — merges multiple feeds into one chronological list. -// Each feed entry is tagged with a label badge so users can tell releases apart. -const CombinedFeedItems: React.FC = ({ - feeds, - title, - maxItems = 20, - showDescription = false, -}) => { - try { - // Call useStoredFeed for each feed (hooks must be called unconditionally at top level) - const feedDataFirst: ParsedFeed | null = useStoredFeed( - feeds[0]?.feedId ?? "bluefinReleases", - ); - const feedDataSecond: ParsedFeed | null = useStoredFeed( - feeds[1]?.feedId ?? "bluefinReleases", - ); - const rawFeeds = [feedDataFirst, feedDataSecond]; - - // Tag each item with its source feedId + label, then merge - type TaggedItem = FeedItem & { _feedId: string; _label: string }; - const tagged: TaggedItem[] = feeds.flatMap((feedMeta, i) => { - let items = rawFeeds[i] ? extractItems(rawFeeds[i]!) : []; - if (feedMeta.filter) items = items.filter(feedMeta.filter); - return items.map((item) => ({ - ...item, - _feedId: feedMeta.feedId, - _label: feedMeta.label, - })); - }); - - // Sort newest-first by date - tagged.sort((a, b) => { - const da = new Date(a.pubDate || a.updated || 0).getTime(); - const db = new Date(b.pubDate || b.updated || 0).getTime(); - return db - da; - }); - - const displayItems = tagged.slice(0, maxItems); - - // Pre-compute all expensive per-item derived data in a single useMemo. - const derivedItems = useMemo( - () => - displayItems.map((item) => { - const itemDescription = - item.description || - (typeof item.content === "object" - ? item.content?.value - : item.content); - const isRelease = isReleaseFeed(item._feedId); - const displayTitle = formatReleaseTitle(item.title, item._feedId); - - // All release data now comes from SBOM cache directly - const derived = isRelease - ? computeDerivedReleaseData(item._feedId, item.title) - : null; - - return { - itemDescription, - isRelease, - displayTitle, - supplyChainLinks: - derived?.supplyChainLinks ?? - getSupplyChainLinks(sbomCache, displayTitle, item._feedId), - majorVersionBumps: derived?.majorVersionBumps ?? [], - releaseSummary: derived?.releaseSummary ?? null, - }; - }), - [displayItems.map((i) => i.guid || i.id).join(",")], - ); - - if (displayItems.length === 0) { - return ( -
-

{title}

-

No items available

-
- ); - } - - return ( -
-

{title}

-
    - {displayItems.map((item, index) => { - const itemLink = resolveItemLink(item, item._feedId); - const itemDate = item.pubDate || item.updated; - const itemId = item.guid || item.id || itemLink || index; - const { - itemDescription, - isRelease, - displayTitle, - supplyChainLinks, - majorVersionBumps, - releaseSummary, - } = derivedItems[index]; - - const inner = ( -
    -
-
- ); - } catch (error) { - console.error("Error loading combined feeds:", error); - return ( -
-

{title}

-

Error loading feed data

-
- ); - } -}; - -export { CombinedFeedItems }; -export default FeedItems; diff --git a/src/config/packageConfig.ts b/src/config/packageConfig.ts deleted file mode 100644 index 19ede1450..000000000 --- a/src/config/packageConfig.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Central configuration for package version tracking. - * - * This configuration defines which packages to track in changelog cards - * and how to extract their versions from changelog content. - * - * To add a new package: - * 1. Add a new entry to the PACKAGE_PATTERNS array - * 2. Specify the display name and regex pattern for version extraction - * - * To remove a package: - * 1. Remove its entry from PACKAGE_PATTERNS array - * - * The patterns match HTML table content in changelog feeds: - * - Standard format: PackageNameversion - * - "All Images" format: 🔄packagenameoldversionnewversion - */ - -export interface PackagePattern { - /** Display name for the package (e.g., "Kernel", "GNOME") */ - name: string; - /** Regex pattern to extract version from changelog HTML content */ - pattern: RegExp; - /** For "All Images" format, the pattern to extract both old and new versions */ - changePattern?: RegExp; -} - -export const PACKAGE_PATTERNS: PackagePattern[] = [ - { - name: "Kernel", - pattern: /Kernel<\/strong><\/td>\s*([^<]+)/, - }, - { - name: "HWE Kernel", - pattern: /HWE Kernel<\/strong><\/td>\s*([^<]+)/, - }, - { - name: "GNOME", - pattern: /(?:Gnome|GNOME)<\/strong><\/td>\s*([^<]+)/, - }, - { - name: "Mesa", - pattern: /Mesa<\/strong><\/td>\s*([^<]+)/, - }, - { - name: "Podman", - pattern: /Podman<\/strong><\/td>\s*([^<]+)/, - }, - { - name: "NVIDIA", - pattern: /Nvidia<\/strong><\/td>\s*([^<]+)/, - }, - { - name: "Docker", - pattern: /Docker<\/strong><\/td>\s*([^<]+)/, - }, - { - name: "systemd", - pattern: /🔄<\/td>\s*systemd<\/td>\s*[^<]*<\/td>\s*([^<]+)/, - changePattern: - /🔄<\/td>\s*systemd<\/td>\s*([^<]+)<\/td>\s*([^<]+)/, - }, - { - name: "bootc", - pattern: /🔄<\/td>\s*bootc<\/td>\s*[^<]*<\/td>\s*([^<]+)/, - changePattern: - /🔄<\/td>\s*bootc<\/td>\s*([^<]+)<\/td>\s*([^<]+)/, - }, -]; - -/** - * Extracts package version from content using the provided pattern. - * Handles both upgrade arrows (old ➡️ new) and static versions. - * - * @param content - HTML content from changelog - * @param pattern - Regex pattern to match package version - * @returns Latest version string or null if not found - */ -export function extractPackageVersion( - content: string, - pattern: RegExp, -): string | null { - const match = content.match(pattern); - if (match) { - const versionText = match[1].trim(); - // Extract the latest version if there's an arrow (6.14.11-300 ➡️ 6.15.9-201) - return versionText.includes("➡️") - ? versionText.split("➡️")[1].trim() - : versionText; - } - return null; -} - -/** - * Extracts version change information for FeedItems component. - * Only returns changes that have upgrade arrows, not static versions. - * - * @param content - HTML content from changelog - * @param packageConfig - Package configuration with patterns - * @returns Version change info or null if no upgrade found - */ -export function extractVersionChange( - content: string, - packageConfig: PackagePattern, -): { name: string; change: string } | null { - // For packages with changePattern (systemd, bootc), use the special pattern - if (packageConfig.changePattern) { - const match = content.match(packageConfig.changePattern); - if (match) { - const fromVersion = match[1].trim(); - const toVersion = match[2].trim(); - return { - name: packageConfig.name, - change: `${fromVersion} → ${toVersion}`, - }; - } - return null; - } - - // For standard format patterns, look for upgrade arrows - const match = content.match(packageConfig.pattern); - if (match) { - const versionText = match[1].trim(); - if (versionText.includes("➡️")) { - // Only show upgrades (with arrow), not static versions - const [fromVersion, toVersion] = versionText - .split("➡️") - .map((v) => v.trim()); - return { - name: packageConfig.name, - change: `${fromVersion} → ${toVersion}`, - }; - } - } - - return null; -} diff --git a/src/lib/sbom-adapter.ts b/src/lib/sbom-adapter.ts deleted file mode 100644 index 3e6e3e606..000000000 --- a/src/lib/sbom-adapter.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * SBOM-first data adapter. - * - * Single entry point for components that need release, version, or package - * data. Reads from sbom-attestations-frontend.json and exposes a clean API - * so that components never import raw JSON directly. - * - * Design goals: - * 1. One canonical import for release data across the site - * 2. Lazy loading — the 104 KB SBOM payload is parsed on first access - * 3. Typed interfaces that match what components actually render - * 4. Adapter layer isolates components from upstream SBOM schema changes - */ - -// ── Types ──────────────────────────────────────────────────────────────────── - -export interface PackageVersions { - kernel?: string | null; - gnome?: string | null; - mesa?: string | null; - podman?: string | null; - systemd?: string | null; - [key: string]: string | null | undefined; -} - -export interface Attestation { - present: boolean | null; - verified: boolean; - predicateType: string | null; - slsaType?: string; - errorKind?: string; - error: string | null; -} - -export interface Release { - tag: string; - imageRef: string; - digest: string | null; - attestation: Attestation; - packageVersions: PackageVersions | null; - checkedAt: string; -} - -export interface Stream { - id: string; - label: string; - org: string; - package: string; - streamPrefix: string; - keyRepo: string; - keyless: boolean; - releases: Record; -} - -export interface SbomData { - generatedAt: string; - lookbackDays: number; - maxReleasesPerStream: number; - streams: Record; -} - -export interface LatestRelease { - stream: string; - tag: string; - date: string; - kernel: string; - gnome: string; - mesa: string; - digest: string; - imageRef: string; - allVersions: PackageVersions; -} - -export interface StreamSummary { - id: string; - label: string; - releaseCount: number; - latestTag: string | null; - latestDate: string | null; -} - -// ── Lazy loader ────────────────────────────────────────────────────────────── - -let _cache: SbomData | null = null; - -function load(): SbomData { - if (_cache) return _cache; - // eslint-disable-next-line @typescript-eslint/no-require-imports - _cache = require("@site/static/data/sbom-attestations-frontend.json") as SbomData; - return _cache; -} - -// ── Public API ─────────────────────────────────────────────────────────────── - -/** Get the raw SBOM data (lazy-loaded). */ -export function getSbomData(): SbomData { - return load(); -} - -/** List all stream IDs. */ -export function getStreamIds(): string[] { - return Object.keys(load().streams); -} - -/** Get a single stream by ID. */ -export function getStream(streamId: string): Stream | undefined { - return load().streams[streamId]; -} - -/** Get the latest release for a stream. Returns null if stream has no releases. */ -export function getLatestRelease(streamId: string): LatestRelease | null { - const stream = getStream(streamId); - if (!stream) return null; - - const entries = Object.entries(stream.releases); - if (entries.length === 0) return null; - - // Releases are ordered newest-first by convention - const [tag, release] = entries[0]; - const pv = release.packageVersions || {}; - - return { - stream: streamId, - tag, - date: release.checkedAt, - kernel: pv.kernel ?? "unknown", - gnome: pv.gnome ?? "unknown", - mesa: pv.mesa ?? "unknown", - digest: release.digest ?? "unknown", - imageRef: release.imageRef, - allVersions: pv, - }; -} - -/** Get latest releases for all streams. Streams with no releases are skipped. */ -export function getAllLatestReleases(): LatestRelease[] { - return getStreamIds() - .map(getLatestRelease) - .filter((r): r is LatestRelease => r !== null); -} - -/** Get a summary of all streams (id, label, release count, latest tag). */ -export function getStreamSummaries(): StreamSummary[] { - const data = load(); - return Object.values(data.streams).map((s) => { - const tags = Object.keys(s.releases); - return { - id: s.id, - label: s.label, - releaseCount: tags.length, - latestTag: tags[0] ?? null, - latestDate: tags[0] ? s.releases[tags[0]].checkedAt : null, - }; - }); -} - -/** Get all releases for a stream, sorted newest-first. */ -export function getReleases(streamId: string): Release[] { - const stream = getStream(streamId); - if (!stream) return []; - return Object.values(stream.releases); -} - -/** Check SBOM freshness. Returns hours since generation. */ -export function getSbomAgeHours(): number { - const gen = new Date(load().generatedAt); - return (Date.now() - gen.getTime()) / (1000 * 60 * 60); -} - -/** - * Find which streams contain a specific package version. - * Useful for "which images ship kernel X.Y.Z?" queries. - */ -export function findStreamsByPackageVersion( - packageName: string, - version: string, -): string[] { - const data = load(); - const matches: string[] = []; - for (const [streamId, stream] of Object.entries(data.streams)) { - for (const release of Object.values(stream.releases)) { - if (release.packageVersions?.[packageName] === version) { - matches.push(streamId); - break; - } - } - } - return matches; -} diff --git a/src/lib/sbom-feed-adapter.ts b/src/lib/sbom-feed-adapter.ts deleted file mode 100644 index 2e283d4c1..000000000 --- a/src/lib/sbom-feed-adapter.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * SBOM → Feed compatibility adapter. - * - * Bridges the gap between the SBOM data model (streams/releases/packages) - * and the legacy feed card format that FeedItems.tsx and FirehoseFeed.tsx - * currently render. This allows a gradual migration: components can switch - * to sbom-feed-adapter imports without rewriting their rendering logic. - * - * Once components are fully migrated, this adapter can be removed and - * components can consume sbom-adapter.ts directly. - */ - -import type { Release } from "./sbom-adapter"; -import { getStream, getStreamIds, getSbomData } from "./sbom-adapter"; - -// ── Feed-compatible types ──────────────────────────────────────────────────── - -export interface VersionChip { - label: string; - value: string; -} - -export interface FeedCard { - id: string; - title: string; - stream: string; - tag: string; - date: string; - imageRef: string; - digest: string; - chips: VersionChip[]; - kind: "release"; -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -const CHIP_KEYS = ["kernel", "gnome", "mesa", "podman", "systemd"] as const; - -function releaseToCard(streamId: string, tag: string, release: Release): FeedCard { - const pv = release.packageVersions || {}; - const chips: VersionChip[] = CHIP_KEYS - .filter((k) => pv[k]) - .map((k) => ({ label: k.charAt(0).toUpperCase() + k.slice(1), value: pv[k]! })); - - const streamLabel = streamId - .replace(/-/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()); - - return { - id: `${streamId}/${tag}`, - title: `${streamLabel} — ${tag}`, - stream: streamId, - tag, - date: release.checkedAt, - imageRef: release.imageRef, - digest: release.digest ?? "", - chips, - kind: "release", - }; -} - -// ── Public API ─────────────────────────────────────────────────────────────── - -/** - * Get all SBOM releases as feed cards, newest-first across all streams. - * Optionally filter by stream prefix (e.g., "bluefin-stable"). - */ -export function getSbomFeedCards(streamFilter?: string): FeedCard[] { - const ids = streamFilter - ? getStreamIds().filter((id) => id.startsWith(streamFilter)) - : getStreamIds(); - - const cards: FeedCard[] = []; - for (const streamId of ids) { - const stream = getStream(streamId); - if (!stream) continue; - for (const [tag, release] of Object.entries(stream.releases)) { - cards.push(releaseToCard(streamId, tag, release)); - } - } - - // Sort newest-first by date - cards.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - return cards; -} - -/** - * Get the latest release card per stream (one card per stream). - * Useful for the "pinned" cards at the top of the changelogs page. - */ -export function getPinnedCards(streamIds?: string[]): FeedCard[] { - const ids = streamIds ?? getStreamIds(); - const cards: FeedCard[] = []; - - for (const streamId of ids) { - const stream = getStream(streamId); - if (!stream) continue; - const tags = Object.keys(stream.releases); - if (tags.length === 0) continue; - const tag = tags[0]; - cards.push(releaseToCard(streamId, tag, stream.releases[tag])); - } - - return cards; -} - -/** - * Get the SBOM generation timestamp as an ISO string. - */ -export function getSbomTimestamp(): string { - return getSbomData().generatedAt; -} diff --git a/src/types/sbom.ts b/src/types/sbom.ts index c99a1a956..12e4517cc 100644 --- a/src/types/sbom.ts +++ b/src/types/sbom.ts @@ -1,7 +1,7 @@ /** * TypeScript interfaces for the SBOM attestation cache. * Written to static/data/sbom-attestations.json by scripts/fetch-github-sbom.js - * and consumed by ImageCards.tsx and FeedItems.tsx. + * and consumed by ImageProductSection.tsx and FirehoseFeed.tsx. */ export interface AttestationResult { @@ -97,8 +97,8 @@ export interface SbomStream { keyless: boolean; /** * Map of cache key → release attestation result. - * Cache keys match the format produced by extractReleaseTag() in utils/sbomRelease.ts: - * stable-YYYYMMDD, gts-YYYYMMDD, etc. + * Cache keys match the format produced by sbomKeyForRelease() in + * utils/firehoseHelpers.ts: stable-YYYYMMDD, stable-daily-YYYYMMDD, etc. */ releases: Record; } diff --git a/src/types/theme.d.ts b/src/types/theme.d.ts deleted file mode 100644 index 8f7154e83..000000000 --- a/src/types/theme.d.ts +++ /dev/null @@ -1,40 +0,0 @@ -declare module "@theme/useStoredFeed" { - interface FeedItem { - title: string; - link: - | string - | { href?: string } - | Array<{ - href?: string; - rel?: string; - $?: { href?: string; type?: string }; - }>; - description?: string; - pubDate?: string; - updated?: string; - guid?: string; - id?: string; - author?: string | { name?: string }; - content?: { value?: string } | string; - } - - interface ParsedFeed { - // RSS feed structure - rss?: { - channel?: { - item?: FeedItem | FeedItem[]; - }; - }; - // Alternative RSS structure - channel?: { - item?: FeedItem | FeedItem[]; - }; - // Atom feed structure - feed?: { - entry?: FeedItem | FeedItem[]; - }; - } - - function useStoredFeed(key: string): ParsedFeed | null; - export default useStoredFeed; -} diff --git a/src/utils/sbomRelease.ts b/src/utils/sbomRelease.ts deleted file mode 100644 index 7a07f4080..000000000 --- a/src/utils/sbomRelease.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * Utility functions for extracting release data from the SBOM attestation cache. - * - * These helpers bridge between RSS feed item titles (which contain release tags - * like "stable-20260501") and the structured SBOM data that contains package - * versions, attestation state, and supply chain information. - */ - -import type { - SbomAttestationsData, - SbomRelease, - PackageVersions, -} from "../types/sbom"; - -// ─── Constants ─────────────────────────────────────────────────────────────── - -/** Maps FeedItems feedId to the corresponding SBOM stream key. */ -export const SBOM_STREAM_BY_FEED_ID: Record = { - bluefinReleases: "bluefin-stable", -}; - -// ─── Tag Extraction ────────────────────────────────────────────────────────── - -/** - * Extract the release tag (e.g. "stable-20260501") from a feed item title. - * The tag is both the SBOM cache key and the GHCR registry tag. - * Returns null if no recognizable tag pattern is found. - */ -export const extractReleaseTag = (title: string): string | null => { - const tagMatch = title.match(/(stable-\d{8}|beta-\d{8}|latest-\d{8})/i); - return tagMatch ? tagMatch[1].toLowerCase() : null; -}; - -/** - * Parse a release tag's date component into ISO date format (YYYY-MM-DD). - */ -export const getReleaseDateFromTag = ( - releaseTag: string | null, -): string | null => { - if (!releaseTag) return null; - const match = releaseTag.match(/(\d{8})$/); - if (!match) return null; - return match[1].replace(/(\d{4})(\d{2})(\d{2})/, "$1-$2-$3"); -}; - -// ─── SBOM Lookup ───────────────────────────────────────────────────────────── - -/** - * Look up an SBOM release entry by feed ID and item title. - * Returns the SbomRelease if found, or null. - */ -export const getSbomRelease = ( - cache: SbomAttestationsData, - feedId: string, - title: string, -): SbomRelease | null => { - const streamId = SBOM_STREAM_BY_FEED_ID[feedId]; - const cacheKey = extractReleaseTag(title); - if (!streamId || !cacheKey) return null; - - return cache.streams?.[streamId]?.releases?.[cacheKey] ?? null; -}; - -/** - * Get the package versions for a release, or null if not available. - */ -export const getPackageVersions = ( - cache: SbomAttestationsData, - feedId: string, - title: string, -): PackageVersions | null => { - const release = getSbomRelease(cache, feedId, title); - return release?.packageVersions ?? null; -}; - -// ─── Supply Chain ──────────────────────────────────────────────────────────── - -export interface SupplyChainLinks { - packageTagUrl: string | null; - attestationVerified: boolean | null; - attestationPresent: boolean | null; -} - -/** - * Build supply-chain link information for a given release title. - * Looks up attestation state from the SBOM cache stream matching the feed. - */ -export const getSupplyChainLinks = ( - cache: SbomAttestationsData, - title: string, - feedId?: string, -): SupplyChainLinks => { - const releaseTag = extractReleaseTag(title); - - if (!releaseTag) { - return { - packageTagUrl: null, - attestationVerified: null, - attestationPresent: null, - }; - } - - let attestationVerified: boolean | null = null; - let attestationPresent: boolean | null = null; - - const streamId = - feedId && feedId in SBOM_STREAM_BY_FEED_ID - ? SBOM_STREAM_BY_FEED_ID[feedId] - : undefined; - if (streamId && cache?.streams && streamId in cache.streams) { - const stream = cache.streams[streamId]; - const entry = stream.releases?.[releaseTag]; - if (entry) { - attestationVerified = entry.attestation?.verified ?? null; - attestationPresent = entry.attestation?.present ?? null; - } - } - - return { - packageTagUrl: `https://github.com/orgs/ublue-os/packages/container/bluefin?tag=${encodeURIComponent(releaseTag)}`, - attestationVerified, - attestationPresent, - }; -}; - -// ─── Version Summary ───────────────────────────────────────────────────────── - -export interface VersionChange { - name: string; - change: string; -} - -/** - * Build a concise version summary (Kernel, GNOME, Mesa, etc.) for a release. - * Reads directly from the SBOM packageVersions. - * - * @param nvidiaVersion Optional NVIDIA version from an external source (not in SBOM) - */ -export const extractVersionSummary = ( - cache: SbomAttestationsData, - title: string, - feedId: string, - nvidiaVersion?: string | null, -): VersionChange[] => { - const packages = getPackageVersions(cache, feedId, title); - if (!packages) return []; - - const changes: VersionChange[] = []; - if (packages.kernel) - changes.push({ name: "Kernel", change: packages.kernel }); - if (packages.gnome) changes.push({ name: "GNOME", change: packages.gnome }); - if (packages.mesa) changes.push({ name: "Mesa", change: packages.mesa }); - if (packages.podman) - changes.push({ name: "Podman", change: packages.podman }); - if (packages.systemd) - changes.push({ name: "systemd", change: packages.systemd }); - if (packages.bootc) changes.push({ name: "bootc", change: packages.bootc }); - if (nvidiaVersion) changes.push({ name: "NVIDIA", change: nvidiaVersion }); - - return changes; -}; - -// ─── Package Diff (computed from SBOM) ─────────────────────────────────────── - -export interface PackageDiffEntry { - name: string; - newVersion: string | null; - oldVersion: string | null; -} - -export interface PackageDiff { - added: PackageDiffEntry[]; - changed: PackageDiffEntry[]; - removed: PackageDiffEntry[]; -} - -export interface MajorVersionBump { - name: string; - from: string; - to: string; -} - -export interface ReleaseSummary { - packageUpdates: number; - newPackages: number; - removedPackages: number; - majorBumps: number; -} - -/** - * Compute a package diff between two consecutive releases by comparing - * their allPackages maps from the SBOM data. - */ -export const computePackageDiff = ( - current: PackageVersions | null, - previous: PackageVersions | null, -): PackageDiff | null => { - const currentPkgs = current?.allPackages; - const previousPkgs = previous?.allPackages; - - // If either release lacks allPackages, we can't compute a diff - if (!currentPkgs || !previousPkgs) return null; - - const added: PackageDiffEntry[] = []; - const changed: PackageDiffEntry[] = []; - const removed: PackageDiffEntry[] = []; - - // Find added and changed packages - for (const [name, newVersion] of Object.entries(currentPkgs)) { - const oldVersion = previousPkgs[name]; - if (oldVersion === undefined) { - added.push({ name, newVersion, oldVersion: null }); - } else if (oldVersion !== newVersion) { - changed.push({ name, newVersion, oldVersion }); - } - } - - // Find removed packages - for (const name of Object.keys(previousPkgs)) { - if (currentPkgs[name] === undefined) { - removed.push({ name, newVersion: null, oldVersion: previousPkgs[name] }); - } - } - - return { added, changed, removed }; -}; - -/** - * Get the previous release in the same SBOM stream (by tag sort order). - * Release tags sort lexicographically as YYYYMMDD. - */ -export const getPreviousRelease = ( - cache: SbomAttestationsData, - feedId: string, - title: string, -): SbomRelease | null => { - const streamId = SBOM_STREAM_BY_FEED_ID[feedId]; - const cacheKey = extractReleaseTag(title); - if (!streamId || !cacheKey) return null; - - const stream = cache.streams?.[streamId]; - if (!stream?.releases) return null; - - // Sort release keys descending (newest first) - const sortedKeys = Object.keys(stream.releases).sort().reverse(); - const currentIdx = sortedKeys.indexOf(cacheKey); - if (currentIdx < 0 || currentIdx >= sortedKeys.length - 1) return null; - - const previousKey = sortedKeys[currentIdx + 1]; - return stream.releases[previousKey] ?? null; -}; - -/** - * Parse the major version number from a version string. - */ -export const parseMajorVersion = (value: string | null): number | null => { - if (!value) return null; - const match = value.match(/\d+/); - if (!match) return null; - return Number.parseInt(match[0], 10); -}; - -/** - * Extract major version bumps from a package diff. - */ -export const extractMajorVersionBumps = ( - diff: PackageDiff | null, -): MajorVersionBump[] => { - if (!diff) return []; - return diff.changed - .flatMap((pkg) => { - const fromMajor = parseMajorVersion(pkg.oldVersion); - const toMajor = parseMajorVersion(pkg.newVersion); - if (fromMajor === null || toMajor === null || toMajor <= fromMajor) { - return []; - } - return [ - { - name: pkg.name, - from: pkg.oldVersion || "unknown", - to: pkg.newVersion || "unknown", - }, - ]; - }) - .slice(0, 10); -}; - -/** - * Build a release summary from a computed package diff. - */ -export const buildReleaseSummary = ( - diff: PackageDiff | null, - majorVersionBumps: MajorVersionBump[], -): ReleaseSummary | null => { - if (!diff) return null; - return { - packageUpdates: diff.changed.length, - newPackages: diff.added.length, - removedPackages: diff.removed.length, - majorBumps: majorVersionBumps.length, - }; -};