diff --git a/AGENTS.md b/AGENTS.md
index 5d8910213..f191beab4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -90,4 +90,5 @@ bin/skeleton/cli.py ../src/content/ko/path/to/file.mdx
- 작업 완료로 간주하기 전에 관련 targeted check를 실행합니다.
- 문서 또는 skill만 변경한 경우 `git diff --check`와 관련 경로 검색을 우선합니다.
- 코드 변경은 변경된 동작에 직접 연결되는 테스트를 먼저 실행하고, 위험도에 따라 lint, typecheck, build를 추가합니다.
+- 웹 렌더링 또는 브라우저 테스트는 사용자가 visible browser를 명시적으로 요청하지 않는 한 headless mode로 수행합니다.
- 사용자가 명시적으로 요청하지 않는 한 local dev server를 시작하지 않습니다.
diff --git a/public/spotlight/ai-work-os-enterprise-intelligence/thumbnail.png b/public/spotlight/ai-work-os-enterprise-intelligence/thumbnail.png
new file mode 100644
index 000000000..0abfe44cf
Binary files /dev/null and b/public/spotlight/ai-work-os-enterprise-intelligence/thumbnail.png differ
diff --git a/public/spotlight/iso-42001-certification/thumbnail.png b/public/spotlight/iso-42001-certification/thumbnail.png
new file mode 100644
index 000000000..3ef0b0ab8
Binary files /dev/null and b/public/spotlight/iso-42001-certification/thumbnail.png differ
diff --git a/public/spotlight/lingo-release/hero-en.png b/public/spotlight/lingo-release/hero-en.png
new file mode 100644
index 000000000..aad12865d
Binary files /dev/null and b/public/spotlight/lingo-release/hero-en.png differ
diff --git a/public/spotlight/lingo-release/hero-ja.png b/public/spotlight/lingo-release/hero-ja.png
new file mode 100644
index 000000000..264a04d9e
Binary files /dev/null and b/public/spotlight/lingo-release/hero-ja.png differ
diff --git a/public/spotlight/lingo-release/hero-ko.png b/public/spotlight/lingo-release/hero-ko.png
new file mode 100644
index 000000000..86d4e30ef
Binary files /dev/null and b/public/spotlight/lingo-release/hero-ko.png differ
diff --git a/public/spotlight/notepie-release/hero-en.png b/public/spotlight/notepie-release/hero-en.png
new file mode 100644
index 000000000..7d60ac086
Binary files /dev/null and b/public/spotlight/notepie-release/hero-en.png differ
diff --git a/public/spotlight/notepie-release/hero-ja.png b/public/spotlight/notepie-release/hero-ja.png
new file mode 100644
index 000000000..4a3b85ca8
Binary files /dev/null and b/public/spotlight/notepie-release/hero-ja.png differ
diff --git a/public/spotlight/notepie-release/hero-ko.png b/public/spotlight/notepie-release/hero-ko.png
new file mode 100644
index 000000000..7c59997ed
Binary files /dev/null and b/public/spotlight/notepie-release/hero-ko.png differ
diff --git a/src/app/[lang]/internal/internal-page.module.css b/src/app/[lang]/internal/internal-page.module.css
new file mode 100644
index 000000000..227070bef
--- /dev/null
+++ b/src/app/[lang]/internal/internal-page.module.css
@@ -0,0 +1,12 @@
+.internalPreviewGrid {
+ display: grid;
+ align-items: start;
+ gap: 32px;
+ grid-template-columns: minmax(0, 1fr) minmax(240px, 288px);
+}
+
+@media (max-width: 1023px) {
+ .internalPreviewGrid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/src/app/[lang]/internal/page.test.tsx b/src/app/[lang]/internal/page.test.tsx
index a49590d99..f9a4b6a6d 100644
--- a/src/app/[lang]/internal/page.test.tsx
+++ b/src/app/[lang]/internal/page.test.tsx
@@ -8,7 +8,7 @@ describe('/[lang]/internal page', () => {
expect(generateStaticParams()).toEqual([{ lang: 'en' }, { lang: 'ja' }, { lang: 'ko' }]);
});
- it('renders the Korean internal page title and description only', async () => {
+ it('renders the Korean internal page title, description, and spotlight preview', async () => {
const container = document.createElement('div');
const result = render(await InternalPage({ params: Promise.resolve({ lang: 'ko' }) }), {
baseElement: container,
@@ -20,6 +20,18 @@ describe('/[lang]/internal page', () => {
within(result.container).getByText('검토와 구현 참고를 위해 유지 중인 내부 컴포넌트 및 페이지 예시 목록입니다.'),
).toBeTruthy();
expect(within(result.container).queryByRole('list')).not.toBeInTheDocument();
+ expect(within(result.container).getByTestId('docs-spotlight-card')).toBeTruthy();
+ });
+
+ it('exposes the localized spotlight preview on the internal page', async () => {
+ const container = document.createElement('div');
+ const result = render(await InternalPage({ params: Promise.resolve({ lang: 'ko' }) }), {
+ baseElement: container,
+ container,
+ });
+
+ expect(within(result.container).getByTestId('docs-spotlight-card')).toBeTruthy();
+ expect(within(result.container).getByText('AI Work OS: 새로운 지능이 기업 안에서 일하는 방식')).toBeTruthy();
});
it('uses the same copy for route metadata', async () => {
diff --git a/src/app/[lang]/internal/page.tsx b/src/app/[lang]/internal/page.tsx
index b5e94accd..529aa896f 100644
--- a/src/app/[lang]/internal/page.tsx
+++ b/src/app/[lang]/internal/page.tsx
@@ -1,4 +1,6 @@
import type { Metadata } from 'next';
+import { DocsSpotlightSidebar } from '@/components/docs-spotlight-sidebar';
+import styles from './internal-page.module.css';
type InternalPageParams = {
lang: string;
@@ -66,29 +68,34 @@ export default async function InternalPage({ params }: InternalPageProps) {
padding: '64px 24px',
}}
>
-
- {copy.title}
-
-
- {copy.description}
-
+
+
+
+ {copy.title}
+
+
+ {copy.description}
+
+
+
+
);
}
diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx
index 9703dc749..76e248ef6 100644
--- a/src/app/[lang]/layout.tsx
+++ b/src/app/[lang]/layout.tsx
@@ -1,4 +1,3 @@
-/* eslint-env node */
import { Footer, Layout, Navbar } from 'nextra-theme-docs';
import { Head } from 'nextra/components';
import { getPageMap } from 'nextra/page-map';
@@ -10,6 +9,8 @@ import { LastUpdated } from '@/components/last-updated';
import LanguageSelector2 from "@/components/language-selector2";
import ConfluenceSourceLink from "@/components/confluence-source-link";
import { QueryPieLogo } from '@/components/querypie-logo';
+import { DocsSpotlightSidebar } from '@/components/docs-spotlight-sidebar';
+import { filterDynamicPageMapRoutes } from '@/lib/nextra-page-map';
const defaultMetadata: Metadata = {
title: {
@@ -53,7 +54,7 @@ export default async function RootLayout({ children, params }) {
/>
);
- const pageMap = await getPageMap(`/${lang || 'en'}`);
+ const pageMap = filterDynamicPageMapRoutes(await getPageMap(`/${lang || 'en'}`));
return (
@@ -85,7 +86,12 @@ export default async function RootLayout({ children, params }) {
On This Page
>
),
- extraContent: ,
+ extraContent: (
+ <>
+
+
+ >
+ ),
}}
lastUpdated={}
>
diff --git a/src/components/docs-spotlight-card.module.css b/src/components/docs-spotlight-card.module.css
new file mode 100644
index 000000000..6b921cf8e
--- /dev/null
+++ b/src/components/docs-spotlight-card.module.css
@@ -0,0 +1,180 @@
+.spotlightCard {
+ margin-bottom: 16px;
+ width: min(100%, 288px);
+}
+
+.spotlightShell {
+ overflow: hidden;
+ border: 1px solid #d4d8e0;
+ border-radius: 8px;
+ background: #ffffff;
+ box-shadow: 0 12px 28px rgba(16, 24, 40, 0.12);
+ padding: 16px;
+}
+
+.spotlightHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.spotlightKicker {
+ overflow-wrap: anywhere;
+ color: #45464d;
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: 0;
+ line-height: 18px;
+}
+
+.iconButton {
+ display: inline-flex;
+ width: 28px;
+ height: 28px;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #d4d8e0;
+ border-radius: 999px;
+ background: #ffffff;
+ color: #45464d;
+ cursor: pointer;
+ padding: 0;
+ transition:
+ border-color 140ms ease,
+ color 140ms ease;
+}
+
+.iconButton:hover {
+ border-color: #111318;
+ color: #111318;
+}
+
+.icon {
+ width: 16px;
+ height: 16px;
+}
+
+.spotlightContent {
+ display: grid;
+ gap: 12px;
+ color: inherit;
+ text-decoration: none;
+}
+
+.spotlightImageFrame {
+ display: block;
+ height: 128px;
+ overflow: hidden;
+ border: 1px solid rgba(212, 216, 224, 0.7);
+ border-radius: 6px;
+ background: #f3f5f8;
+}
+
+.spotlightImage {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 220ms ease;
+}
+
+.spotlightContent:hover .spotlightImage {
+ transform: scale(1.02);
+}
+
+.spotlightCopy {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.spotlightTitle {
+ display: -webkit-box;
+ overflow: hidden;
+ color: #191c1e;
+ font-size: 15px;
+ font-weight: 600;
+ letter-spacing: 0;
+ line-height: 21px;
+ overflow-wrap: anywhere;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.spotlightMeta {
+ color: #5b616b;
+ font-size: 12px;
+ font-weight: 400;
+ letter-spacing: 0;
+ line-height: 17px;
+}
+
+.spotlightCta {
+ display: flex;
+ min-height: 36px;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #111318;
+ border-radius: 6px;
+ color: #111318;
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 0;
+ line-height: 18px;
+ transition:
+ background 140ms ease,
+ color 140ms ease;
+}
+
+.spotlightContent:hover .spotlightCta {
+ background: #111318;
+ color: #ffffff;
+}
+
+.spotlightFooter {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 16px;
+ border-top: 1px solid rgba(212, 216, 224, 0.8);
+ padding-top: 12px;
+}
+
+.spotlightIndicators {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.spotlightIndicator {
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: #c6cbd4;
+}
+
+.activeSpotlightIndicator {
+ background: #111318;
+}
+
+.spotlightControls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.iconButton:focus-visible,
+.spotlightContent:focus-visible {
+ outline: 2px solid rgba(17, 19, 24, 0.28);
+ outline-offset: 4px;
+}
+
+@media (max-width: 1023px) {
+ .spotlightCard {
+ display: none;
+ }
+}
diff --git a/src/components/docs-spotlight-card.test.tsx b/src/components/docs-spotlight-card.test.tsx
new file mode 100644
index 000000000..9d721ee1d
--- /dev/null
+++ b/src/components/docs-spotlight-card.test.tsx
@@ -0,0 +1,180 @@
+import { act, fireEvent, render, within } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { DocsSpotlightCard } from './docs-spotlight-card';
+import { DOCS_SPOTLIGHT_STORAGE_KEY } from '@/lib/docs-spotlight/storage';
+import type { ActiveDocsSpotlightContent } from '@/lib/docs-spotlight/content';
+
+const items: ActiveDocsSpotlightContent['items'] = [
+ {
+ id: 'iso-42001-certification',
+ date: '2026-06-04',
+ href: '/news/24/iso-42001-certification-announcement',
+ imageAlt: 'ISO 42001 announcement',
+ imageSrc: '/spotlight/iso-42001-certification/thumbnail.png',
+ title: 'ISO/IEC 42001 Certification for AI Management System',
+ spotlightMeta: 'June 4, 2026',
+ },
+ {
+ id: 'lingo-release',
+ date: '2026-06-05',
+ href: '/news/26/lingo-launch',
+ imageAlt: 'Lingo launch',
+ imageSrc: '/spotlight/lingo-release/hero-en.png',
+ title: 'Lingo: AI Real-Time Interpretation Service',
+ spotlightMeta: 'June 5, 2026',
+ },
+];
+
+const content: ActiveDocsSpotlightContent = {
+ ariaLabel: 'Latest company announcements',
+ items,
+ nextLabel: 'Next announcement',
+ previousLabel: 'Previous announcement',
+ spotlightCtaLabel: 'Read full story',
+ spotlightDismissLabel: 'Dismiss spotlight',
+ spotlightLabel: 'Spotlight',
+};
+
+function readSpotlightStorageRecord(id: string) {
+ const rawValue = window.localStorage.getItem(DOCS_SPOTLIGHT_STORAGE_KEY);
+
+ if (!rawValue) {
+ throw new Error(`Expected ${DOCS_SPOTLIGHT_STORAGE_KEY} to exist`);
+ }
+
+ return JSON.parse(rawValue)[id];
+}
+
+function createMemoryStorage(): Storage {
+ const values = new Map();
+
+ return {
+ clear: () => values.clear(),
+ getItem: key => values.get(key) ?? null,
+ key: index => Array.from(values.keys())[index] ?? null,
+ get length() {
+ return values.size;
+ },
+ removeItem: key => values.delete(key),
+ setItem: (key, value) => values.set(key, String(value)),
+ } as Storage;
+}
+
+function clickLinkWithoutNavigation(element: HTMLElement) {
+ element.addEventListener('click', event => event.preventDefault(), { once: true });
+ fireEvent.click(element);
+}
+
+describe('DocsSpotlightCard', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-06-12T00:00:00.000Z'));
+ Object.defineProperty(window, 'localStorage', {
+ configurable: true,
+ value: createMemoryStorage(),
+ });
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn().mockReturnValue({
+ matches: false,
+ }),
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ });
+
+ it('renders the first active item after browser visibility state is checked', () => {
+ const container = document.createElement('div');
+ const result = render(, {
+ baseElement: container,
+ container,
+ });
+
+ expect(result.container.querySelector('[data-testid="docs-spotlight-card"]')).toBeTruthy();
+ expect(within(result.container).getByText('ISO/IEC 42001 Certification for AI Management System')).toBeTruthy();
+ expect(within(result.container).getByAltText('ISO 42001 announcement')).toHaveAttribute(
+ 'src',
+ '/spotlight/iso-42001-certification/thumbnail.png',
+ );
+
+ const link = within(result.container).getByRole('link', { name: /ISO\/IEC 42001/ });
+ expect(link).toHaveAttribute(
+ 'href',
+ expect.stringContaining('https://www.querypie.com/en/news/24/iso-42001-certification-announcement?'),
+ );
+ });
+
+ it('rotates, pauses on hover, and supports manual previous and next controls', () => {
+ const container = document.createElement('div');
+ const result = render(, {
+ baseElement: container,
+ container,
+ });
+
+ fireEvent.mouseEnter(within(result.container).getByTestId('docs-spotlight-card'));
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(within(result.container).getByText('ISO/IEC 42001 Certification for AI Management System')).toBeTruthy();
+
+ fireEvent.mouseLeave(within(result.container).getByTestId('docs-spotlight-card'));
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(within(result.container).getByText('Lingo: AI Real-Time Interpretation Service')).toBeTruthy();
+
+ fireEvent.click(within(result.container).getByRole('button', { name: 'Previous announcement' }));
+ expect(within(result.container).getByText('ISO/IEC 42001 Certification for AI Management System')).toBeTruthy();
+
+ fireEvent.click(within(result.container).getByRole('button', { name: 'Next announcement' }));
+ expect(within(result.container).getByText('Lingo: AI Real-Time Interpretation Service')).toBeTruthy();
+ });
+
+ it('stores viewed and dismissed records while keeping failures recoverable', () => {
+ const container = document.createElement('div');
+ const result = render(, {
+ baseElement: container,
+ container,
+ });
+
+ clickLinkWithoutNavigation(within(result.container).getByRole('link', { name: /ISO\/IEC 42001/ }));
+ expect(readSpotlightStorageRecord('iso-42001-certification')).toEqual({
+ disposition: 'viewed',
+ updatedAt: '2026-06-12T00:00:00.000Z',
+ expiresAt: '2026-07-12T00:00:00.000Z',
+ });
+
+ fireEvent.click(within(result.container).getByRole('button', { name: 'Dismiss spotlight' }));
+ expect(within(result.container).queryByTestId('docs-spotlight-card')).not.toBeInTheDocument();
+ });
+
+ it('renders nothing when all active items are suppressed', () => {
+ window.localStorage.setItem(
+ DOCS_SPOTLIGHT_STORAGE_KEY,
+ JSON.stringify({
+ 'iso-42001-certification': {
+ disposition: 'viewed',
+ updatedAt: '2026-06-01T00:00:00.000Z',
+ expiresAt: '2026-07-01T00:00:00.000Z',
+ },
+ 'lingo-release': {
+ disposition: 'dismissed',
+ updatedAt: '2026-06-01T00:00:00.000Z',
+ expiresAt: '2026-07-01T00:00:00.000Z',
+ },
+ }),
+ );
+
+ const container = document.createElement('div');
+ const result = render(, {
+ baseElement: container,
+ container,
+ });
+
+ expect(within(result.container).queryByTestId('docs-spotlight-card')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/docs-spotlight-card.tsx b/src/components/docs-spotlight-card.tsx
new file mode 100644
index 000000000..0457f9623
--- /dev/null
+++ b/src/components/docs-spotlight-card.tsx
@@ -0,0 +1,208 @@
+'use client';
+
+import { ChevronLeftIcon, ChevronRightIcon, XMarkIcon } from '@heroicons/react/24/outline';
+import type { FocusEvent } from 'react';
+import { useEffect, useRef, useState } from 'react';
+
+import type { ActiveDocsSpotlightContent, DocsSpotlightLocale } from '@/lib/docs-spotlight/content';
+import {
+ getDocsSpotlightBrowserLocalStorage,
+ readDocsSpotlightVisibilityState,
+ writeDocsSpotlightVisibilityRecord,
+ type DocsSpotlightDisposition,
+} from '@/lib/docs-spotlight/storage';
+import {
+ createDocsSpotlightTrackingHref,
+ sendDocsSpotlightClickEvent,
+ sendDocsSpotlightDismissEvent,
+ sendDocsSpotlightViewEvent,
+} from '@/lib/docs-spotlight/tracking';
+
+import styles from './docs-spotlight-card.module.css';
+
+type DocsSpotlightCardProps = {
+ content: ActiveDocsSpotlightContent | null;
+ locale: DocsSpotlightLocale;
+ rotationIntervalMs?: number;
+};
+
+const defaultRotationIntervalMs = 4000;
+const hiddenSpotlightCardViewportQuery = '(max-width: 1023px)';
+
+function prefersReducedMotion() {
+ return (
+ typeof globalThis.matchMedia === 'function' && globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches
+ );
+}
+
+function isSpotlightCardInVisibleViewport() {
+ return (
+ typeof globalThis.matchMedia !== 'function' || !globalThis.matchMedia(hiddenSpotlightCardViewportQuery).matches
+ );
+}
+
+export function DocsSpotlightCard({
+ content,
+ locale,
+ rotationIntervalMs = defaultRotationIntervalMs,
+}: DocsSpotlightCardProps) {
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [hasCheckedVisibilityState, setHasCheckedVisibilityState] = useState(false);
+ const [isPaused, setIsPaused] = useState(false);
+ const [isVisible, setIsVisible] = useState(true);
+ const [renderableItems, setRenderableItems] = useState(content?.items ?? []);
+ const viewedItemIdsRef = useRef>(new Set());
+ const activeItem = renderableItems[activeIndex] ?? renderableItems[0];
+ const canRotate = renderableItems.length > 1 && rotationIntervalMs > 0;
+
+ useEffect(() => {
+ if (!content) {
+ setRenderableItems([]);
+ setHasCheckedVisibilityState(true);
+ return;
+ }
+
+ const storage = getDocsSpotlightBrowserLocalStorage();
+ const visibilityState = storage ? readDocsSpotlightVisibilityState(storage) : {};
+
+ setRenderableItems(content.items.filter(item => !visibilityState[item.id]));
+ setActiveIndex(0);
+ setIsVisible(true);
+ setHasCheckedVisibilityState(true);
+ }, [content]);
+
+ useEffect(() => {
+ if (!canRotate || isPaused || prefersReducedMotion()) {
+ return;
+ }
+
+ const intervalId = globalThis.setInterval(() => {
+ setActiveIndex(currentIndex => (currentIndex + 1) % renderableItems.length);
+ }, rotationIntervalMs);
+
+ return () => globalThis.clearInterval(intervalId);
+ }, [canRotate, isPaused, renderableItems.length, rotationIntervalMs]);
+
+ useEffect(() => {
+ if (
+ !hasCheckedVisibilityState ||
+ !activeItem ||
+ !isVisible ||
+ !isSpotlightCardInVisibleViewport() ||
+ viewedItemIdsRef.current.has(activeItem.id)
+ ) {
+ return;
+ }
+
+ viewedItemIdsRef.current.add(activeItem.id);
+ sendDocsSpotlightViewEvent(activeItem);
+ }, [activeItem, hasCheckedVisibilityState, isVisible]);
+
+ const handleBlur = (event: FocusEvent) => {
+ const nextFocusedElement = event.relatedTarget;
+
+ if (!(nextFocusedElement instanceof Node) || !event.currentTarget.contains(nextFocusedElement)) {
+ setIsPaused(false);
+ }
+ };
+
+ const recordDisposition = (disposition: DocsSpotlightDisposition) => {
+ if (!activeItem) {
+ return;
+ }
+
+ const storage = getDocsSpotlightBrowserLocalStorage();
+
+ if (!storage) {
+ return;
+ }
+
+ writeDocsSpotlightVisibilityRecord(storage, activeItem.id, disposition);
+ };
+
+ if (!content || !hasCheckedVisibilityState || !activeItem || !isVisible) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/docs-spotlight-sidebar.tsx b/src/components/docs-spotlight-sidebar.tsx
new file mode 100644
index 000000000..f1d5ebc7b
--- /dev/null
+++ b/src/components/docs-spotlight-sidebar.tsx
@@ -0,0 +1,13 @@
+import { DocsSpotlightCard } from '@/components/docs-spotlight-card';
+import { getActiveDocsSpotlightContent, resolveDocsSpotlightLocale } from '@/lib/docs-spotlight/content';
+
+type DocsSpotlightSidebarProps = {
+ locale?: string;
+};
+
+export function DocsSpotlightSidebar({ locale }: DocsSpotlightSidebarProps) {
+ const resolvedLocale = resolveDocsSpotlightLocale(locale);
+ const content = getActiveDocsSpotlightContent(resolvedLocale);
+
+ return ;
+}
diff --git a/src/lib/docs-spotlight/content.test.ts b/src/lib/docs-spotlight/content.test.ts
new file mode 100644
index 000000000..2c644afea
--- /dev/null
+++ b/src/lib/docs-spotlight/content.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+
+import { docsSpotlightContent, getActiveDocsSpotlightContent } from './content';
+
+describe('docs spotlight content', () => {
+ it('uses the current corp-web-app spotlight ids and localized labels', () => {
+ expect(docsSpotlightContent.en.items.map(item => item.id)).toEqual([
+ 'iso-42001-certification',
+ 'lingo-release',
+ 'notepie-release',
+ 'ai-work-os-enterprise-intelligence',
+ ]);
+ expect(docsSpotlightContent.ko.spotlightLabel).toBe('하이라이트');
+ expect(docsSpotlightContent.ja.spotlightCtaLabel).toBe('詳しく見る');
+ });
+
+ it('filters active items inclusively and keeps the newest active item first', () => {
+ expect(getActiveDocsSpotlightContent('en', { today: '2026-06-17', random: () => 0 })?.items.map(item => item.id)).toEqual([
+ 'notepie-release',
+ 'iso-42001-certification',
+ 'lingo-release',
+ ]);
+ expect(getActiveDocsSpotlightContent('ko', { today: '2026-06-17', random: () => 0 })?.items[0]?.id).toBe(
+ 'ai-work-os-enterprise-intelligence',
+ );
+ expect(getActiveDocsSpotlightContent('en', { today: '2026-07-04', random: () => 0 })?.items.map(item => item.id)).toEqual(
+ expect.arrayContaining(['iso-42001-certification']),
+ );
+ expect(getActiveDocsSpotlightContent('en', { today: '2026-07-09', random: () => 0 })?.items.map(item => item.id)).toEqual([
+ 'notepie-release',
+ ]);
+ });
+
+ it('returns null when no item is active', () => {
+ expect(getActiveDocsSpotlightContent('en', { today: '2026-07-10' })).toBeNull();
+ });
+
+ it('falls back to English content for unknown locales', () => {
+ expect(getActiveDocsSpotlightContent('unknown', { today: '2026-06-17' })?.ariaLabel).toBe(
+ 'Latest company announcements',
+ );
+ });
+});
diff --git a/src/lib/docs-spotlight/content.ts b/src/lib/docs-spotlight/content.ts
new file mode 100644
index 000000000..579750dc7
--- /dev/null
+++ b/src/lib/docs-spotlight/content.ts
@@ -0,0 +1,258 @@
+export type DocsSpotlightLocale = 'en' | 'ja' | 'ko';
+
+export type DocsSpotlightItem = {
+ id: string;
+ date: string;
+ href: string;
+ imageSrc: string;
+ imageAlt: string;
+ title: string;
+ spotlightMeta: string;
+ visibleUntil: string;
+};
+
+export type ActiveDocsSpotlightItem = Omit;
+
+export type DocsSpotlightContent = {
+ ariaLabel: string;
+ spotlightLabel: string;
+ spotlightCtaLabel: string;
+ spotlightDismissLabel: string;
+ nextLabel: string;
+ previousLabel: string;
+ items: readonly DocsSpotlightItem[];
+};
+
+export type ActiveDocsSpotlightContent = Omit & {
+ items: readonly ActiveDocsSpotlightItem[];
+};
+
+type ActiveDocsSpotlightOptions = {
+ random?: () => number;
+ today?: string;
+};
+
+const datePattern = /^\d{4}-\d{2}-\d{2}$/;
+
+export const docsSpotlightContent = {
+ en: {
+ ariaLabel: 'Latest company announcements',
+ nextLabel: 'Next announcement',
+ previousLabel: 'Previous announcement',
+ spotlightCtaLabel: 'Read full story',
+ spotlightDismissLabel: 'Dismiss spotlight',
+ spotlightLabel: 'Spotlight',
+ items: [
+ {
+ id: 'iso-42001-certification',
+ date: '2026-06-04',
+ href: '/news/24/iso-42001-certification-announcement',
+ imageAlt: 'QueryPie Achieves ISO/IEC 42001 Certification for Its AI Management System',
+ imageSrc: '/spotlight/iso-42001-certification/thumbnail.png',
+ title: 'ISO/IEC 42001 Certification for AI Management System',
+ spotlightMeta: 'June 4, 2026',
+ visibleUntil: '2026-07-04',
+ },
+ {
+ id: 'lingo-release',
+ date: '2026-06-05',
+ href: '/news/26/lingo-launch',
+ imageAlt: 'QueryPie unveils Lingo, an AI real-time interpretation service',
+ imageSrc: '/spotlight/lingo-release/hero-en.png',
+ title: 'Lingo: AI Real-Time Interpretation Service',
+ spotlightMeta: 'June 5, 2026',
+ visibleUntil: '2026-07-05',
+ },
+ {
+ id: 'notepie-release',
+ date: '2026-06-09',
+ href: '/news/27/notepie-launch',
+ imageAlt: 'QueryPie Unveils NotePie, an AI Work Assistant Based on Documents and Web Sources',
+ imageSrc: '/spotlight/notepie-release/hero-en.png',
+ title: 'NotePie: AI Work Assistant for Documents and Web Sources',
+ spotlightMeta: 'June 9, 2026',
+ visibleUntil: '2026-07-09',
+ },
+ {
+ id: 'ai-work-os-enterprise-intelligence',
+ date: '2026-06-16',
+ href: '/ko/blog/30/ai-work-os-enterprise-intelligence',
+ imageAlt: 'AI Work OS: How a New Intelligence Works Inside the Enterprise',
+ imageSrc: '/spotlight/ai-work-os-enterprise-intelligence/thumbnail.png',
+ title: 'AI Work OS: How a New Intelligence Works Inside the Enterprise',
+ spotlightMeta: 'June 16, 2026',
+ visibleUntil: '2026-06-15',
+ },
+ ],
+ },
+ ja: {
+ ariaLabel: '最新のお知らせ',
+ nextLabel: '次のお知らせ',
+ previousLabel: '前のお知らせ',
+ spotlightCtaLabel: '詳しく見る',
+ spotlightDismissLabel: 'Spotlightを閉じる',
+ spotlightLabel: '注目',
+ items: [
+ {
+ id: 'iso-42001-certification',
+ date: '2026-06-04',
+ href: '/news/24/iso-42001-certification-announcement',
+ imageAlt: 'QueryPie AI、AIマネジメントシステムの国際規格 ISO/IEC 42001 認証を取得',
+ imageSrc: '/spotlight/iso-42001-certification/thumbnail.png',
+ title: 'AIマネジメントシステムの国際規格 ISO/IEC 42001 認証を取得',
+ spotlightMeta: '2026年6月4日',
+ visibleUntil: '2026-07-04',
+ },
+ {
+ id: 'lingo-release',
+ date: '2026-06-05',
+ href: '/news/26/lingo-launch',
+ imageAlt: 'QueryPie AI、AIリアルタイム通訳サービス「Lingo」を公開',
+ imageSrc: '/spotlight/lingo-release/hero-ja.png',
+ title: 'AIリアルタイム通訳サービス「Lingo」を公開',
+ spotlightMeta: '2026年6月5日',
+ visibleUntil: '2026-07-05',
+ },
+ {
+ id: 'notepie-release',
+ date: '2026-06-09',
+ href: '/news/27/notepie-launch',
+ imageAlt: 'QueryPie AI、文書・Web資料ベースのAI業務支援サービス「NotePie」を公開',
+ imageSrc: '/spotlight/notepie-release/hero-ja.png',
+ title: '文書・Web資料ベースのAI業務支援サービス「NotePie」を公開',
+ spotlightMeta: '2026年6月9日',
+ visibleUntil: '2026-07-09',
+ },
+ {
+ id: 'ai-work-os-enterprise-intelligence',
+ date: '2026-06-16',
+ href: '/ko/blog/30/ai-work-os-enterprise-intelligence',
+ imageAlt: 'AI Work OS:新しい知能が企業内で働く方法',
+ imageSrc: '/spotlight/ai-work-os-enterprise-intelligence/thumbnail.png',
+ title: 'AI Work OS:新しい知能が企業内で働く方法',
+ spotlightMeta: '2026年6月16日',
+ visibleUntil: '2026-06-15',
+ },
+ ],
+ },
+ ko: {
+ ariaLabel: '회사 주요 소식',
+ nextLabel: '다음 소식',
+ previousLabel: '이전 소식',
+ spotlightCtaLabel: '자세히 보기',
+ spotlightDismissLabel: '하이라이트 닫기',
+ spotlightLabel: '하이라이트',
+ items: [
+ {
+ id: 'iso-42001-certification',
+ date: '2026-06-04',
+ href: '/news/24/iso-42001-certification-announcement',
+ imageAlt: 'AI 경영시스템 국제 표준 ISO/IEC 42001 인증 획득',
+ imageSrc: '/spotlight/iso-42001-certification/thumbnail.png',
+ title: 'AI 경영시스템 국제 표준 ISO/IEC 42001 인증 획득',
+ spotlightMeta: '2026년 6월 4일',
+ visibleUntil: '2026-07-04',
+ },
+ {
+ id: 'lingo-release',
+ date: '2026-06-05',
+ href: '/news/26/lingo-launch',
+ imageAlt: 'AI 실시간 통역 서비스 ‘Lingo’ 공개',
+ imageSrc: '/spotlight/lingo-release/hero-ko.png',
+ title: 'AI 실시간 통역 서비스 ‘Lingo’ 공개',
+ spotlightMeta: '2026년 6월 5일',
+ visibleUntil: '2026-07-05',
+ },
+ {
+ id: 'notepie-release',
+ date: '2026-06-09',
+ href: '/news/27/notepie-launch',
+ imageAlt: '문서·웹 자료 기반 AI 업무 지원 서비스 ‘NotePie’ 공개',
+ imageSrc: '/spotlight/notepie-release/hero-ko.png',
+ title: '문서·웹 자료 기반 AI 업무 지원 서비스 ‘NotePie’ 공개',
+ spotlightMeta: '2026년 6월 9일',
+ visibleUntil: '2026-07-09',
+ },
+ {
+ id: 'ai-work-os-enterprise-intelligence',
+ date: '2026-06-16',
+ href: '/ko/blog/30/ai-work-os-enterprise-intelligence',
+ imageAlt: 'AI Work OS: 새로운 지능이 기업 안에서 일하는 방식',
+ imageSrc: '/spotlight/ai-work-os-enterprise-intelligence/thumbnail.png',
+ title: 'AI Work OS: 새로운 지능이 기업 안에서 일하는 방식',
+ spotlightMeta: '2026년 6월 16일',
+ visibleUntil: '2026-07-16',
+ },
+ ],
+ },
+} satisfies Record;
+
+function todayInSeoul() {
+ return new Intl.DateTimeFormat('en-CA', {
+ day: '2-digit',
+ month: '2-digit',
+ timeZone: 'Asia/Seoul',
+ year: 'numeric',
+ }).format(new Date());
+}
+
+function shuffleItems(items: readonly DocsSpotlightItem[], random: () => number) {
+ const shuffledItems = [...items];
+
+ for (let index = shuffledItems.length - 1; index > 0; index -= 1) {
+ const randomValue = Math.min(Math.max(random(), 0), 0.9999999999999999);
+ const randomIndex = Math.floor(randomValue * (index + 1));
+ const currentItem = shuffledItems[index];
+
+ shuffledItems[index] = shuffledItems[randomIndex];
+ shuffledItems[randomIndex] = currentItem;
+ }
+
+ return shuffledItems;
+}
+
+function orderActiveItems(items: readonly DocsSpotlightItem[], random: () => number) {
+ if (items.length < 2) {
+ return [...items];
+ }
+
+ const [latestItem, ...remainingItems] = [...items].sort((left, right) => right.date.localeCompare(left.date));
+
+ return [latestItem, ...shuffleItems(remainingItems, random)];
+}
+
+function stripVisibleUntil(item: DocsSpotlightItem): ActiveDocsSpotlightItem {
+ const { visibleUntil: _visibleUntil, ...activeItem } = item;
+
+ return activeItem;
+}
+
+export function resolveDocsSpotlightLocale(locale: string | undefined): DocsSpotlightLocale {
+ return locale === 'ja' || locale === 'ko' ? locale : 'en';
+}
+
+export function getActiveDocsSpotlightContent(
+ locale: string | undefined,
+ options: ActiveDocsSpotlightOptions = {},
+): ActiveDocsSpotlightContent | null {
+ const today = options.today ?? todayInSeoul();
+
+ if (!datePattern.test(today)) {
+ throw new Error('Expected docs spotlight today option to use YYYY-MM-DD format');
+ }
+
+ const content = docsSpotlightContent[resolveDocsSpotlightLocale(locale)];
+ const activeItems = orderActiveItems(
+ content.items.filter(item => item.visibleUntil >= today),
+ options.random ?? Math.random,
+ ).map(stripVisibleUntil);
+
+ if (activeItems.length === 0) {
+ return null;
+ }
+
+ return {
+ ...content,
+ items: activeItems,
+ };
+}
diff --git a/src/lib/docs-spotlight/storage.test.ts b/src/lib/docs-spotlight/storage.test.ts
new file mode 100644
index 000000000..7f70297cb
--- /dev/null
+++ b/src/lib/docs-spotlight/storage.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ DOCS_SPOTLIGHT_STORAGE_KEY,
+ parseDocsSpotlightVisibilityRecords,
+ readDocsSpotlightVisibilityState,
+ writeDocsSpotlightVisibilityRecord,
+} from './storage';
+
+function createMemoryStorage(): Storage {
+ const values = new Map();
+
+ return {
+ clear: () => values.clear(),
+ getItem: key => values.get(key) ?? null,
+ key: index => Array.from(values.keys())[index] ?? null,
+ get length() {
+ return values.size;
+ },
+ removeItem: key => values.delete(key),
+ setItem: (key, value) => values.set(key, String(value)),
+ } as Storage;
+}
+
+describe('docs spotlight storage', () => {
+ it('parses visibility records and marks expired records', () => {
+ expect(
+ parseDocsSpotlightVisibilityRecords(
+ JSON.stringify({
+ active: {
+ disposition: 'viewed',
+ updatedAt: '2099-06-12T00:00:00.000Z',
+ expiresAt: '2099-07-12T00:00:00.000Z',
+ },
+ expired: {
+ disposition: 'dismissed',
+ updatedAt: '2020-06-12T00:00:00.000Z',
+ expiresAt: '2020-07-12T00:00:00.000Z',
+ },
+ }),
+ new Date('2026-06-12T00:00:00.000Z'),
+ ),
+ ).toEqual([
+ {
+ disposition: 'viewed',
+ expiresAt: '2099-07-12T00:00:00.000Z',
+ id: 'active',
+ isExpired: false,
+ updatedAt: '2099-06-12T00:00:00.000Z',
+ },
+ {
+ disposition: 'dismissed',
+ expiresAt: '2020-07-12T00:00:00.000Z',
+ id: 'expired',
+ isExpired: true,
+ updatedAt: '2020-06-12T00:00:00.000Z',
+ },
+ ]);
+ });
+
+ it('recovers from corrupt payloads and prunes expired records', () => {
+ const storage = createMemoryStorage();
+
+ storage.setItem(
+ DOCS_SPOTLIGHT_STORAGE_KEY,
+ JSON.stringify({
+ active: {
+ disposition: 'viewed',
+ updatedAt: '2099-06-12T00:00:00.000Z',
+ expiresAt: '2099-07-12T00:00:00.000Z',
+ },
+ expired: {
+ disposition: 'dismissed',
+ updatedAt: '2020-06-12T00:00:00.000Z',
+ expiresAt: '2020-07-12T00:00:00.000Z',
+ },
+ }),
+ );
+
+ expect(readDocsSpotlightVisibilityState(storage, new Date('2026-06-12T00:00:00.000Z'))).toEqual({
+ active: {
+ disposition: 'viewed',
+ expiresAt: '2099-07-12T00:00:00.000Z',
+ updatedAt: '2099-06-12T00:00:00.000Z',
+ },
+ });
+ expect(storage.getItem(DOCS_SPOTLIGHT_STORAGE_KEY)).toContain('active');
+ expect(storage.getItem(DOCS_SPOTLIGHT_STORAGE_KEY)).not.toContain('expired');
+
+ storage.setItem(DOCS_SPOTLIGHT_STORAGE_KEY, 'not-json');
+ expect(readDocsSpotlightVisibilityState(storage)).toEqual({});
+ });
+
+ it('writes a viewed or dismissed record with a 30-day TTL', () => {
+ const storage = createMemoryStorage();
+
+ expect(
+ writeDocsSpotlightVisibilityRecord(
+ storage,
+ 'iso-42001-certification',
+ 'dismissed',
+ new Date('2026-06-12T00:00:00.000Z'),
+ ),
+ ).toBe(true);
+
+ expect(JSON.parse(storage.getItem(DOCS_SPOTLIGHT_STORAGE_KEY) ?? '{}')).toEqual({
+ 'iso-42001-certification': {
+ disposition: 'dismissed',
+ updatedAt: '2026-06-12T00:00:00.000Z',
+ expiresAt: '2026-07-12T00:00:00.000Z',
+ },
+ });
+ });
+});
diff --git a/src/lib/docs-spotlight/storage.ts b/src/lib/docs-spotlight/storage.ts
new file mode 100644
index 000000000..621b13026
--- /dev/null
+++ b/src/lib/docs-spotlight/storage.ts
@@ -0,0 +1,179 @@
+export const DOCS_SPOTLIGHT_STORAGE_KEY = 'querypie:docs:spotlight:v1';
+export const DOCS_SPOTLIGHT_VISIBILITY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+
+export type DocsSpotlightDisposition = 'viewed' | 'dismissed';
+
+export type DocsSpotlightVisibilityRecord = {
+ disposition: DocsSpotlightDisposition;
+ updatedAt: string;
+ expiresAt: string;
+};
+
+export type DocsSpotlightVisibilityState = Record;
+
+export type ParsedDocsSpotlightVisibilityRecord = DocsSpotlightVisibilityRecord & {
+ id: string;
+ isExpired: boolean;
+};
+
+const validDispositions = new Set(['viewed', 'dismissed']);
+
+export function getDocsSpotlightBrowserLocalStorage() {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+function isObject(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+}
+
+function assertTimestamp(value: unknown, fieldName: string, recordId: string) {
+ if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) {
+ throw new Error(`Invalid ${fieldName} timestamp for ${recordId}`);
+ }
+
+ return value;
+}
+
+function normalizeVisibilityRecord(
+ id: string,
+ candidate: unknown,
+ now: Date,
+): ParsedDocsSpotlightVisibilityRecord {
+ if (!isObject(candidate)) {
+ throw new Error(`Expected docs spotlight visibility record for ${id} to be an object`);
+ }
+
+ const disposition = candidate.disposition;
+
+ if (typeof disposition !== 'string' || !validDispositions.has(disposition as DocsSpotlightDisposition)) {
+ throw new Error(`Invalid disposition for ${id}`);
+ }
+
+ const updatedAt = assertTimestamp(candidate.updatedAt, 'updatedAt', id);
+ const expiresAt = assertTimestamp(candidate.expiresAt, 'expiresAt', id);
+
+ return {
+ disposition: disposition as DocsSpotlightDisposition,
+ expiresAt,
+ id,
+ isExpired: Date.parse(expiresAt) <= now.getTime(),
+ updatedAt,
+ };
+}
+
+function persistVisibilityState(storage: Storage, state: DocsSpotlightVisibilityState) {
+ if (Object.keys(state).length === 0) {
+ storage.removeItem(DOCS_SPOTLIGHT_STORAGE_KEY);
+ return;
+ }
+
+ storage.setItem(DOCS_SPOTLIGHT_STORAGE_KEY, JSON.stringify(state));
+}
+
+export function parseDocsSpotlightVisibilityRecords(
+ rawValue: string,
+ now: Date = new Date(),
+): ParsedDocsSpotlightVisibilityRecord[] {
+ const parsedValue: unknown = JSON.parse(rawValue);
+
+ if (!isObject(parsedValue)) {
+ throw new Error('Expected docs spotlight visibility state to be an object map');
+ }
+
+ return Object.entries(parsedValue)
+ .map(([id, candidate]) => normalizeVisibilityRecord(id, candidate, now))
+ .sort((left, right) => left.id.localeCompare(right.id));
+}
+
+export function readDocsSpotlightVisibilityState(
+ storage: Storage,
+ now: Date = new Date(),
+): DocsSpotlightVisibilityState {
+ try {
+ const rawValue = storage.getItem(DOCS_SPOTLIGHT_STORAGE_KEY);
+
+ if (!rawValue) {
+ return {};
+ }
+
+ const parsedValue: unknown = JSON.parse(rawValue);
+
+ if (!isObject(parsedValue)) {
+ try {
+ storage.removeItem(DOCS_SPOTLIGHT_STORAGE_KEY);
+ } catch {
+ // Best-effort cleanup; rendering should continue from an empty state.
+ }
+
+ return {};
+ }
+
+ const nextState: DocsSpotlightVisibilityState = {};
+ let shouldPersistPrunedState = false;
+
+ for (const [id, candidate] of Object.entries(parsedValue)) {
+ try {
+ const record = normalizeVisibilityRecord(id, candidate, now);
+
+ if (record.isExpired) {
+ shouldPersistPrunedState = true;
+ continue;
+ }
+
+ nextState[id] = {
+ disposition: record.disposition,
+ expiresAt: record.expiresAt,
+ updatedAt: record.updatedAt,
+ };
+ } catch {
+ shouldPersistPrunedState = true;
+ }
+ }
+
+ if (shouldPersistPrunedState) {
+ try {
+ persistVisibilityState(storage, nextState);
+ } catch {
+ // Pruning failure must not block visibility decisions.
+ }
+ }
+
+ return nextState;
+ } catch {
+ return {};
+ }
+}
+
+export function writeDocsSpotlightVisibilityRecord(
+ storage: Storage,
+ id: string,
+ disposition: DocsSpotlightDisposition,
+ now: Date = new Date(),
+) {
+ try {
+ const updatedAt = now.toISOString();
+ const expiresAt = new Date(now.getTime() + DOCS_SPOTLIGHT_VISIBILITY_TTL_MS).toISOString();
+ const nextState = {
+ ...readDocsSpotlightVisibilityState(storage, now),
+ [id]: {
+ disposition,
+ expiresAt,
+ updatedAt,
+ },
+ };
+
+ storage.setItem(DOCS_SPOTLIGHT_STORAGE_KEY, JSON.stringify(nextState));
+
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/lib/docs-spotlight/tracking.test.ts b/src/lib/docs-spotlight/tracking.test.ts
new file mode 100644
index 000000000..702abfaba
--- /dev/null
+++ b/src/lib/docs-spotlight/tracking.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ createDocsSpotlightAnalyticsParams,
+ createDocsSpotlightTrackingHref,
+ sendDocsSpotlightClickEvent,
+ sendDocsSpotlightDismissEvent,
+ sendDocsSpotlightViewEvent,
+} from './tracking';
+
+const item = {
+ id: 'lingo-release',
+ href: '/news/26/lingo-launch',
+ title: 'Lingo: AI Real-Time Interpretation Service',
+};
+
+const readParams = (href: string) => new URL(href).searchParams;
+
+describe('docs spotlight tracking', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('adds docs sidebar UTM values to QueryPie-owned relative URLs and preserves hash', () => {
+ const href = createDocsSpotlightTrackingHref('/news/26/lingo-launch?ref=hero#demo', 'lingo-release', 'en');
+ const params = readParams(href);
+
+ expect(href).toMatch(/^https:\/\/www\.querypie\.com\/en\/news\/26\/lingo-launch\?/);
+ expect(params.get('ref')).toBe('hero');
+ expect(params.get('utm_source')).toBe('qp');
+ expect(params.get('utm_medium')).toBe('notice');
+ expect(params.get('utm_campaign')).toBe('lingo-release');
+ expect(params.get('utm_content')).toBe('docs_sidebar_card');
+ expect(params.get('utm_id')).toBe('sn_lingo-release');
+ expect(href.endsWith('#demo')).toBe(true);
+ });
+
+ it('does not rewrite external URLs', () => {
+ const href = 'https://example.com/event?utm_source=partner#details';
+
+ expect(createDocsSpotlightTrackingHref(href, 'external-event', 'ko')).toBe(href);
+ });
+
+ it('builds and sends GA promotion events without throwing when gtag is unavailable', () => {
+ expect(createDocsSpotlightAnalyticsParams(item)).toMatchObject({
+ promotion_id: 'sn_lingo-release',
+ promotion_name: 'lingo-release',
+ creative_slot: 'docs_sidebar_card',
+ creative_name: 'Lingo: AI Real-Time Interpretation Service',
+ spotlight_id: 'lingo-release',
+ spotlight_surface: 'docs_sidebar_card',
+ spotlight_destination: '/news/26/lingo-launch',
+ });
+
+ const gtagMock = vi.fn();
+ vi.stubGlobal('gtag', gtagMock);
+
+ sendDocsSpotlightViewEvent(item);
+ sendDocsSpotlightClickEvent(item);
+ sendDocsSpotlightDismissEvent(item);
+
+ expect(gtagMock).toHaveBeenNthCalledWith(1, 'event', 'view_promotion', expect.any(Object));
+ expect(gtagMock).toHaveBeenNthCalledWith(2, 'event', 'select_promotion', expect.any(Object));
+ expect(gtagMock).toHaveBeenNthCalledWith(3, 'event', 'site_notice_dismiss', expect.any(Object));
+
+ vi.stubGlobal('gtag', undefined);
+ expect(() => sendDocsSpotlightClickEvent(item)).not.toThrow();
+ });
+});
diff --git a/src/lib/docs-spotlight/tracking.ts b/src/lib/docs-spotlight/tracking.ts
new file mode 100644
index 000000000..3ff636b23
--- /dev/null
+++ b/src/lib/docs-spotlight/tracking.ts
@@ -0,0 +1,131 @@
+import type { DocsSpotlightLocale } from './content';
+
+type DocsSpotlightTrackingItem = {
+ href: string;
+ id: string;
+ title: string;
+};
+
+type DocsSpotlightEventName = 'view_promotion' | 'select_promotion' | 'site_notice_dismiss';
+
+type GtagGlobal = typeof globalThis & {
+ gtag?: (command: 'event', eventName: DocsSpotlightEventName, params: Record) => void;
+};
+
+const queryPieUrlBase = 'https://www.querypie.com';
+const queryPieDomains = new Set(['querypie.com', 'www.querypie.com']);
+const localePathPattern = /^\/(en|ja|ko)(\/|$)/;
+
+function createUtmParams(itemId: string) {
+ return {
+ utm_campaign: itemId,
+ utm_content: 'docs_sidebar_card',
+ utm_id: `sn_${itemId}`,
+ utm_medium: 'notice',
+ utm_source: 'qp',
+ };
+}
+
+function parseUrl(href: string) {
+ try {
+ return new URL(href);
+ } catch {
+ return new URL(href, queryPieUrlBase);
+ }
+}
+
+function isQueryPieOwnedUrl(url: URL) {
+ return queryPieDomains.has(url.hostname);
+}
+
+function localizeQueryPiePath(pathname: string, locale: DocsSpotlightLocale) {
+ if (localePathPattern.test(pathname)) {
+ return pathname;
+ }
+
+ return `/${locale}${pathname.startsWith('/') ? pathname : `/${pathname}`}`;
+}
+
+export function createDocsSpotlightTrackingHref(href: string, itemId: string, locale: DocsSpotlightLocale) {
+ const url = parseUrl(href);
+
+ if (!isQueryPieOwnedUrl(url)) {
+ return href;
+ }
+
+ url.hostname = 'www.querypie.com';
+ url.protocol = 'https:';
+ url.pathname = localizeQueryPiePath(url.pathname, locale);
+
+ const params = createUtmParams(itemId);
+
+ Object.entries(params).forEach(([key, value]) => {
+ url.searchParams.set(key, value);
+ });
+
+ return url.toString();
+}
+
+const getGtag = () => {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ const candidate = (globalThis as GtagGlobal).gtag;
+
+ return typeof candidate === 'function' ? candidate : null;
+};
+
+export function createDocsSpotlightAnalyticsParams(item: DocsSpotlightTrackingItem) {
+ const promotionId = `sn_${item.id}`;
+ const sharedParams = {
+ promotion_id: promotionId,
+ promotion_name: item.id,
+ creative_slot: 'docs_sidebar_card',
+ creative_name: item.title,
+ spotlight_id: item.id,
+ spotlight_surface: 'docs_sidebar_card',
+ spotlight_title: item.title,
+ spotlight_destination: item.href,
+ };
+
+ return {
+ ...sharedParams,
+ items: [
+ {
+ item_id: item.id,
+ item_name: item.title,
+ promotion_id: promotionId,
+ promotion_name: item.id,
+ creative_slot: 'docs_sidebar_card',
+ creative_name: item.title,
+ },
+ ],
+ };
+}
+
+function sendDocsSpotlightAnalyticsEvent(eventName: DocsSpotlightEventName, item: DocsSpotlightTrackingItem) {
+ const gtag = getGtag();
+
+ if (!gtag) {
+ return;
+ }
+
+ try {
+ gtag('event', eventName, createDocsSpotlightAnalyticsParams(item));
+ } catch {
+ // Analytics must never block navigation, dismissal, persistence, or rendering.
+ }
+}
+
+export function sendDocsSpotlightViewEvent(item: DocsSpotlightTrackingItem) {
+ sendDocsSpotlightAnalyticsEvent('view_promotion', item);
+}
+
+export function sendDocsSpotlightClickEvent(item: DocsSpotlightTrackingItem) {
+ sendDocsSpotlightAnalyticsEvent('select_promotion', item);
+}
+
+export function sendDocsSpotlightDismissEvent(item: DocsSpotlightTrackingItem) {
+ sendDocsSpotlightAnalyticsEvent('site_notice_dismiss', item);
+}
diff --git a/src/lib/nextra-page-map.test.ts b/src/lib/nextra-page-map.test.ts
new file mode 100644
index 000000000..1b05d73fb
--- /dev/null
+++ b/src/lib/nextra-page-map.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+
+import { filterDynamicPageMapRoutes } from './nextra-page-map';
+
+describe('filterDynamicPageMapRoutes', () => {
+ it('removes app-only dynamic routes before passing pageMap to Nextra Layout', () => {
+ expect(
+ filterDynamicPageMapRoutes([
+ { name: 'overview', route: '/ko/overview' },
+ { name: 'internal', route: '/[lang]/internal' },
+ {
+ name: 'nested',
+ route: '/ko/nested',
+ children: [
+ { name: 'api', route: '/ko/nested/api' },
+ { name: '[version]', route: '/[lang]/sandbox/[version]' },
+ ],
+ },
+ ]),
+ ).toEqual([
+ { name: 'overview', route: '/ko/overview' },
+ {
+ name: 'nested',
+ route: '/ko/nested',
+ children: [{ name: 'api', route: '/ko/nested/api' }],
+ },
+ ]);
+ });
+});
diff --git a/src/lib/nextra-page-map.ts b/src/lib/nextra-page-map.ts
new file mode 100644
index 000000000..bb984b4c0
--- /dev/null
+++ b/src/lib/nextra-page-map.ts
@@ -0,0 +1,28 @@
+import type { PageMapItem } from 'nextra';
+
+function hasRoute(item: PageMapItem): item is PageMapItem & { route: string } {
+ return 'route' in item && typeof item.route === 'string';
+}
+
+function hasChildren(item: PageMapItem): item is PageMapItem & { children: PageMapItem[] } {
+ return 'children' in item && Array.isArray(item.children);
+}
+
+function hasDynamicRouteSegment(item: PageMapItem) {
+ return hasRoute(item) && item.route.includes('[');
+}
+
+export function filterDynamicPageMapRoutes(pageMap: readonly PageMapItem[]): PageMapItem[] {
+ return pageMap
+ .filter(item => !hasDynamicRouteSegment(item))
+ .map(item => {
+ if (!hasChildren(item)) {
+ return item;
+ }
+
+ return {
+ ...item,
+ children: filterDynamicPageMapRoutes(item.children),
+ };
+ });
+}