diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 95d1321a2e8..ce6524321c3 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -17,6 +17,7 @@ import { useFeedLayout } from '../hooks'; import { CollectionList } from './cards/collection/CollectionList'; import { FeedItemType } from './cards/common/common'; import { AdGrid } from './cards/ad/AdGrid'; +import { PreferredSearchCard } from './post/preferredSources'; import { AdList } from './cards/ad/AdList'; import { SignalAdList } from './cards/ad/SignalAdList'; import type { AdCardProps } from './cards/ad/common/common'; @@ -489,6 +490,18 @@ function FeedItemComponent({ /> ); } + case FeedItemType.Placeholder: { + // An ad position the ad server could not fill renders a grey card. + // Offer something of ours in that space instead — never in a slot real + // content would have taken. + const isAdSlot = typeof item.index === 'number'; + + return isAdSlot ? ( + } /> + ) : ( + + ); + } default: return ; } diff --git a/packages/shared/src/components/post/PostContent.tsx b/packages/shared/src/components/post/PostContent.tsx index 72c2d6d9b12..f9df24c30d6 100644 --- a/packages/shared/src/components/post/PostContent.tsx +++ b/packages/shared/src/components/post/PostContent.tsx @@ -87,6 +87,7 @@ export function PostContentRaw({ isBannerVisible, isPostPage, getWidgetRailAd, + widgetsLeading, contentLeading, renderSummarySegments, aboveComments, @@ -301,6 +302,7 @@ export function PostContentRaw({ origin={origin} onCopyPostLink={onCopyPostLink} getRailAd={getWidgetRailAd} + leading={widgetsLeading} /> ); diff --git a/packages/shared/src/components/post/PostWidgets.tsx b/packages/shared/src/components/post/PostWidgets.tsx index f42748fe9ea..1385685b407 100644 --- a/packages/shared/src/components/post/PostWidgets.tsx +++ b/packages/shared/src/components/post/PostWidgets.tsx @@ -17,6 +17,7 @@ import { PostSidebarAdWidget } from './PostSidebarAdWidget'; import { FeaturedArchives } from '../widgets/FeaturedArchives'; import { MentionedToolsWidget } from '../brand/MentionedToolsWidget'; import { PostSignupWidget } from './PostSignupWidget'; +import { PreferGoogleSourceAction } from './preferredSources'; import { HighlightPostSidebarWidget } from '../cards/highlight/HighlightPostSidebarWidget'; const UserEntityCard = dynamic( @@ -67,6 +68,8 @@ export type PostWidgetsProps = Omit & hideToc?: boolean; /** Renders a slot after the widget at each position. */ getRailAd?: (position: PostWidgetPosition) => ReactNode; + /** Rendered first, above every other widget. */ + leading?: ReactNode; /** Rendered last, below the footer links. */ trailing?: ReactNode; /** Drops the internal sidebar ad — for templates carrying their own. */ @@ -103,6 +106,7 @@ export function PostWidgets({ hideSignupWidget = false, hideToc = false, getRailAd, + leading, trailing, hideAdWidget, }: PostWidgetsProps): ReactElement { @@ -163,6 +167,8 @@ export function PostWidgets({ return ( + {leading} + {!hideSignupWidget && } {withAd(PostWidgetPosition.Source, sourceCard)} {withAd( diff --git a/packages/shared/src/components/post/common.tsx b/packages/shared/src/components/post/common.tsx index 9b9bb526448..6306f86ab30 100644 --- a/packages/shared/src/components/post/common.tsx +++ b/packages/shared/src/components/post/common.tsx @@ -97,6 +97,8 @@ export interface PostContentProps * extensions. */ getWidgetRailAd?: (position: PostWidgetPosition) => ReactNode; + /** Rendered at the very top of the widget column. */ + widgetsLeading?: ReactNode; /** * Replaces the default TLDR paragraph so an ad template can interleave * units between summary segments. Like every ad prop here: only the diff --git a/packages/shared/src/components/post/preferredSources/PreferGoogleButton.tsx b/packages/shared/src/components/post/preferredSources/PreferGoogleButton.tsx new file mode 100644 index 00000000000..bba393cb274 --- /dev/null +++ b/packages/shared/src/components/post/preferredSources/PreferGoogleButton.tsx @@ -0,0 +1,66 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { ButtonProps } from '../../buttons/Button'; +import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { GoogleIcon } from '../../icons'; +import { + getPreferredSourceUrl, + DAILY_DEV_DOMAIN, +} from '../../../lib/preferredSources'; + +export type PreferGoogleButtonProps = Pick< + ButtonProps<'button'>, + 'size' | 'variant' +> & { + label?: string; + className?: string; + /** + * Falls back to the deeplink instead of Google's script. Required anywhere + * the script cannot run or has not initialised. + */ + useDeeplink?: boolean; + isReady?: boolean; + onAdd?: () => void; +}; + +/** + * Adds daily.dev to the reader's Google preferred sources. + * + * Google's own button renders in an iframe we cannot theme, so this drives the + * documented JS API from our own `Button` — same outcome, our design system. + * Google sets no wording rule for a custom badge; the binding constraint is the + * G mark itself, which must stay full-colour and unmodified. + */ +export function PreferGoogleButton({ + label = 'Add as preferred source', + size = ButtonSize.Small, + variant = ButtonVariant.Primary, + className, + useDeeplink = false, + isReady = true, + onAdd, +}: PreferGoogleButtonProps): ReactElement { + const linkProps = useDeeplink + ? ({ + tag: 'a', + href: getPreferredSourceUrl(DAILY_DEV_DOMAIN), + target: '_blank', + rel: 'noopener noreferrer', + } as const) + : {}; + + return ( + + ); +} diff --git a/packages/shared/src/components/post/preferredSources/PreferGoogleSourceAction.tsx b/packages/shared/src/components/post/preferredSources/PreferGoogleSourceAction.tsx new file mode 100644 index 00000000000..d3d8611c2ee --- /dev/null +++ b/packages/shared/src/components/post/preferredSources/PreferGoogleSourceAction.tsx @@ -0,0 +1,42 @@ +import type { ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import { ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { usePreferredSource } from '../../../hooks/usePreferredSource'; +import { PreferGoogleButton } from './PreferGoogleButton'; + +/** + * The widget-column ask, at the very top of the rail — above the source card, + * the signup widget and the ad slot. No margin of its own: PageWidgets already + * sets the gap between rail items, and an extra one here would push the whole + * column down. + */ +export function PreferGoogleSourceAction(): ReactElement | null { + const { isEligible, isReady, useDeeplink, onAdd, onImpression } = + usePreferredSource({ + placement: 'post widgets', + }); + + useEffect(() => { + if (isEligible) { + onImpression(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- once per appearance + }, [isEligible]); + + if (!isEligible) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/packages/shared/src/components/post/preferredSources/PreferredSearchCard.tsx b/packages/shared/src/components/post/preferredSources/PreferredSearchCard.tsx new file mode 100644 index 00000000000..18d07c96760 --- /dev/null +++ b/packages/shared/src/components/post/preferredSources/PreferredSearchCard.tsx @@ -0,0 +1,146 @@ +import type { ReactElement } from 'react'; +import React, { useEffect } from 'react'; +import classNames from 'classnames'; +import { Card, CardTitle } from '../../cards/common/Card'; +import { Header } from '../../marketing/cta/common'; +import { ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { GoogleIcon } from '../../icons'; +import LogoIcon from '../../../svg/LogoIcon'; +import { usePreferredSource } from '../../../hooks/usePreferredSource'; +import { PreferGoogleButton } from './PreferGoogleButton'; + +/** + * The daily.dev favicon, rebuilt in CSS: the shipped icon is a PNG in each + * app's `public/`, which a shared component cannot reach, and this preview + * needs the real one — Google puts a site's actual favicon beside its result, + * so the bare white logo mark read as neither Google nor daily.dev. + * + * The two colours are sampled from `favicon-32x32.png` and are deliberately + * literals: they mirror an image asset, not a theme token, so they must not + * follow the app's light/dark switch. + */ +const Favicon = (): ReactElement => ( + + + +); + +/** + * A glimpse of a Google results page with daily.dev marked Preferred. Drawn in + * CSS rather than shipped as an asset: it stays crisp at any density and costs + * the feed no image request. The G mark is Google's own, unmodified. + * + * Deliberately light in both themes. This is a picture *of Google*, not a piece + * of our UI — themed dark it stopped reading as a search result at all, which + * is the one job it has. Hence the literal colours: Google's own result palette + * rather than our tokens, which would flip with the app. + */ +export const SearchPreview = ({ + className, + query = 'cursor agent mode review', + title = 'Cursor agent mode: three weeks in production', +}: { + className?: string; + query?: string; + title?: string; +}): ReactElement => ( +
+
+ + + {query} + +
+
+
+ + + daily.dev + + + Preferred + +
+ + {title} + + + +
+ + +
+
+
+); + +/** + * Fills a feed ad position the ad server could not fill. It never takes a slot + * from real content — only one that would otherwise render a grey placeholder. + */ +export function PreferredSearchCard({ + fallback = null, +}: { + /** Rendered instead when the prompt is not eligible — e.g. the placeholder. */ + fallback?: ReactElement | null; +} = {}): ReactElement | null { + const { isEligible, isReady, useDeeplink, onAdd, onDismiss, onImpression } = + usePreferredSource({ placement: 'feed ad fallback' }); + + useEffect(() => { + if (isEligible) { + onImpression(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- once per appearance + }, [isEligible]); + + if (!isEligible) { + return fallback; + } + + return ( + +
+ + See daily.dev in your Google results + + + + + ); +} diff --git a/packages/shared/src/components/post/preferredSources/PreferredSourceSetting.tsx b/packages/shared/src/components/post/preferredSources/PreferredSourceSetting.tsx new file mode 100644 index 00000000000..92908032fed --- /dev/null +++ b/packages/shared/src/components/post/preferredSources/PreferredSourceSetting.tsx @@ -0,0 +1,51 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { ButtonSize, ButtonVariant } from '../../buttons/Button'; +import { useGooglePreferredSource } from '../../../hooks/useGooglePreferredSource'; +import { PreferGoogleButton } from './PreferGoogleButton'; + +/** + * The permanent home for the ask. + * + * Every other surface goes quiet after one click or one dismissal, which would + * otherwise leave a reader who changed their mind with no way back. This row + * ignores that state and is always available. + * + * A button, not a toggle: Google exposes no way to read whether the reader + * already added us, and a switch would promise a state we cannot show. + */ +export function PreferredSourceSetting(): ReactElement { + const { isReady, hasFailed, addPreferredSource } = useGooglePreferredSource(); + + return ( +
+
+ + Preferred source on Google + + + Show daily.dev more often in Top Stories and AI Overviews. + +
+ +
+ ); +} diff --git a/packages/shared/src/components/post/preferredSources/index.ts b/packages/shared/src/components/post/preferredSources/index.ts new file mode 100644 index 00000000000..5e27fb18ad0 --- /dev/null +++ b/packages/shared/src/components/post/preferredSources/index.ts @@ -0,0 +1,4 @@ +export * from './PreferGoogleButton'; +export * from './PreferGoogleSourceAction'; +export * from './PreferredSearchCard'; +export * from './PreferredSourceSetting'; diff --git a/packages/shared/src/hooks/useGooglePreferredSource.ts b/packages/shared/src/hooks/useGooglePreferredSource.ts new file mode 100644 index 00000000000..dc46f9cc0b6 --- /dev/null +++ b/packages/shared/src/hooks/useGooglePreferredSource.ts @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ThemeMode, useSettingsContext } from '../contexts/SettingsContext'; +import { + PREFERRED_SOURCE_SCRIPT_ID, + PREFERRED_SOURCE_SRC, + PREFERRED_SOURCE_TIMEOUT_MS, +} from '../lib/preferredSources'; + +type PreferredSourceTheme = 'light' | 'dark'; + +type PreferredSourceApi = { + init: (options: { theme?: PreferredSourceTheme; lang?: string }) => void; + addPreferredSource: () => void; +}; + +declare global { + // eslint-disable-next-line no-var, vars-on-top + var PREFERRED_SOURCE: Array<(api: PreferredSourceApi) => void> | undefined; +} + +const resolveTheme = (mode: ThemeMode): PreferredSourceTheme => { + if (mode === ThemeMode.Auto) { + return globalThis.matchMedia?.('(prefers-color-scheme: light)').matches + ? 'light' + : 'dark'; + } + + return mode === ThemeMode.Light ? 'light' : 'dark'; +}; + +/** + * Loads Google's publisher script in `manual` mode and hands back a trigger. + * + * Manual mode is not optional for us. The documented drop-in — an empty + * `
` — is scanned once, when the script + * loads, and every post page in the webapp is reached by a client-side route + * change long after that. The auto-scan would find nothing. Manual mode also + * lets us keep our own `Button` instead of the iframe Google renders, which + * cannot inherit our tokens and adds a per-embed layout shift. + * + * The script is fetched on demand rather than from ``, so a post page that + * never shows the widget never pays for it. + */ +export const useGooglePreferredSource = ({ + enabled = true, + lang, +}: { + enabled?: boolean; + lang?: string; +} = {}): { + isReady: boolean; + hasFailed: boolean; + addPreferredSource: () => void; +} => { + const { themeMode } = useSettingsContext(); + const [isReady, setIsReady] = useState(false); + const [hasFailed, setHasFailed] = useState(false); + const apiRef = useRef(); + // Read at callback time, not closed over: the effect must not re-run when the + // reader flips the theme, because every run pushes another callback onto a + // queue with no way to remove one. + const themeRef = useRef(themeMode); + themeRef.current = themeMode; + + useEffect(() => { + if (!enabled || typeof window === 'undefined') { + return undefined; + } + + let cancelled = false; + let timeout: ReturnType; + + const onApi = (api: PreferredSourceApi) => { + if (cancelled) { + return; + } + + // Cancel the failure timer first: it fires on a wall clock, so without + // this a script that simply loaded slowly would still be reported as + // blocked and every surface would drop to the deeplink. + clearTimeout(timeout); + apiRef.current = api; + api.init({ theme: resolveTheme(themeRef.current), lang }); + setIsReady(true); + setHasFailed(false); + }; + + globalThis.PREFERRED_SOURCE = globalThis.PREFERRED_SOURCE || []; + globalThis.PREFERRED_SOURCE.push(onApi); + + // `news.google.com/swg/...` is exactly the shape of host a content blocker + // eats, and a blocked script fires no error in every browser — so the + // timeout is the real detector and `onerror` only makes it faster. Without + // one of them the button stays disabled forever, which is worse than not + // offering it: callers switch to the deeplink, which needs no script. + const fail = () => { + if (!cancelled && !apiRef.current) { + setHasFailed(true); + } + }; + timeout = setTimeout(fail, PREFERRED_SOURCE_TIMEOUT_MS); + + if (!document.getElementById(PREFERRED_SOURCE_SCRIPT_ID)) { + const script = document.createElement('script'); + script.id = PREFERRED_SOURCE_SCRIPT_ID; + script.src = PREFERRED_SOURCE_SRC; + script.async = true; + script.setAttribute('preferred-sources-control', 'manual'); + script.addEventListener('error', fail); + document.head.appendChild(script); + } + + return () => { + cancelled = true; + clearTimeout(timeout); + }; + }, [enabled, lang]); + + const addPreferredSource = useCallback(() => { + apiRef.current?.addPreferredSource(); + }, []); + + return { isReady, hasFailed, addPreferredSource }; +}; diff --git a/packages/shared/src/hooks/usePreferredSource.ts b/packages/shared/src/hooks/usePreferredSource.ts new file mode 100644 index 00000000000..e478bcfa5d6 --- /dev/null +++ b/packages/shared/src/hooks/usePreferredSource.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useConditionalFeature } from './useConditionalFeature'; +import usePersistentContext from './usePersistentContext'; +import { useAuthContext } from '../contexts/AuthContext'; +import { useLogContext } from '../contexts/LogContext'; +import { featurePreferredSource } from '../lib/featureManagement'; +import { LogEvent, TargetType } from '../lib/log'; +import type { PreferredSourceState } from '../lib/preferredSources'; +import { + PREFERRED_SOURCE_FORCE_KEY, + PREFERRED_SOURCE_STATE_KEY, +} from '../lib/preferredSources'; +import { useGooglePreferredSource } from './useGooglePreferredSource'; + +export type UsePreferredSourceProps = { + /** Which surface is asking. Goes out with every event. */ + placement: string; + /** Extra gate on top of the flag and the global state. */ + shouldEvaluate?: boolean; +}; + +export type UsePreferredSource = { + /** The flag is on and the reader has not answered yet. */ + isEligible: boolean; + isReady: boolean; + /** + * Google's script never arrived. Render the deeplink instead — it needs no + * script, so the ask still works for a reader running a content blocker. + */ + useDeeplink: boolean; + /** Opens Google's flow, logs the click and silences every other surface. */ + onAdd: () => void; + /** Silences every surface without opening Google. */ + onDismiss: () => void; + onImpression: () => void; +}; + +/** + * The one gate every Preferred Sources surface goes through. + * + * Because Google has no read API, "already added" is our own optimistic state: + * a reader who clicks is treated as done even if they abandon Google's dialog. + * That is the right trade — asking again is worse than counting one non-answer + * as a yes — and it is why the settings row exists as a permanent way back in. + */ +export const usePreferredSource = ({ + placement, + shouldEvaluate = true, +}: UsePreferredSourceProps): UsePreferredSource => { + const router = useRouter(); + // REVIEW AFFORDANCE — remove before merge. The flag is off by default, so a + // Vercel preview would show nothing; `?preferredSource=1` forces the gate on + // so the placements can be reviewed without a GrowthBook rule. + // + // Sticky for the tab, deliberately: the param survives a full page load but + // not client-side navigation, so opening a post from the feed (which is a + // modal over the feed, with no query string of its own) would silently drop + // it and the reviewer would see nothing. sessionStorage carries it across + // every route until the tab closes. + // + // Read in an effect rather than during render: the server has no + // sessionStorage, so reading it inline would make the first client render + // disagree with the server HTML and trip a hydration error. + const [isForced, setIsForced] = useState(false); + const isForcedParam = router?.query?.preferredSource === '1'; + + useEffect(() => { + if (isForcedParam) { + globalThis.sessionStorage?.setItem(PREFERRED_SOURCE_FORCE_KEY, '1'); + setIsForced(true); + return; + } + + setIsForced( + globalThis.sessionStorage?.getItem(PREFERRED_SOURCE_FORCE_KEY) === '1', + ); + }, [isForcedParam]); + const { isAuthReady } = useAuthContext(); + const { logEvent } = useLogContext(); + const [state, setState, isStateLoaded] = + usePersistentContext( + PREFERRED_SOURCE_STATE_KEY, + null, + ); + + // Deliberately not gated on being signed in. Post pages and the feed are + // public, and a reader who arrived from Google — the one person for whom + // this ask is self-interested rather than a favour — is usually signed out. + // Capping is local-storage based, so it works for them too. + const gate = isAuthReady && shouldEvaluate; + const { value: isEnabled } = useConditionalFeature({ + feature: featurePreferredSource, + shouldEvaluate: gate, + }); + + const isEligible = + gate && (!!isEnabled || isForced) && isStateLoaded && !state; + + const { isReady, hasFailed, addPreferredSource } = useGooglePreferredSource({ + enabled: isEligible, + }); + + const onImpression = useCallback(() => { + logEvent({ + event_name: LogEvent.ImpressionPreferredSource, + target_type: TargetType.PreferredSource, + target_id: placement, + }); + }, [logEvent, placement]); + + const onAdd = useCallback(() => { + logEvent({ + event_name: LogEvent.ClickPreferredSource, + target_type: TargetType.PreferredSource, + target_id: placement, + }); + addPreferredSource(); + setState('added'); + }, [addPreferredSource, logEvent, placement, setState]); + + const onDismiss = useCallback(() => { + logEvent({ + event_name: LogEvent.DismissPreferredSource, + target_type: TargetType.PreferredSource, + target_id: placement, + }); + setState('dismissed'); + }, [logEvent, placement, setState]); + + return { + isEligible, + isReady, + useDeeplink: hasFailed, + onAdd, + onDismiss, + onImpression, + }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 090bda416d2..ad763391119 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -354,3 +354,8 @@ export const featureSidebarTourExistingBefore = new Feature( 'sidebar_tour_existing_before', '', ); + +// Google Preferred Sources. One flag for every surface: the ask is the same +// ask everywhere, and the capping is global, so splitting it per placement +// would let a reader meet it twice after silencing it once. +export const featurePreferredSource = new Feature('preferred_source', false); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 1ef0830f678..6d32b6b9c6f 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -115,6 +115,12 @@ export enum LogEvent { EmptyAdsenseSlot = 'empty adsense slot', AdsenseSlotError = 'adsense slot error', AdsenseTestMode = 'adsense test mode', + // Google Preferred Sources. Google reports nothing back — no read API, and + // no Search Console dimension — so these two events are the only measurement + // this feature will ever have. + ImpressionPreferredSource = 'impression preferred source', + ClickPreferredSource = 'click preferred source', + DismissPreferredSource = 'dismiss preferred source', OpenSmartComposer = 'open smart composer', CloseSmartComposer = 'close smart composer', SubmitSmartComposer = 'submit smart composer', @@ -555,6 +561,7 @@ export enum TargetType { PromotionalBanner = 'promotion_banner', MarketingCtaPopover = 'promotion_popover', MarketingCtaPopoverSmall = 'promotion_popover_small', + PreferredSource = 'preferred source', MarketingCtaPlus = 'promotion_plus', MarketingCtaBrief = 'promotion_briefing', MarketingCtaHelpGuide = 'promotion_help_guide', diff --git a/packages/shared/src/lib/preferredSources.ts b/packages/shared/src/lib/preferredSources.ts new file mode 100644 index 00000000000..8072ad264e0 --- /dev/null +++ b/packages/shared/src/lib/preferredSources.ts @@ -0,0 +1,86 @@ +/** + * Google Preferred Sources — https://developers.google.com/search/docs/appearance/preferred-sources + * + * A reader can mark a site as "preferred" and Google then surfaces it more often + * in Top Stories, AI Overviews and AI Mode. Two integration routes exist, and the + * difference between them decides everything about where we can put this: + * + * - The official JS button adds *the domain that hosts the button*. On daily.dev + * that is always daily.dev, never the publisher whose article the reader is on. + * - The deeplink takes a `q` param, so it can target *any* domain — including the + * source of the post being read. + * + * Only domain and subdomain level sites are eligible; a path like example.com/blog + * is not, which is why `normalizePreferredSourceDomain` rejects anything with one. + */ + +export const PREFERRED_SOURCE_SCRIPT_ID = 'google-preferred-source'; +export const PREFERRED_SOURCE_SRC = + 'https://news.google.com/swg/js/v1/publisher.js'; + +/** How long to wait for Google's script before falling back to the deeplink. */ +export const PREFERRED_SOURCE_TIMEOUT_MS = 4000; +export const PREFERRED_SOURCE_DEEPLINK = + 'https://www.google.com/preferences/source'; + +export const DAILY_DEV_DOMAIN = 'daily.dev'; + +/** + * Reduces whatever the API handed us — a bare host, a full URL, a host with + * `www.` — to the host Google expects, or null when the value can never be + * eligible (empty, path-bearing, or not a hostname at all). + */ +export const normalizePreferredSourceDomain = ( + value?: string, +): string | null => { + if (!value) { + return null; + } + + const trimmed = value.trim().toLowerCase(); + + if (!trimmed) { + return null; + } + + let host = trimmed; + + if (host.includes('://')) { + try { + host = new URL(host).hostname; + } catch { + return null; + } + } else if (host.includes('/')) { + // A bare host with a path is a subdirectory, which Google does not accept. + return null; + } + + host = host.replace(/^www\./, ''); + + // Hostname, not an IP or a single label: at least one dot, no spaces, and a + // TLD of two or more letters. + if (!/^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/.test(host)) { + return null; + } + + return host; +}; + +export const getPreferredSourceUrl = (domain: string): string => + `${PREFERRED_SOURCE_DEEPLINK}?q=${encodeURIComponent(domain)}`; + +/** + * One key for every surface. Google exposes no way to read whether a reader + * already added us, so this is the only "done" signal we will ever have: it is + * written optimistically on click, and it silences every prompt at once. + */ +export const PREFERRED_SOURCE_STATE_KEY = 'preferred_source_state'; + +/** REVIEW AFFORDANCE — remove before merge. See usePreferredSource. */ +export const PREFERRED_SOURCE_FORCE_KEY = 'preferred_source_force'; + +export type PreferredSourceState = 'added' | 'dismissed'; + +/** Identifies our own notification / quest so the frontend can decorate it. */ +export const PREFERRED_SOURCE_REFERENCE_ID = 'google_preferred_source'; diff --git a/packages/storybook/public/preferred-source-badge.png b/packages/storybook/public/preferred-source-badge.png new file mode 100644 index 00000000000..45394d5e663 Binary files /dev/null and b/packages/storybook/public/preferred-source-badge.png differ diff --git a/packages/storybook/public/preferred-source-cover.png b/packages/storybook/public/preferred-source-cover.png new file mode 100644 index 00000000000..92516f020dc Binary files /dev/null and b/packages/storybook/public/preferred-source-cover.png differ diff --git a/packages/storybook/stories/preferred-sources/Measurement.stories.tsx b/packages/storybook/stories/preferred-sources/Measurement.stories.tsx new file mode 100644 index 00000000000..e9ff653c103 --- /dev/null +++ b/packages/storybook/stories/preferred-sources/Measurement.stories.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Bullets, + CodeBlock, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, +} from '../open-graph/ogStoryLayout'; + +const Measurement = (): React.ReactElement => ( + + + Worth being blunt before anyone builds a business case on this: Google + publishes almost nothing that a publisher can act on, and the one headline + number it does publish is unfalsifiable as stated. + + + The public numbers, and what each is worth + + + So the honest framing for whichever option we ship: this is a goodwill and + positioning bet with a plausible traffic upside, not a measurable traffic + channel. If we present it internally as the latter, we will be asked for + numbers that do not exist. + + + + + What we can measure ourselves + + Our own click is the only number anyone will have, which makes + instrumenting it non-optional. Both components take an{' '} + onClick/onAdd callback for exactly this. + + {`// LogEvent additions +ClickPreferredSource = 'click preferred source', +ImpressionPreferredSource = 'impression preferred source', + +// extra: { target: 'dailydev' | 'source', placement, copy, domain? } +// placement: 'post_page' | 'new_tab' | 'sidebar' | 'settings' | 'footer' | ...`} + + + + + Three ways this goes wrong + + + The one that is not a UI change + + Google has now publicly endorsed the idea daily.dev has been arguing since + day one: readers should choose their sources, rather than have an + algorithm choose for them. Google shipped a preferences panel buried in + search settings; we shipped a whole product. That comparison writes + itself, and it is the cheapest and highest-ceiling thing on this page — an + email, a blog post, and a campaign, with no engineering dependency at all. + + +); + +const meta: Meta = { + title: 'Preferred Sources/5. Measurement & Risks', + component: Measurement, + parameters: { layout: 'fullscreen' }, +}; + +export default meta; + +export const Default: StoryObj = { name: 'Measurement' }; diff --git a/packages/storybook/stories/preferred-sources/Mechanism.stories.tsx b/packages/storybook/stories/preferred-sources/Mechanism.stories.tsx new file mode 100644 index 00000000000..acc76a99128 --- /dev/null +++ b/packages/storybook/stories/preferred-sources/Mechanism.stories.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Bullets, + CodeBlock, + Divider, + Heading, + Muted, + Page, + PageHeader, + SpecTable, +} from '../open-graph/ogStoryLayout'; + +const Mechanism = (): React.ReactElement => ( + + + Everything below is from Google’s Search Central documentation for + publishers, checked against how our webapp is actually put together. The + gotchas are the reason we cannot use the copy-paste snippet Google leads + with. + + + + The official button — adds the hosting domain + + + Google’s headline integration is two lines. It renders a localised, + Google-styled button that adds the current site and returns the reader to + the page they were on. + + {` + + + +
`}
+ + data-theme takes light (default) or{' '} + dark; data-lang overrides the auto-detected + language. There is no attribute for which domain to add — the + script reads the origin it is running on. + + + The deeplink — adds any domain + + A plain URL into Google’s source preferences tool. No script, no iframe, + no third-party bytes, and the q parameter takes any eligible + host. + + {`https://www.google.com/preferences/source?q=towardsdatascience.com`} + + This is the only route that can express “prefer the publisher of this + post” from a daily.dev page. It costs a tab switch instead of an in-page + confirmation — the one real downside versus the script. + + + + + The four gotchas + + script has run. An empty
mounted by React is never scanned, so nothing renders.', + 'Load with preferred-sources-control="manual" and drive it from the PREFERRED_SOURCE callback queue, calling addPreferredSource() from our own click handler.', + ], + [ + 'The rendered button is an iframe — one per embed', + 'It cannot inherit our tokens, typography or radii, it will not match the buttons beside it, and it reserves no space until it loads, so it shifts the sidebar.', + 'Use the advanced JS API with our own