diff --git a/scripts/finalize-sitemap.ts b/scripts/finalize-sitemap.ts index 981ee1030..8e8417e16 100644 --- a/scripts/finalize-sitemap.ts +++ b/scripts/finalize-sitemap.ts @@ -9,7 +9,11 @@ let sitemap: string try { sitemap = await fs.readFile(sitemapPath, 'utf-8') } catch (error) { - if (process.env.VITE_E2E === 'true' && (error as NodeJS.ErrnoException).code === 'ENOENT') { + const isMissingSitemap = (error as NodeJS.ErrnoException).code === 'ENOENT' + const canSkipMissingSitemap = + process.env.VITE_E2E === 'true' || process.env.VERCEL_ENV !== 'production' + + if (isMissingSitemap && canSkipMissingSitemap) { process.exit(0) } throw error diff --git a/src/marketing/app/blog/[slug]/page.tsx b/src/marketing/app/blog/[slug]/page.tsx index 53675620b..3c5ed6a31 100644 --- a/src/marketing/app/blog/[slug]/page.tsx +++ b/src/marketing/app/blog/[slug]/page.tsx @@ -3,6 +3,7 @@ import Footer from '../../_components/Footer' import Header from '../../_components/Header' import Reveal from '../../_components/Reveal' import { developersPath } from '../../_lib/developersPaths' +import MicroHeader from '../_components/MicroHeader' import { categoryBySlug, formatDate, isNew } from '../_lib/categories' import { getPost } from '../_lib/posts' @@ -35,8 +36,9 @@ export default function BlogPostPage({ params }: { params: { slug: string } }) {
+ -
+
-

+

{post.title}

@@ -77,7 +82,7 @@ export default function BlogPostPage({ params }: { params: { slug: string } }) { HTML at build time, so raw HTML injection here is trusted. */} {/* biome-ignore lint/security/noDangerouslySetInnerHtml: trusted build-time markdown */} -
+
diff --git a/src/marketing/app/blog/_components/MicroHeader.css b/src/marketing/app/blog/_components/MicroHeader.css new file mode 100644 index 000000000..857b73162 --- /dev/null +++ b/src/marketing/app/blog/_components/MicroHeader.css @@ -0,0 +1,376 @@ +.blog-micro-header { + --blog-micro-ease: cubic-bezier(0.23, 1, 0.32, 1); + position: fixed; + bottom: 14px; + left: 50%; + z-index: 60; + display: flex; + align-items: center; + height: 40px; + max-width: calc(100vw - 32px); + padding: 0 6px; + transform: translateX(-50%); + border: 1px solid color-mix(in srgb, var(--foreground) 14%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--surface-shell) 84%, transparent); + box-shadow: + 0 12px 32px rgb(0 0 0 / 28%), + 0 3px 10px rgb(0 0 0 / 20%); + color: var(--foreground); + font-family: var(--font-pilat-book), ui-sans-serif, system-ui, sans-serif; + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); +} + +.blog-micro-segment { + position: relative; + z-index: 1; + display: flex; + flex: none; + align-items: center; + height: 28px; + padding: 0 12px; + border: 0; + border-radius: 999px; + background: none; + color: color-mix(in srgb, var(--foreground) 78%, transparent); + font: inherit; + font-size: 13px; + font-weight: 400; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + transition: + color 200ms ease, + transform 160ms var(--blog-micro-ease); +} + +.blog-micro-segment:hover { + color: var(--foreground); +} + +.blog-micro-segment:active { + transform: scale(0.96); +} + +.blog-micro-segment:focus-visible, +.blog-micro-menu button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--foreground) 55%, transparent); + outline-offset: 1px; +} + +.blog-micro-wash { + position: absolute; + top: calc(50% - 14px); + left: -1px; + height: 28px; + border-radius: 999px; + background: color-mix(in srgb, var(--foreground) 8%, transparent); + pointer-events: none; + transition: opacity 180ms ease; +} + +.blog-micro-title { + display: inline-block; + max-width: 0; + overflow: hidden; + opacity: 0; + white-space: nowrap; + transition: + max-width 320ms var(--blog-micro-ease), + opacity 220ms ease; +} + +.blog-micro-title[data-shown] { + max-width: min(620px, 62vw); + opacity: 1; +} + +.blog-micro-top { + padding: 0 10px; + color: color-mix(in srgb, var(--foreground) 48%, transparent); +} + +.blog-micro-crumbs { + display: flex; + align-items: center; +} + +.blog-micro-crumb { + overflow: hidden; +} + +.blog-micro-crumb-side { + display: block; + max-width: 130px; + padding: 0 8px; + overflow: hidden; + color: color-mix(in srgb, var(--foreground) 42%, transparent); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + transition: + max-width 280ms var(--blog-micro-ease), + color 200ms ease; +} + +.blog-micro-crumb-side:hover { + color: color-mix(in srgb, var(--foreground) 76%, transparent); +} + +.blog-micro-crumb-current { + max-width: min(280px, 34vw); + color: color-mix(in srgb, var(--foreground) 80%, transparent); + font-weight: 600; + transition: + max-width 280ms var(--blog-micro-ease), + transform 160ms var(--blog-micro-ease); +} + +.blog-micro-post-title { + cursor: default; +} + +.blog-micro-post-title:active { + transform: none; +} + +.blog-micro-chevron { + flex: none; + margin-left: 7px; + color: color-mix(in srgb, var(--foreground) 42%, transparent); + transition: transform 200ms var(--blog-micro-ease); +} + +.blog-micro-crumb-current[aria-expanded="true"] .blog-micro-chevron { + transform: rotate(180deg); +} + +.blog-micro-crumb-separator { + flex: none; + color: color-mix(in srgb, var(--foreground) 30%, transparent); +} + +.blog-micro-live-label, +.blog-micro-label-swap { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + animation: blog-micro-label-swap 320ms var(--blog-micro-ease); +} + +.blog-micro-crumbs[data-direction="1"] { + --blog-micro-swap-x: 32px; +} + +.blog-micro-crumbs[data-direction="-1"] { + --blog-micro-swap-x: -32px; +} + +@keyframes blog-micro-label-swap { + from { + opacity: 0; + filter: blur(3px); + transform: translate(var(--blog-micro-swap-x, 0), 2px); + } +} + +.blog-micro-readtime-slot { + order: 1; + display: inline-flex; + align-items: center; + max-width: 0; + overflow: hidden; + transition: max-width 420ms var(--blog-micro-ease); +} + +.blog-micro-readtime-slot[data-shown] { + max-width: 72px; +} + +.blog-micro-segment.blog-micro-readtime { + height: 28px; + padding: 0 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--foreground) 5%, transparent); + color: color-mix(in srgb, var(--foreground) 76%, transparent); + font-size: 12px; + font-weight: 500; + cursor: default; + opacity: 0; + transform: scale(0.9); + transition: + opacity 300ms ease, + transform 420ms var(--blog-micro-ease); +} + +.blog-micro-readtime-slot[data-shown] .blog-micro-readtime { + opacity: 1; + transform: scale(1); +} + +.blog-micro-segment.blog-micro-readtime:active { + transform: scale(1); +} + +.blog-micro-readtime-number { + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; +} + +.blog-micro-readtime-ring { + position: absolute; + top: -1px; + left: -1px; + overflow: visible; + pointer-events: none; +} + +.blog-micro-menu { + position: fixed; + bottom: 64px; + left: 50%; + z-index: 59; + display: flex; + flex-direction: column; + min-width: 250px; + max-width: min(360px, calc(100vw - 32px)); + max-height: min(420px, 62vh); + padding: 6px; + overflow-y: auto; + transform: translateX(-50%); + transform-origin: bottom center; + border: 1px solid color-mix(in srgb, var(--foreground) 14%, transparent); + border-radius: 20px; + background: color-mix(in srgb, var(--surface-shell) 90%, transparent); + box-shadow: 0 18px 46px rgb(0 0 0 / 28%); + font-family: var(--font-pilat-book), ui-sans-serif, system-ui, sans-serif; + -webkit-backdrop-filter: blur(14px); + backdrop-filter: blur(14px); + animation: blog-micro-menu-in 200ms var(--blog-micro-ease); +} + +@keyframes blog-micro-menu-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(4px) scale(0.97); + } +} + +.blog-micro-menu button { + display: flex; + align-items: center; + gap: 11px; + padding: 8px 12px; + border: 0; + border-radius: 13px; + background: none; + color: color-mix(in srgb, var(--foreground) 76%, transparent); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; + transition: + background-color 150ms ease, + color 150ms ease; +} + +.blog-micro-menu button:hover, +.blog-micro-menu button:focus-visible { + background: color-mix(in srgb, var(--foreground) 8%, transparent); + color: var(--foreground); +} + +.blog-micro-menu button[data-active] { + color: var(--foreground); + font-weight: 600; +} + +.blog-micro-menu-tick { + flex: none; + width: 14px; + height: 2px; + background: color-mix(in srgb, var(--foreground) 25%, transparent); + transition: background-color 150ms ease; +} + +.blog-micro-menu button[data-active] .blog-micro-menu-tick { + background: var(--foreground); +} + +.blog-micro-menu-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.blog-prose h2 { + scroll-margin-top: 24px; +} + +@media (hover: hover) and (pointer: fine) { + .blog-micro-crumb-side:hover { + max-width: 340px; + } + + .blog-micro-crumb-current:hover { + max-width: min(460px, 50vw); + } +} + +@media (max-width: 900px) { + .blog-micro-crumb-side, + .blog-micro-crumb-separator { + display: none; + } + + .blog-micro-title[data-shown] { + max-width: min(420px, 58vw); + } + + .blog-micro-crumb-current { + max-width: min(250px, 43vw); + } +} + +@media (max-width: 540px) { + .blog-micro-header { + bottom: max(10px, env(safe-area-inset-bottom)); + max-width: calc(100vw - 20px); + } + + .blog-micro-title[data-shown] { + max-width: 58vw; + } + + .blog-micro-top { + padding: 0 8px; + } + + .blog-micro-crumb-current { + max-width: 42vw; + padding-right: 10px; + padding-left: 10px; + } + + .blog-micro-menu { + bottom: calc(max(10px, env(safe-area-inset-bottom)) + 50px); + } +} + +@media (prefers-reduced-motion: reduce) { + .blog-micro-title, + .blog-micro-readtime-slot, + .blog-micro-header .blog-micro-readtime-slot .blog-micro-readtime, + .blog-micro-crumb-side, + .blog-micro-crumb-current { + transition: none; + } + + .blog-micro-live-label, + .blog-micro-label-swap, + .blog-micro-menu { + animation: none; + } +} diff --git a/src/marketing/app/blog/_components/MicroHeader.tsx b/src/marketing/app/blog/_components/MicroHeader.tsx new file mode 100644 index 000000000..253664e33 --- /dev/null +++ b/src/marketing/app/blog/_components/MicroHeader.tsx @@ -0,0 +1,532 @@ +'use client' + +import { + type CSSProperties, + type MouseEvent as ReactMouseEvent, + useEffect, + useId, + useRef, + useState, +} from 'react' +import './MicroHeader.css' + +type Section = { + id: string + title: string +} + +function sectionSlug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') +} + +function ProgressRing({ + width: boxWidth, + height: boxHeight, + progress, + uid, + className, + glow = 3.5, + style, +}: { + width: number + height: number + progress: number + uid: string + className: string + glow?: number + style?: CSSProperties +}) { + const strokeWidth = 1.5 + const width = boxWidth - strokeWidth + const height = boxHeight - strokeWidth + const radius = height / 2 + const straight = Math.max(0, width - 2 * radius) + const perimeter = 2 * straight + 2 * Math.PI * radius + const startOffset = (straight / 2 / perimeter) * 100 + const visible = progress * 100 + const dash = `${visible} ${Math.max(0, 100 - visible) + 0.5}` + const shared = { + x: strokeWidth / 2, + y: strokeWidth / 2, + width, + height, + rx: radius, + pathLength: 100, + fill: 'none', + stroke: `url(#${uid}-ink)`, + strokeLinecap: 'round' as const, + strokeDasharray: dash, + strokeDashoffset: -startOffset, + } + + return ( + + ) +} + +/** + * Floating blog navigation adapted from tanishq.xyz's engineering MicroHeader. + * It tracks the article's H2 sections and keeps reading progress available + * after the primary site navigation has scrolled away. + */ +export default function MicroHeader({ title }: { title: string }) { + const ringId = useId().replace(/:/g, '') + const [expanded, setExpanded] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) + const [sections, setSections] = useState([]) + const [activeIndex, setActiveIndex] = useState(-1) + const [progress, setProgress] = useState(0) + const [readMinutes, setReadMinutes] = useState(0) + const [readTimeShown, setReadTimeShown] = useState(false) + const [direction, setDirection] = useState(0) + const [readBox, setReadBox] = useState({ x: 0, y: 0, width: 0, height: 0 }) + const [wash, setWash] = useState({ x: 0, width: 0, visible: false }) + + const rootRef = useRef(null) + const menuRef = useRef(null) + const readTimeRef = useRef(null) + const hoveredElementRef = useRef(null) + const washFrameRef = useRef(0) + const washRunningRef = useRef(false) + const shownWashRef = useRef({ x: 0, width: 0 }) + const navigationLockRef = useRef(0) + const lastIndexRef = useRef(-1) + + useEffect(() => { + if (activeIndex === lastIndexRef.current) return + setDirection(activeIndex > lastIndexRef.current ? 1 : -1) + lastIndexRef.current = activeIndex + }, [activeIndex]) + + useEffect(() => { + const header = rootRef.current + if (!header) return + + const measure = () => { + const readTime = readTimeRef.current + if (!readTime) return + const readTimeRect = readTime.getBoundingClientRect() + const headerRect = header.getBoundingClientRect() + setReadBox({ + x: readTimeRect.left - headerRect.left, + y: readTimeRect.top - headerRect.top, + width: readTimeRect.width, + height: readTimeRect.height, + }) + } + + measure() + const observer = new ResizeObserver(measure) + observer.observe(header) + if (readTimeRef.current) observer.observe(readTimeRef.current) + return () => observer.disconnect() + }, [readMinutes, readTimeShown]) + + useEffect(() => { + const content = document.querySelector('[data-blog-content]') + if (!content) return + + let revealFrame = 0 + const syncContent = () => { + const usedIds = new Set() + const headings = Array.from(content.querySelectorAll('h2')) + const nextSections = headings.map((heading, index) => { + const baseId = + heading.id || sectionSlug(heading.textContent ?? '') || `section-${index + 1}` + let id = baseId + let suffix = 2 + while (usedIds.has(id)) { + id = `${baseId}-${suffix}` + suffix += 1 + } + usedIds.add(id) + heading.id = id + return { id, title: heading.textContent?.trim() || `Section ${index + 1}` } + }) + setSections(nextSections) + + const words = content.textContent?.trim().match(/\S+/g)?.length ?? 0 + const minutes = words > 0 ? Math.max(1, Math.round(words / 220)) : 0 + setReadMinutes(minutes) + if (minutes > 0 && !revealFrame) { + revealFrame = requestAnimationFrame(() => setReadTimeShown(true)) + } + } + + syncContent() + const observer = new MutationObserver(syncContent) + observer.observe(content, { childList: true, subtree: true }) + return () => { + observer.disconnect() + cancelAnimationFrame(revealFrame) + } + }, [title]) + + useEffect(() => { + const article = document.querySelector('[data-blog-article]') + const titleElement = document.querySelector('[data-blog-title]') + if (!article || !titleElement) return + + let measureFrame = 0 + let progressFrame = 0 + let shownProgress = 0 + let targetProgress = 0 + + const animateProgress = () => { + const next = shownProgress + (targetProgress - shownProgress) * 0.14 + if (Math.abs(targetProgress - next) < 0.0008) { + shownProgress = targetProgress + setProgress(shownProgress) + progressFrame = 0 + return + } + shownProgress = next + setProgress(shownProgress) + progressFrame = requestAnimationFrame(animateProgress) + } + + const measure = () => { + measureFrame = 0 + const titleHasPassed = titleElement.getBoundingClientRect().bottom <= 70 + setExpanded(titleHasPassed) + + const articleRect = article.getBoundingClientRect() + const articleTop = window.scrollY + articleRect.top + const progressEnd = Math.max( + articleTop + 1, + articleTop + articleRect.height - window.innerHeight * 0.72, + ) + targetProgress = Math.max( + 0, + Math.min(1, (window.scrollY - articleTop) / (progressEnd - articleTop)), + ) + if (!progressFrame) progressFrame = requestAnimationFrame(animateProgress) + + if (Date.now() < navigationLockRef.current) return + const headings = Array.from( + document.querySelectorAll('[data-blog-content] h2'), + ) + headings.forEach((heading, index) => { + const section = sections[index] + if (section) heading.id = section.id + }) + let nextIndex = -1 + for (let index = 0; index < headings.length; index += 1) { + if (headings[index].getBoundingClientRect().top <= 150) nextIndex = index + } + + const pageBottom = + window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 8 + if (pageBottom && headings.length > 0) nextIndex = headings.length - 1 + setActiveIndex(nextIndex) + } + + const scheduleMeasure = () => { + if (!measureFrame) measureFrame = requestAnimationFrame(measure) + } + + measure() + window.addEventListener('scroll', scheduleMeasure, { passive: true }) + window.addEventListener('resize', scheduleMeasure) + return () => { + window.removeEventListener('scroll', scheduleMeasure) + window.removeEventListener('resize', scheduleMeasure) + cancelAnimationFrame(measureFrame) + cancelAnimationFrame(progressFrame) + } + }, [sections.length]) + + useEffect(() => { + if (!menuOpen) return + + const closeOnOutsidePointer = (event: PointerEvent) => { + const target = event.target as Node + if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) { + setMenuOpen(false) + } + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') setMenuOpen(false) + } + + document.addEventListener('pointerdown', closeOnOutsidePointer) + document.addEventListener('keydown', closeOnEscape) + return () => { + document.removeEventListener('pointerdown', closeOnOutsidePointer) + document.removeEventListener('keydown', closeOnEscape) + } + }, [menuOpen]) + + const washTarget = () => { + const hoveredElement = hoveredElementRef.current + const header = rootRef.current + if (!hoveredElement || !header) return null + + const segmentRect = hoveredElement.getBoundingClientRect() + const headerRect = header.getBoundingClientRect() + return { x: segmentRect.left - headerRect.left, width: segmentRect.width } + } + + const followWash = (snap: boolean) => { + if (snap) { + const target = washTarget() + if (target) shownWashRef.current = target + } + if (washRunningRef.current) return + + washRunningRef.current = true + const tick = () => { + const target = washTarget() + if (!hoveredElementRef.current || !target) { + washRunningRef.current = false + return + } + + const shown = shownWashRef.current + const next = { + x: shown.x + (target.x - shown.x) * 0.28, + width: shown.width + (target.width - shown.width) * 0.28, + } + shownWashRef.current = next + setWash({ ...next, visible: true }) + washFrameRef.current = requestAnimationFrame(tick) + } + washFrameRef.current = requestAnimationFrame(tick) + } + + const moveWash = (event: ReactMouseEvent) => { + const shouldSnap = hoveredElementRef.current === null + hoveredElementRef.current = event.currentTarget + followWash(shouldSnap) + } + + useEffect(() => () => cancelAnimationFrame(washFrameRef.current), []) + + const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches + + const backToTop = () => { + setMenuOpen(false) + window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }) + } + + const jumpToSection = (id: string) => { + setMenuOpen(false) + const index = sections.findIndex((section) => section.id === id) + if (index >= 0) setActiveIndex(index) + navigationLockRef.current = Date.now() + 900 + const headings = Array.from( + document.querySelectorAll('[data-blog-content] h2'), + ) + const heading = document.getElementById(id) ?? (index >= 0 ? headings[index] : null) + if (heading) heading.id = id + heading?.scrollIntoView({ + behavior: prefersReducedMotion() ? 'auto' : 'smooth', + block: 'start', + }) + } + + const currentTitle = activeIndex >= 0 ? sections[activeIndex]?.title : title + const previousSection = activeIndex > 0 ? sections[activeIndex - 1] : null + const nextSection = activeIndex < sections.length - 1 ? sections[activeIndex + 1] : null + const remainingMinutes = readMinutes * (1 - progress) + const readLabel = + progress >= 0.999 ? 'Done' : remainingMinutes < 1 ? '<1m' : `${Math.ceil(remainingMinutes)}m` + const readAria = progress >= 0.999 ? 'Finished reading' : `About ${readLabel} left to read` + + return ( + <> + + + {menuOpen && sections.length > 0 && ( +
+ {sections.map((section, index) => ( + + ))} +
+ )} + + ) +}