+
+
{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/tests/e2e/carbon-ads.spec.ts b/apps/site/tests/e2e/carbon-ads.spec.ts
new file mode 100644
index 0000000000000..55f8c05a43a8d
--- /dev/null
+++ b/apps/site/tests/e2e/carbon-ads.spec.ts
@@ -0,0 +1,101 @@
+import { test, expect, type Page } from '@playwright/test';
+
+const zones = { top: 'CWBI6KJY', sidebar: 'CWBI6KJU' };
+
+const createAd = (id: string) => ({
+ company: `Company ${id}`,
+ description: `Description ${id}`,
+ callToAction: 'Learn more',
+ image: 'https://cdn4.buysellads.net/test.png',
+ largeImage: 'https://cdn4.buysellads.net/test.png',
+ backgroundColor: '#ffffff',
+ textColor: '#000000',
+ ctaBackgroundColor: '#000000',
+ ctaTextColor: '#ffffff',
+ statlink: `https://srv.buysellads.com/ads/click/x/${id}`,
+ statview: `https://srv.buysellads.com/ads/view/x/${id}`,
+ ad_via_link: 'https://www.carbonads.net/',
+});
+
+/**
+ * Serves fixtures instead of contacting Carbon Ads, and records every ad request
+ */
+const mockAds = async (page: Page) => {
+ const requests: Array = [];
+
+ await page.route('https://srv.carbonads.net/**', route => {
+ const zone = new URL(route.request().url()).pathname;
+ requests.push(zone);
+
+ return route.fulfill({
+ json: { ads: [createAd(String(requests.length))] },
+ headers: { 'access-control-allow-origin': '*' },
+ });
+ });
+
+ await page.route('https://srv.buysellads.com/**', route =>
+ route.fulfill({ body: '' })
+ );
+
+ await page.route('https://cdn4.buysellads.net/**', route =>
+ route.fulfill({ body: '' })
+ );
+
+ return requests;
+};
+
+const adLinks = (page: Page) =>
+ page.locator('a[href^="https://srv.buysellads.com/ads/click/"]');
+
+test.describe('Carbon Ads', () => {
+ test.use({ viewport: { width: 1440, height: 900 } });
+
+ test('should never show or request ads on the homepage', async ({ page }) => {
+ const requests = await mockAds(page);
+
+ await page.goto('/en');
+ await page.waitForLoadState('networkidle');
+
+ expect(requests).toEqual([]);
+ await expect(adLinks(page)).toHaveCount(0);
+ });
+
+ test('should request each format once per page view', async ({ page }) => {
+ const requests = await mockAds(page);
+
+ await page.goto('/en/about');
+ await expect(adLinks(page)).toHaveCount(2);
+
+ expect(requests.sort()).toEqual([
+ `/ads/${zones.sidebar}.json`,
+ `/ads/${zones.top}.json`,
+ ]);
+
+ // Client-side navigation starts a new page view (Top Bar only)
+ await page.locator('nav a[href="/en/blog"]').first().click();
+ await page.waitForURL('**/en/blog');
+ await expect(adLinks(page)).toHaveCount(1);
+ await expect(page.getByAltText('Company 3')).toBeVisible();
+
+ expect(requests).toHaveLength(3);
+
+ // Navigating to the homepage removes the ads, without new requests
+ await page.locator('nav a[href="/en"]').first().click();
+ await page.waitForURL(/\/en$/);
+ await page.waitForLoadState('networkidle');
+
+ await expect(adLinks(page)).toHaveCount(0);
+ expect(requests).toHaveLength(3);
+ });
+
+ test('should not request the Sidebar on narrow screens', async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 800 });
+
+ const requests = await mockAds(page);
+
+ await page.goto('/en/about');
+ await expect(adLinks(page)).toHaveCount(1);
+
+ expect(requests).toEqual([`/ads/${zones.top}.json`]);
+ });
+});
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/docs/carbon-ads.md b/docs/carbon-ads.md
new file mode 100644
index 0000000000000..277a2bf6fbf3a
--- /dev/null
+++ b/docs/carbon-ads.md
@@ -0,0 +1,58 @@
+# Carbon Ads
+
+The Website shows [Carbon Ads](https://www.carbonads.net/) through the OpenJS Foundation's Carbon Ads project for Node.js. Ads are rendered from Carbon's JSON API, directly in the visitor's browser, without any third-party script.
+
+## Placements
+
+| Format | Zone key | Where |
+| ------- | ---------- | ----------------------------------------------------------- |
+| Banner | `CWBI6KJY` | Full width, above page content, on the layouts listed below |
+| Sidebar | `CWBI6KJU` | Below the table of contents, from the `xl` breakpoint |
+
+| Layout | Banner | Sidebar |
+| ------------------------ | ------ | ------------------------- |
+| `about`, `article` | Yes | From the `xl` breakpoint |
+| `blog-post` | Yes | From the `xl` breakpoint |
+| `blog-category` | Yes | No (no right-hand column) |
+| `download` | **No** | **No** |
+| `download-archive` | **No** | **No** |
+| `home`, error/404 pages | **No** | **No** |
+| `page` (fallback layout) | No | No |
+
+The homepage never renders any ad markup, reserves no space and makes no ad requests. Layouts opt in explicitly through `` before their main content. The Sidebar is rendered inside `` (`apps/site/components/MetaBar.tsx`), so every layout that uses it shows the Sidebar.
+
+On narrower screens the Sidebar is neither shown nor requested (the Top Bar covers those views). Its breakpoint is hard-coded as `(min-width: 1280px)` in `apps/site/components/CarbonAds/Sidebar.tsx` and must match the layout grids. Resizing the window back above the breakpoint requests a new Sidebar ad, which records another impression.
+
+Learn (`/learn`) and the API docs (`/api`, `/docs`) are separate applications and are not affected by this integration.
+
+## How it works
+
+- `apps/site/hooks/useCarbonAd.ts` requests the ad for the current page view and sends its view beacon. A page view changes with the pathname or locale (not with hashes or query strings), so a revisited page always gets a fresh ad.
+- `apps/site/components/CarbonAds/TopBar.tsx` and `apps/site/components/CarbonAds/Sidebar.tsx` render each format. No space is reserved while loading; when there is no ad (empty response, invalid data, network error or ad blocker) nothing is shown.
+- The ad request itself records the impression. The `statview` beacon is sent once per page view after at least 50% of the ad was visible for one continuous second. Clicks go through `statlink`.
+
+## Staging and production
+
+By default, every request is sent with `ignore=yes`, which returns a real ad **without counting the impression**. This applies to local development, tests and all preview deployments (which are production builds too).
+
+Impressions are only counted when the build has:
+
+```bash
+NEXT_PUBLIC_CARBON_ADS_PRODUCTION=true
+```
+
+As with any `NEXT_PUBLIC_` variable, this value is embedded at build time, so it must be set in the environment of the **production deployment build only** (e.g. the Vercel "Production" environment, or the Cloudflare production build), never for previews. Changing it requires a new build. It is listed in `apps/site/turbo.json` so that it is part of the build cache key; without that, a cached preview build could be reused for production (or vice versa).
+
+There is no Content Security Policy on the Website at the moment. If one is added, it must allow `srv.carbonads.net` (ad requests), `srv.buysellads.com` (view beacons) and the creative image host (e.g. `cdn4.buysellads.net`).
+
+### Launch sequence
+
+1. Deploy the integration to a staging/preview URL (impressions are not counted there).
+2. Send the staging URL to Carbon for review. They serve a test creative and check requests, view beacons and click links.
+3. Set `NEXT_PUBLIC_CARBON_ADS_PRODUCTION=true` for the production build and deploy.
+4. For the first seven days Carbon counts traffic but serves no ads: responses are empty and the slots render nothing.
+5. Paid ads then start appearing in the same slots, without code changes.
+
+## Testing
+
+End-to-end tests live in `apps/site/tests/e2e/carbon-ads.spec.ts`, and impression accounting is unit tested in `apps/site/hooks/__tests__/useCarbonAd.test.jsx`. When testing manually, avoid clicking real ads repeatedly; prefer intercepting `https://srv.carbonads.net/ads/*.json` with fixtures (e.g. in Playwright or the browser's developer tools).
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}
);
};