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), + }; + }); +}