Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ Flipping the banner on Vercel is "edit env var → redeploy", the same model as

To stand up a new event: copy an existing file in `src/legacy-pages/hackathon/` to a new slug, add an App Router wrapper in `src/app/(website)/hackathon/<new-slug>/page.tsx`, edit its data (or write a custom layout), then set `HACKATHON_EVENT_SLUG=<new-slug>` and `HACKATHON_BANNER_ENABLED=true` on Vercel.

### Cookie consent & analytics (OneTrust + GTM)

[`ConsentTags`](./src/components/consent-tags.tsx) renders the OneTrust consent banner and the Google Tag Manager container at the top of `<body>`, gated by [`resolveOneTrustEnv`](./src/lib/onetrust.ts): production deploys get the production OneTrust variant, previews get the test variant (works on any domain), and local dev gets none. Set `ONETRUST_ENV=test pnpm dev` to see the banner locally. Tag order is load-bearing — the OneTrust AutoBlocker must run before GTM so non-consented cookies are gated — so the tags render as plain blocking scripts, not `next/script` (React hoists only the banner stylesheet `<link>` into `<head>`; the scripts keep their `<body>` source order). The "Your Privacy Choices" footer link ([`YourPrivacyChoicesLink`](./src/components/your-privacy-choices-link.tsx)) opens the OneTrust preference center and must remain in both footers (main and perspectives).

Because the site is a SPA there is no next page load to pick up a consent change, so `ConsentTags` also renders a consent-change handler that reloads the page when the user actually changes their consent (guarded by comparing `OnetrustActiveGroups` against its initial snapshot). It chains — never replaces — `OptanonWrapper`, which the shared databricks `onetrust.js` defines to delete opted-out cookies, so that script's logic keeps running first and the handler must stay ordered after it.

### Site URL Resolution

Anywhere we need an absolute URL — `llms.txt`, `sitemap.xml`, `robots.txt`, JSON-LD, `/api/markdown`, `/api/bootstrap-prompt`, `/api/mcp`, the `Copy prompt` / `Copy Markdown` buttons — we resolve the site origin in this order (see `src/lib/site-url.ts`):
Expand Down
Binary file added public/img/gpc-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/app/(perspectives)/perspectives/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Image from "next/image";
import Link from "next/link";

import { COPYRIGHT_LINE, LEGAL_LINKS } from "@/lib/legal-links";
import { YourPrivacyChoicesLink } from "@/components/your-privacy-choices-link";

export default function PerspectivesLayout({
children,
Expand Down Expand Up @@ -63,6 +64,7 @@ export default function PerspectivesLayout({
{link.label}
</Link>
))}
<YourPrivacyChoicesLink className="text-grey-40 hover:text-grey-70 focus-visible:outline-db-cyan w-fit rounded-sm text-[0.8125rem] leading-none tracking-tight no-underline transition-colors hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-4" />
</nav>
</div>
</footer>
Expand Down
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Metadata, Viewport } from "next";
import { Analytics } from "@vercel/analytics/react";

import { resolveSiteUrl } from "@/lib/site-url";
import { ConsentTags } from "@/components/consent-tags";

export const metadata: Metadata = {
metadataBase: new URL(resolveSiteUrl()),
Expand Down Expand Up @@ -55,6 +56,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning className="dark">
<body>
<ConsentTags />
{renderVercelAnalytics ? <Analytics /> : null}
{children}
</body>
Expand Down
109 changes: 109 additions & 0 deletions src/components/consent-tags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { ReactNode } from "react";

import {
GTM_CONTAINER_ID,
ONETRUST_DOMAIN_SCRIPT_ID,
resolveOneTrustEnv,
} from "@/lib/onetrust";

const GTM_HEAD_SNIPPET =
"(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':" +
"new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0]," +
"j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=" +
"'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);" +
`})(window,document,'script','dataLayer','${GTM_CONTAINER_ID}');`;

// On www.databricks.com a consent change takes effect on the next page load;
// this SPA has none, so reload when consent actually changes. The databricks
// onetrust.js defines OptanonWrapper (OneTrust's init + consent-change
// callback) and deletes opted-out cookies inside it, so chain it — never
// replace it — and let it run first. Reloading is safe in both directions:
// the post-reload OptanonWrapper init re-runs the cookie deletion. The
// OnetrustActiveGroups snapshot guard keeps SDK-internal re-fires (geo
// resolution, implied-consent recording) from reloading without a real
// user-driven change.
const CONSENT_CHANGE_SNIPPET =
"(function(){" +
"var previous=window.OptanonWrapper;" +
"var initialGroups=null;" +
"var registered=false;" +
"window.OptanonWrapper=function(){" +
"if(typeof previous==='function')previous();" +
"if(initialGroups===null)initialGroups=window.OnetrustActiveGroups||'';" +
"if(registered||!window.OneTrust||typeof window.OneTrust.OnConsentChanged!=='function')return;" +
"registered=true;" +
"window.OneTrust.OnConsentChanged(function(){" +
"if((window.OnetrustActiveGroups||'')!==initialGroups)window.location.reload();" +
"});" +
"};" +
"})();";

/**
* OneTrust cookie consent + Google Tag Manager, copied from the
* www.databricks.com install. Rendered as the first children of <body> as
* plain blocking tags — not next/script — because the DOM order is the legal
* contract: the OneTrust AutoBlocker must execute before the GTM snippet so
* it can gate the cookies GTM drops, and the <noscript> iframe must sit
* immediately after the opening <body> tag. The scripts stay in <body> in
* source order; React hoists only the stylesheet <link> into <head>, where
* the databricks.com install puts it too. Renders nothing when no consent
* variant applies (local dev/build); GTM never loads without OneTrust.
*
* The consent-change script must come after onetrust.js (it chains that
* script's OptanonWrapper) — see CONSENT_CHANGE_SNIPPET above.
*/
export function ConsentTags(): ReactNode {
const variant = resolveOneTrustEnv();
if (!variant) {
return null;
}

const isTest = variant === "test";

return (
<>
<noscript>
<iframe
src={`https://www.googletagmanager.com/ns.html?id=${GTM_CONTAINER_ID}`}
height="0"
width="0"
style={{ display: "none", visibility: "hidden" }}
/>
</noscript>
<script
type="text/javascript"
src={`https://www.databricks.com/sites/default/files/${isTest ? "onetrust-test" : "onetrust"}/DB_OtAutoBlock.js?v=1`}
/>
<script
src="https://cdn.cookielaw.org/scripttemplates/otSDKStub.js"
data-document-language="true"
type="text/javascript"
charSet="UTF-8"
data-domain-script={
isTest
? `${ONETRUST_DOMAIN_SCRIPT_ID}-test`
: ONETRUST_DOMAIN_SCRIPT_ID
}
/>
<script
type="text/javascript"
src="https://www.databricks.com/wp-content/plugins/databricks/js/onetrust.js?ver=1.0.0"
id="db-onetrust-script"
/>
<script
id="db-onetrust-consent-change"
dangerouslySetInnerHTML={{ __html: CONSENT_CHANGE_SNIPPET }}
/>
<link
rel="stylesheet"
id="db-onetrust-style"
href="https://www.databricks.com/wp-content/uploads/db_onetrust.css"
media="all"
/>
<script
id="db-gtm"
dangerouslySetInnerHTML={{ __html: GTM_HEAD_SNIPPET }}
/>
</>
);
}
2 changes: 2 additions & 0 deletions src/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from "next/link";
import { COPYRIGHT_LINE, LEGAL_LINKS } from "@/lib/legal-links";
import { cn } from "@/lib/utils";
import { Icons } from "@/components/icons";
import { YourPrivacyChoicesLink } from "@/components/your-privacy-choices-link";

type FooterItem = {
label: string;
Expand Down Expand Up @@ -155,6 +156,7 @@ function LegalLinks({ className }: { className?: string }): ReactNode {
{link.label}
</Link>
))}
<YourPrivacyChoicesLink className="text-grey-40 hover:text-grey-70 focus-visible:outline-db-cyan w-fit rounded-sm text-[0.8125rem] leading-none tracking-tight no-underline transition-colors hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-4" />
</nav>
);
}
Expand Down
40 changes: 40 additions & 0 deletions src/components/your-privacy-choices-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import Image from "next/image";

import { cn } from "@/lib/utils";

declare global {
interface Window {
OneTrust?: { ToggleInfoDisplay: () => void };
}
}

/**
* The legally required "Your Privacy Choices" footer link with the GPC icon,
* matching www.databricks.com. Opens the OneTrust preference center when the
* consent scripts are loaded (production/previews, see src/lib/onetrust.ts);
* a no-op in local dev where they are off.
*/
export function YourPrivacyChoicesLink({ className }: { className?: string }) {
return (
<a
href="#yourprivacychoices"
className={cn("inline-flex items-center gap-1.5", className)}
onClick={(event) => {
event.preventDefault();
window.OneTrust?.ToggleInfoDisplay();
}}
>
Your Privacy Choices
<Image
src="/img/gpc-icon.png"
alt=""
className="h-3.5 w-auto"
width={75}
height={36}
loading="lazy"
/>
</a>
);
}
33 changes: 33 additions & 0 deletions src/lib/onetrust.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* OneTrust cookie consent environment resolution.
*
* Two variants of the standard Databricks OneTrust install exist:
* - "test": works on any domain — Vercel previews and local testing.
* - "production": only works on *.databricks.com domains.
*
* Resolution order:
* 1. ONETRUST_ENV — explicit override, e.g. `ONETRUST_ENV=test pnpm dev`
* to see the banner locally.
* 2. VERCEL_ENV=production — production variant.
* 3. VERCEL_ENV=preview|development — test variant.
* 4. otherwise (local dev/build) — null: no tags, no banner, and GTM must
* not load either (the AutoBlocker gates GTM's cookies).
*/

export const ONETRUST_DOMAIN_SCRIPT_ID = "92466579-1717-44d3-809d-a05fb02843ed";
export const GTM_CONTAINER_ID = "GTM-TWTKQQ";

type Env = Record<string, string | undefined>;

export type OneTrustEnv = "production" | "test";

export function resolveOneTrustEnv(env: Env = process.env): OneTrustEnv | null {
if (env.ONETRUST_ENV === "production" || env.ONETRUST_ENV === "test") {
return env.ONETRUST_ENV;
}
if (env.VERCEL_ENV === "production") return "production";
if (env.VERCEL_ENV === "preview" || env.VERCEL_ENV === "development") {
return "test";
}
return null;
}
6 changes: 5 additions & 1 deletion tests/broken-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const NON_PAGE_ROUTES = new Set([
]);
const NON_PAGE_ROUTE_PREFIXES = ["/api/", "/raw-docs/", "/_next/"];

// JS-handled pseudo-anchors that intentionally have no element id (e.g. the
// "Your Privacy Choices" link opens the OneTrust preference center onClick).
const PSEUDO_ANCHORS = new Set(["yourprivacychoices"]);

function listFiles(root: string): string[] {
if (!existsSync(root)) {
return [];
Expand Down Expand Up @@ -193,7 +197,7 @@ describe("broken internal links", () => {
const failures: string[] = [];

for (const link of crawl.links) {
if (link.hash === "") {
if (link.hash === "" || PSEUDO_ANCHORS.has(link.hash)) {
continue;
}

Expand Down
45 changes: 45 additions & 0 deletions tests/consent-tags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, test, vi } from "vitest";

import { ConsentTags } from "../src/components/consent-tags";

afterEach(() => {
vi.unstubAllEnvs();
});

describe("ConsentTags", () => {
test("renders nothing without a consent variant", () => {
vi.stubEnv("ONETRUST_ENV", "");
vi.stubEnv("VERCEL_ENV", "");
expect(renderToStaticMarkup(createElement(ConsentTags))).toBe("");
});

test("keeps the legal tag order: AutoBlocker, SDK stub, onetrust.js, consent-change handler, GTM", () => {
vi.stubEnv("ONETRUST_ENV", "test");
const html = renderToStaticMarkup(createElement(ConsentTags));

const order = [
"OtAutoBlock.js",
"otSDKStub.js",
"plugins/databricks/js/onetrust.js",
"db-onetrust-consent-change",
"'dataLayer','GTM-TWTKQQ'",
].map((marker) => html.indexOf(marker));

expect(order.every((index) => index !== -1)).toBe(true);
expect(order).toEqual([...order].sort((a, b) => a - b));
});

test("consent-change handler chains OptanonWrapper and reloads via OnConsentChanged", () => {
vi.stubEnv("ONETRUST_ENV", "test");
const html = renderToStaticMarkup(createElement(ConsentTags));

// onetrust.js owns OptanonWrapper (cookie deletion on opt-out); the
// handler must call the previous wrapper, not replace it.
expect(html).toContain("var previous=window.OptanonWrapper");
expect(html).toContain("previous()");
expect(html).toContain("OnConsentChanged");
expect(html).toContain("window.location.reload()");
});
});
1 change: 1 addition & 0 deletions tests/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ test.describe("footer navigation", () => {
"https://www.databricks.com/legal/terms-of-use",
"https://www.databricks.com/legal/modern-slavery-policy-statement",
"https://www.databricks.com/legal/supplemental-privacy-notice-california-residents",
"#yourprivacychoices",
"/product/databricks-apps",
"/product/lakebase",
"/product/agent-bricks",
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e/pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,9 @@ test.describe("docs MDX compatibility", () => {
expect(layout.articleHeight).toBeLessThanOrEqual(3165);
expect(layout.codeBlocks).toBe(3);
expect(layout.inlineCodes).toBe(2);
expect(layout.linksWithoutHackathonBanner).toBe(65);
// 67 = page links + footer links including "Your Privacy Choices", which
// the footer renders twice (desktop and mobile legal blocks).
expect(layout.linksWithoutHackathonBanner).toBe(67);
});

test("renders relative docs image assets and links", async ({ page }) => {
Expand Down
35 changes: 35 additions & 0 deletions tests/onetrust.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";

import { resolveOneTrustEnv } from "../src/lib/onetrust";

describe("resolveOneTrustEnv", () => {
test("ONETRUST_ENV override wins over VERCEL_ENV", () => {
expect(
resolveOneTrustEnv({ ONETRUST_ENV: "test", VERCEL_ENV: "production" }),
).toBe("test");
expect(
resolveOneTrustEnv({ ONETRUST_ENV: "production", VERCEL_ENV: "preview" }),
).toBe("production");
});

test("VERCEL_ENV=production resolves to the production variant", () => {
expect(resolveOneTrustEnv({ VERCEL_ENV: "production" })).toBe("production");
});

test("VERCEL_ENV preview and development resolve to the test variant", () => {
expect(resolveOneTrustEnv({ VERCEL_ENV: "preview" })).toBe("test");
expect(resolveOneTrustEnv({ VERCEL_ENV: "development" })).toBe("test");
});

test("no recognized env resolves to null (no tags, no banner)", () => {
expect(resolveOneTrustEnv({})).toBe(null);
expect(resolveOneTrustEnv({ VERCEL_ENV: "staging" })).toBe(null);
});

test("unrecognized ONETRUST_ENV values fall through to VERCEL_ENV", () => {
expect(
resolveOneTrustEnv({ ONETRUST_ENV: "on", VERCEL_ENV: "preview" }),
).toBe("test");
expect(resolveOneTrustEnv({ ONETRUST_ENV: "off" })).toBe(null);
});
});