From eae362aa7bcc6a8c5f39e901c0f4e5aa4073b14c Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sat, 31 Oct 2026 13:18:22 +0100 Subject: [PATCH 1/2] chore: carbon ads Co-authored-by: Claudio Wunder Co-authored-by: Brian Muenzenmeyer Co-authored-by: Matteo Collina --- .changeset/smart-lilies-invent.md | 5 + apps/site/components/CarbonAds/Sidebar.tsx | 50 ++++++++ apps/site/components/CarbonAds/TopBar.tsx | 42 +++++++ .../components/CarbonAds/index.module.css | 112 ++++++++++++++++++ apps/site/components/MetaBar.tsx | 2 + apps/site/hooks/useCarbonAd.ts | 90 ++++++++++++++ apps/site/layouts/About.tsx | 3 + apps/site/layouts/ArticlePage.tsx | 3 + apps/site/layouts/Blog.tsx | 3 + apps/site/layouts/Post.tsx | 3 + apps/site/next.constants.mjs | 11 ++ apps/site/turbo.json | 4 + .../MetaBar/__tests__/index.test.jsx | 23 ++++ .../src/Containers/MetaBar/index.tsx | 6 +- 14 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 .changeset/smart-lilies-invent.md create mode 100644 apps/site/components/CarbonAds/Sidebar.tsx create mode 100644 apps/site/components/CarbonAds/TopBar.tsx create mode 100644 apps/site/components/CarbonAds/index.module.css create mode 100644 apps/site/hooks/useCarbonAd.ts diff --git a/.changeset/smart-lilies-invent.md b/.changeset/smart-lilies-invent.md new file mode 100644 index 0000000000000..df5a9032cd185 --- /dev/null +++ b/.changeset/smart-lilies-invent.md @@ -0,0 +1,5 @@ +--- +'@node-core/ui-components': minor +--- + +Adds a footer slot to the metabar diff --git a/apps/site/components/CarbonAds/Sidebar.tsx b/apps/site/components/CarbonAds/Sidebar.tsx new file mode 100644 index 0000000000000..0310892cf850f --- /dev/null +++ b/apps/site/components/CarbonAds/Sidebar.tsx @@ -0,0 +1,50 @@ +'use client'; + +import classNames from 'classnames'; + +import useCarbonAd, { adColors } from '#site/hooks/useCarbonAd'; + +import type { FC } from 'react'; + +import styles from './index.module.css'; + +const CarbonSidebar: FC = () => { + const { ad, ref } = useCarbonAd('CWBI6KJU', '(min-width: 1280px)'); + + if (!ad) { + return null; + } + + return ( + + ); +}; + +export default CarbonSidebar; diff --git a/apps/site/components/CarbonAds/TopBar.tsx b/apps/site/components/CarbonAds/TopBar.tsx new file mode 100644 index 0000000000000..4f6999eddad0b --- /dev/null +++ b/apps/site/components/CarbonAds/TopBar.tsx @@ -0,0 +1,42 @@ +'use client'; + +import useCarbonAd, { adColors } from '#site/hooks/useCarbonAd'; + +import type { FC } from 'react'; + +import styles from './index.module.css'; + +const CarbonTopBar: FC = () => { + const { ad, ref } = useCarbonAd('CWBI6KJY'); + + if (!ad) { + return null; + } + + return ( + + ); +}; + +export default CarbonTopBar; diff --git a/apps/site/components/CarbonAds/index.module.css b/apps/site/components/CarbonAds/index.module.css new file mode 100644 index 0000000000000..c283ed0ef7421 --- /dev/null +++ b/apps/site/components/CarbonAds/index.module.css @@ -0,0 +1,112 @@ +@reference "../../styles/index.css"; + +.topBar, +.sideBar { + @apply relative + bg-(--ad-bg) + text-(color:--ad-text) + dark:bg-(--ad-bg-dark) + dark:text-(color:--ad-text-dark); + + & a { + @apply text-inherit + no-underline; + } +} + +.badge { + @apply shrink-0 + rounded-sm + border + border-current + px-1.5 + text-xs + leading-4 + opacity-70 + hover:opacity-100; +} + +.topBar { + @apply mb-4 + flex + items-center + gap-3 + rounded-md + px-4 + py-2 + text-sm; +} + +.topBarLink { + @apply flex + flex-1 + items-center + gap-3; +} + +.logo { + @apply size-8 + shrink-0 + rounded-sm; +} + +.cta { + @apply hidden + shrink-0 + rounded-md + bg-(--ad-cta-bg) + px-3 + py-1 + text-xs + font-semibold + whitespace-nowrap + text-(color:--ad-cta-text) + sm:block + dark:bg-(--ad-cta-bg-dark) + dark:text-(color:--ad-cta-text-dark); +} + +.sideBar { + @apply w-44 + overflow-hidden + rounded-lg; +} + +.sideBarLink { + @apply flex + flex-col; +} + +.image { + @apply aspect-square + w-full + object-cover; +} + +.sideBarBody { + @apply flex + flex-col + items-start + gap-1 + p-3.5 + text-xs; +} + +.sideBarCta { + @apply mt-2 + rounded-full + border + border-current + px-3 + py-1 + font-semibold; +} + +.overlay { + @apply absolute + top-2 + right-2 + border-transparent + bg-neutral-950/50 + text-white; +} diff --git a/apps/site/components/MetaBar.tsx b/apps/site/components/MetaBar.tsx index 4d88cabd0ba9f..788f5fc55a649 100644 --- a/apps/site/components/MetaBar.tsx +++ b/apps/site/components/MetaBar.tsx @@ -6,6 +6,7 @@ import { defaultLocale } from '@node-core/website-i18n'; import { useFormatter, useLocale, useTranslations } from 'next-intl'; import AvatarGroup from '#site/components/AvatarGroup'; +import CarbonSidebar from '#site/components/CarbonAds/Sidebar'; import Link from '#site/components/Link'; import useClientContext from '#site/hooks/useClientContext'; import useMediaQuery from '#site/hooks/useMediaQuery'; @@ -74,6 +75,7 @@ const MetaBar: FC = () => { ), }} headings={{ items: headings }} + footer={} /> ); }; diff --git a/apps/site/hooks/useCarbonAd.ts b/apps/site/hooks/useCarbonAd.ts new file mode 100644 index 0000000000000..83d5f6052337a --- /dev/null +++ b/apps/site/hooks/useCarbonAd.ts @@ -0,0 +1,90 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; + +import useMediaQuery from '#site/hooks/useMediaQuery'; +import { + CARBON_ADS_COUNT_IMPRESSIONS, + CARBON_ADS_URL, +} from '#site/next.constants.mjs'; + +import type { CSSProperties } from 'react'; + +export type CarbonAd = Record; + +export const adColors = (ad: CarbonAd) => + ({ + '--ad-bg': ad.backgroundColor, + '--ad-bg-dark': ad.backgroundColorDark ?? ad.backgroundColor, + '--ad-text': ad.textColor, + '--ad-text-dark': ad.textColorDark ?? ad.textColor, + '--ad-cta-bg': ad.ctaBackgroundColor, + '--ad-cta-bg-dark': ad.ctaBackgroundColorDark ?? ad.ctaBackgroundColor, + '--ad-cta-text': ad.ctaTextColor, + '--ad-cta-text-dark': ad.ctaTextColorDark ?? ad.ctaTextColor, + }) as CSSProperties; + +const useCarbonAd = (zone: string, media?: string) => { + const [ad, setAd] = useState(null); + const ref = useRef(null); + // Layouts persist across navigations, so refetch per page + const pathname = usePathname(); + const matches = useMediaQuery(media ?? 'all'); + + useEffect(() => { + if (!matches) { + return; + } + + const controller = new AbortController(); + const url = new URL(`${zone}.json`, CARBON_ADS_URL); + + if (!CARBON_ADS_COUNT_IMPRESSIONS) { + url.searchParams.set('ignore', 'yes'); + } + + fetch(url, { signal: controller.signal }) + .then(response => (response.ok ? response.json() : null)) + .then(data => data?.ads?.[0]?.statlink && setAd(data.ads[0])) + .catch(() => {}); + + return () => { + controller.abort(); + setAd(null); + }; + }, [zone, matches, pathname]); + + useEffect(() => { + if (!ad?.statview || !ref.current) { + return; + } + + let timer: ReturnType; + + const observer = new IntersectionObserver( + entries => { + clearTimeout(timer); + + if (entries.at(-1)!.intersectionRatio >= 0.5) { + timer = setTimeout(() => { + new Image().src = ad.statview; + observer.disconnect(); + }, 1000); + } + }, + { threshold: 0.5 } + ); + + observer.observe(ref.current); + + return () => { + clearTimeout(timer); + observer.disconnect(); + }; + }, [ad]); + + return { ad, ref }; +}; + +export default useCarbonAd; diff --git a/apps/site/layouts/About.tsx b/apps/site/layouts/About.tsx index 4c53a24799298..d80b889f8cc84 100644 --- a/apps/site/layouts/About.tsx +++ b/apps/site/layouts/About.tsx @@ -1,6 +1,7 @@ import Article from '@node-core/ui-components/Containers/Article'; import Breadcrumbs from '#site/components/Breadcrumbs'; +import CarbonTopBar from '#site/components/CarbonAds/TopBar'; import Footer from '#site/components/Footer'; import MetaBar from '#site/components/MetaBar'; import NavBar from '#site/components/NavBar'; @@ -17,6 +18,8 @@ const AboutLayout: FC = ({ children }) => (
+ + {children}
diff --git a/apps/site/layouts/ArticlePage.tsx b/apps/site/layouts/ArticlePage.tsx index ebca2366de34a..63f58de69d460 100644 --- a/apps/site/layouts/ArticlePage.tsx +++ b/apps/site/layouts/ArticlePage.tsx @@ -1,5 +1,6 @@ import Article from '@node-core/ui-components/Containers/Article'; +import CarbonTopBar from '#site/components/CarbonAds/TopBar'; import Footer from '#site/components/Footer'; import MetaBar from '#site/components/MetaBar'; import NavBar from '#site/components/NavBar'; @@ -16,6 +17,8 @@ const ArticlePageLayout: FC = ({ children }) => (
+ + {children}
diff --git a/apps/site/layouts/Blog.tsx b/apps/site/layouts/Blog.tsx index 7cf0755dd23a0..cc00cad2b0f3f 100644 --- a/apps/site/layouts/Blog.tsx +++ b/apps/site/layouts/Blog.tsx @@ -3,6 +3,7 @@ import { useTranslations } from 'next-intl'; import { getClientContext } from '#site/client-context'; import BlogHeader from '#site/components/Blog/BlogHeader'; import BlogCategories from '#site/components/BlogCategories'; +import CarbonTopBar from '#site/components/CarbonAds/TopBar'; import Footer from '#site/components/Footer'; import NavBar from '#site/components/NavBar'; import { getBlogData } from '#site/util/blog'; @@ -55,6 +56,8 @@ const BlogLayout: FC = () => {
+ + = ({ children }) => {
{type === 'vulnerability' && } + +

{frontmatter.title}

diff --git a/apps/site/next.constants.mjs b/apps/site/next.constants.mjs index b9509bb4c19f3..ff16d1ed200da 100644 --- a/apps/site/next.constants.mjs +++ b/apps/site/next.constants.mjs @@ -172,3 +172,14 @@ export const ORAMA_DB_URLS = { [LEARN_URL.slice(0, -1)]: 'https://nodejs.org/learn/orama-db.json', [`${DOCS_URL}latest/api`]: 'https://beta.docs.nodejs.org/orama-db.json', }; + +/** + * The Carbon Ads JSON API endpoint (suffixed with `{zone}.json`) + */ +export const CARBON_ADS_URL = 'https://srv.carbonads.net/ads/'; + +/** + * Whether Carbon Ads should count impressions (production only) + */ +export const CARBON_ADS_COUNT_IMPRESSIONS = + process.env.NEXT_PUBLIC_CARBON_ADS_PRODUCTION === 'true'; diff --git a/apps/site/turbo.json b/apps/site/turbo.json index bcb3469ba50dc..b26f340ed07df 100644 --- a/apps/site/turbo.json +++ b/apps/site/turbo.json @@ -22,6 +22,7 @@ "NEXT_PUBLIC_DOCS_URL", "NEXT_PUBLIC_BASE_PATH", "NEXT_PUBLIC_DATA_URL", + "NEXT_PUBLIC_CARBON_ADS_PRODUCTION", "TURBO_CACHE", "TURBO_TELEMETRY_DISABLED", "TURBOPACK_STATS" @@ -53,6 +54,7 @@ "NEXT_PUBLIC_DOCS_URL", "NEXT_PUBLIC_BASE_PATH", "NEXT_PUBLIC_DATA_URL", + "NEXT_PUBLIC_CARBON_ADS_PRODUCTION", "TURBO_CACHE", "TURBO_TELEMETRY_DISABLED", "TURBOPACK_STATS", @@ -72,6 +74,7 @@ "NEXT_PUBLIC_DOCS_URL", "NEXT_PUBLIC_BASE_PATH", "NEXT_PUBLIC_DATA_URL", + "NEXT_PUBLIC_CARBON_ADS_PRODUCTION", "TURBO_CACHE", "TURBO_TELEMETRY_DISABLED", "TURBOPACK_STATS" @@ -96,6 +99,7 @@ "NEXT_PUBLIC_DOCS_URL", "NEXT_PUBLIC_BASE_PATH", "NEXT_PUBLIC_DATA_URL", + "NEXT_PUBLIC_CARBON_ADS_PRODUCTION", "TURBO_CACHE", "TURBO_TELEMETRY_DISABLED", "TURBOPACK_STATS" diff --git a/packages/ui-components/src/Containers/MetaBar/__tests__/index.test.jsx b/packages/ui-components/src/Containers/MetaBar/__tests__/index.test.jsx index 6460c2fdf125d..ed05df5dd8c69 100644 --- a/packages/ui-components/src/Containers/MetaBar/__tests__/index.test.jsx +++ b/packages/ui-components/src/Containers/MetaBar/__tests__/index.test.jsx @@ -60,4 +60,27 @@ describe('MetaBar', () => { const h6Element = queryByText('Heading Level 6'); assert.ok(!h6Element?.ownerDocument); }); + + it('renders an optional footer after the metadata list', () => { + const { container, getByText } = render( + Footer content

} + /> + ); + + const footer = getByText('Footer content'); + + assert.equal(container.querySelector('dl').contains(footer), false); + assert.equal(container.querySelector('aside').contains(footer), true); + }); + + it('renders no footer by default', () => { + const { container } = render( + + ); + + assert.equal(container.querySelector('aside').children.length, 1); + }); }); diff --git a/packages/ui-components/src/Containers/MetaBar/index.tsx b/packages/ui-components/src/Containers/MetaBar/index.tsx index 8f73c84127837..d66304e694a3b 100644 --- a/packages/ui-components/src/Containers/MetaBar/index.tsx +++ b/packages/ui-components/src/Containers/MetaBar/index.tsx @@ -4,7 +4,7 @@ import { Fragment, useMemo } from 'react'; import { CODE_LIKE_TYPES } from '#ui/constants'; import type { Heading, LinkLike } from '#ui/types'; -import type { FC, HTMLAttributes } from 'react'; +import type { FC, HTMLAttributes, ReactNode } from 'react'; import styles from './index.module.css'; @@ -16,6 +16,7 @@ type MetaBarProps = { }; as?: LinkLike; heading: string; + footer?: ReactNode; } & HTMLAttributes; const MetaBar: FC = ({ @@ -23,6 +24,7 @@ const MetaBar: FC = ({ headings, as: Component = 'a', heading, + footer, ...props }) => { // The default depth of headings to display in the table of contents. @@ -74,6 +76,8 @@ const MetaBar: FC = ({ )} + + {footer} ); }; From c5fa89363476ffee1eeeafc2a43343d71ee2b8a7 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sat, 31 Oct 2026 13:30:56 +0100 Subject: [PATCH 2/2] fixup! --- apps/site/components/CarbonAds/index.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/site/components/CarbonAds/index.module.css b/apps/site/components/CarbonAds/index.module.css index c283ed0ef7421..2711d6165e9fb 100644 --- a/apps/site/components/CarbonAds/index.module.css +++ b/apps/site/components/CarbonAds/index.module.css @@ -68,6 +68,7 @@ .sideBar { @apply w-44 + shrink-0 overflow-hidden rounded-lg; }