+
+
{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 a4019123a8805..fcd64fbbd6db3 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..0bfba1c6ba8d5
--- /dev/null
+++ b/apps/site/tests/e2e/carbon-ads.spec.ts
@@ -0,0 +1,163 @@
+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`]);
+ });
+
+ test('should request the Sidebar from each layout breakpoint', async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 1000, height: 800 });
+
+ const requests = await mockAds(page);
+
+ // Between `ml` and `xl`, only blog posts have a right-hand column
+ await page.goto('/en/about');
+ await expect(adLinks(page)).toHaveCount(1);
+
+ expect(requests).toEqual([`/ads/${zones.top}.json`]);
+
+ requests.length = 0;
+
+ await page.goto(
+ '/en/blog/announcements/diving-into-the-nodejs-website-redesign'
+ );
+ await expect(adLinks(page)).toHaveCount(2);
+
+ expect(requests.sort()).toEqual([
+ `/ads/${zones.sidebar}.json`,
+ `/ads/${zones.top}.json`,
+ ]);
+ });
+
+ for (const path of [
+ '/en/about',
+ '/en/blog/announcements/diving-into-the-nodejs-website-redesign',
+ ]) {
+ test(`should not shift ${path} when the Top Bar loads`, async ({
+ page,
+ }) => {
+ await mockAds(page);
+
+ let respond = () => {};
+ const response = new Promise(resolve => (respond = resolve));
+
+ await page.route(
+ `https://srv.carbonads.net/ads/${zones.top}.json*`,
+ async route => {
+ await response;
+ await route.fallback();
+ }
+ );
+
+ await page.goto(path);
+
+ // Only the position matters: the heading's size can change as fonts load
+ const heading = page.locator('main h1').first();
+ const before = await heading.boundingBox();
+
+ respond();
+
+ await expect(adLinks(page)).toHaveCount(2);
+
+ const after = await heading.boundingBox();
+
+ expect(after?.y).toBe(before?.y);
+ });
+ }
+});
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..b740cc34fde03
--- /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 breakpoints below |
+
+| Layout | Banner | Sidebar |
+| ------------------------ | ------ | ------------------------- |
+| `about`, `article` | Yes | From the `xl` breakpoint |
+| `blog-post` | Yes | From the `ml` 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 breakpoints are hard-coded in `apps/site/components/CarbonAds/Sidebar.tsx`, as `(min-width: 896px)` for `blog-post` and `(min-width: 1280px)` for other layouts, 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. From the `lg` breakpoint, the Top Bar reserves its minimum height while loading, so one- and two-line creatives don't shift the page; longer ones can still shift it slightly. Narrower screens reserve no space, because the height varies too much with the creative's length, and neither does the Sidebar, because it is the last item in its column. When there is no ad (empty response, invalid data, network error or ad blocker) nothing is shown, and any reserved space collapses.
+- 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}
);
};