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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
# Values below are PUBLIC (they're shipped to the browser by Astro's
# `envField`, and live behind rate limiting / allow-lists on the vendor side).

# Kapa Custom Frontend integration ID for the "Ask AI" button in the header.
# If unset, the Ask AI button is hidden and the site still runs normally.
# Get yours at: https://app.kapa.ai/admin
# Kapa Website Widget integration ID for the "Ask AI" button in the sidebar.
# If unset, docs falls back to the shared widget integration ID used in preview/prod.
# Get yours at: https://app.kapa.ai/admin (Integrations → Widget → Copy integration ID).
# The same value is passed to the widget as `data-website-id`.
# PUBLIC_KAPA_INTEGRATION_ID=f31c5644-dd65-4f49-9762-bf580062afa8
PUBLIC_KAPA_INTEGRATION_ID=

# Optional Kapa project ID used to build internal conversation links in
# "Create ticket" email drafts:
# https://app.kapa.ai/<PROJECT_ID>/conversations/<THREAD_ID>
# You can find this in the Kapa dashboard URL while viewing the project.
PUBLIC_KAPA_PROJECT_ID=

# RudderStack analytics — required for docs_404 event tracking and page analytics.
# Without these, no analytics events are sent and the weekly 404 monitor will
# report zero data. Set both in the Vercel project environment (all environments).
Expand All @@ -21,3 +29,12 @@ PUBLIC_KAPA_INTEGRATION_ID=
# directive in vercel.json (a mismatch silently blocks all events in the browser).
PUBLIC_RUDDERSTACK_WRITE_KEY=
PUBLIC_RUDDERSTACK_DATA_PLANE_URL=

# Server-side handoff forwarding endpoint used by /api/support-handoff.
# This should point at the DevX service endpoint that creates Front
# conversations from docs chat handoff requests.
SUPPORT_HANDOFF_ENDPOINT_URL=

# Required when SUPPORT_HANDOFF_ENDPOINT_URL is set. Docs forwards this only
# to the DevX service as the `?secret=` query parameter, not as a bearer token.
SUPPORT_HANDOFF_SHARED_SECRET=
36 changes: 30 additions & 6 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export default defineConfig({
access: 'public',
optional: true,
}),
PUBLIC_KAPA_PROJECT_ID: envField.string({
context: 'client',
access: 'public',
optional: true,
}),
PUBLIC_RUDDERSTACK_WRITE_KEY: envField.string({
context: 'client',
access: 'public',
Expand All @@ -43,6 +48,16 @@ export default defineConfig({
access: 'public',
optional: true,
}),
SUPPORT_HANDOFF_ENDPOINT_URL: envField.string({
context: 'server',
access: 'secret',
optional: true,
}),
SUPPORT_HANDOFF_SHARED_SECRET: envField.string({
context: 'server',
access: 'secret',
optional: true,
}),
},
},
integrations: [
Expand All @@ -61,15 +76,24 @@ export default defineConfig({
baseUrl: 'https://github.com/warpdotdev/docs/edit/main/',
},
lastUpdated: true,
// Soft-wrap long lines by default. Expressive Code defaults to
// `overflow-x: auto` for `<pre>`, which combined with macOS's
// auto-hidden scrollbars made wide lines silently truncate.
// `wrap: true` adds the `.wrap` class so EC's `white-space: pre-wrap`
// kicks in; leading indents are preserved via its `span.indent` rule.
// Keep long lines unwrapped so code blocks use horizontal scrolling.
// This aligns docs behavior with the side chat renderer and preserves
// exact line shape for commands and snippets.
expressiveCode: {
defaultProps: {
wrap: true,
wrap: false,
},
// IMPORTANT: Expressive Code's Vite plugin rewrites Shiki's bundled
// theme registry (shiki/dist/themes.mjs) and strips every theme not
// listed as a *string* in its `themes` config. Starlight passes its
// themes as objects, so the registry is emptied for the entire Vite
// module graph — including the Kapa side-chat island, whose runtime
// createHighlighter(['github-light', 'github-dark']) then throws
// "theme is not included in this bundle" and falls back to plaintext.
// Keeping the registry intact restores chat code block highlighting.
// Only the requested themes are ever fetched at runtime (lazy chunks),
// so this does not bloat the pages served to visitors.
removeUnusedThemes: false,
// Map languages Shiki doesn't bundle to a safe fallback. PromQL
// blocks live in platform/self-hosting/monitoring.mdx;
// without this alias every build emits noisy "language could not be
Expand Down
34 changes: 4 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

235 changes: 235 additions & 0 deletions src/components/ArticleImageLightbox.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
---
// A singleton, delegated lightbox for article content and explicit chat image
// triggers. Event delegation keeps it active for future Starlight client-side
// swaps without wrapping article images in buttons or changing their layout.
---

<script>
const cleanupKey = '__warpDocsArticleImageLightboxCleanup';
type LightboxWindow = Window & {
__warpDocsArticleImageLightboxCleanup?: () => void;
};

function installArticleImageLightbox() {
const lightboxWindow = window as LightboxWindow;
lightboxWindow[cleanupKey]?.();

const lightbox = document.createElement('dialog');
lightbox.className = 'docs-image-lightbox';
lightbox.hidden = true;
lightbox.setAttribute('role', 'dialog');
lightbox.setAttribute('aria-modal', 'true');
lightbox.setAttribute('aria-labelledby', 'docs-image-lightbox-title');

const content = document.createElement('div');
content.className = 'docs-image-lightbox__content';
const title = document.createElement('span');
title.id = 'docs-image-lightbox-title';
title.className = 'sr-only';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'docs-image-lightbox__close';
closeButton.setAttribute('aria-label', 'Close expanded image');
closeButton.innerHTML =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m6 6 12 12M18 6 6 18"/></svg>';
const expandedImage = document.createElement('img');
expandedImage.className = 'docs-image-lightbox__image';
expandedImage.decoding = 'async';
expandedImage.loading = 'eager';
expandedImage.referrerPolicy = 'no-referrer';
const fallback = document.createElement('span');
fallback.className = 'docs-image-lightbox__fallback';
fallback.setAttribute('role', 'img');
fallback.textContent = 'Image unavailable';
fallback.hidden = true;

content.append(title, closeButton, expandedImage, fallback);
lightbox.append(content);
document.body.append(lightbox);

let activeTrigger: HTMLElement | null = null;

const isEligibleArticleImage = (image: HTMLImageElement) =>
image.matches('main .sl-markdown-content img') &&
!image.closest('.not-content, #sl-kapa-panel, [data-no-lightbox]');
const isEligibleKapaImage = (image: HTMLImageElement, trigger: HTMLElement) =>
trigger.matches('.sl-kapa-answer-image-button') &&
image.matches('.sl-kapa-answer-image') &&
image.closest('#sl-kapa-panel') !== null;

const isImageOnlyLink = (link: HTMLAnchorElement, image: HTMLImageElement) =>
link.textContent?.trim() === '' &&
link.children.length === 1 &&
link.firstElementChild?.contains(image);

const getImageTarget = (target: EventTarget | null) => {
if (!(target instanceof Element)) return null;
const trigger = target.closest<HTMLElement>('[data-warp-image-lightbox-trigger="true"]');
if (!trigger) return null;
const image =
trigger instanceof HTMLImageElement ? trigger : trigger.querySelector('img');
if (!(image instanceof HTMLImageElement)) return null;

return isEligibleArticleImage(image) || isEligibleKapaImage(image, trigger)
? { image, trigger }
: null;
};

const imageLabel = (image: HTMLImageElement) =>
image.alt.trim() || 'Documentation image';

const enhanceImages = () => {
document
.querySelectorAll<HTMLImageElement>('main .sl-markdown-content img')
.forEach((image) => {
if (!isEligibleArticleImage(image)) return;
const link = image.closest('a');
if (link instanceof HTMLAnchorElement) {
if (!isImageOnlyLink(link, image)) return;
image.dataset.docsImageLightboxImage = 'true';
link.dataset.warpImageLightboxTrigger = 'true';
link.setAttribute('role', 'button');
link.setAttribute('aria-haspopup', 'dialog');
link.setAttribute('aria-label', `Expand image: ${imageLabel(image)}`);
return;
}

image.dataset.docsImageLightboxImage = 'true';
image.dataset.warpImageLightboxTrigger = 'true';
image.tabIndex = 0;
image.setAttribute('role', 'button');
image.setAttribute('aria-haspopup', 'dialog');
image.setAttribute('aria-label', `Expand image: ${imageLabel(image)}`);
});
};

const close = ({ restoreFocus = true } = {}) => {
if (lightbox.hidden) return;
if (lightbox.open) {
lightbox.close();
}
lightbox.hidden = true;
expandedImage.removeAttribute('src');
document.documentElement.classList.remove('docs-image-lightbox-open');
document.body.classList.remove('docs-image-lightbox-open');
const trigger = activeTrigger;
activeTrigger = null;
if (restoreFocus) {
window.requestAnimationFrame(() => trigger?.focus());
}
};

const open = (image: HTMLImageElement, trigger: HTMLElement) => {
const source = image.currentSrc || image.src;
if (!source) return;

const label = imageLabel(image);
activeTrigger = trigger;
title.textContent = `Expanded image: ${label}`;
expandedImage.alt = label;
expandedImage.src = source;
expandedImage.hidden = false;
fallback.hidden = true;
fallback.setAttribute('aria-label', `${label} unavailable`);
lightbox.hidden = false;
if (!lightbox.open) {
lightbox.showModal();
}
document.documentElement.classList.add('docs-image-lightbox-open');
document.body.classList.add('docs-image-lightbox-open');
window.requestAnimationFrame(() => closeButton.focus());
};

const onDocumentClick = (event: MouseEvent) => {
const target = getImageTarget(event.target);
if (!target) return;
event.preventDefault();
event.stopPropagation();
open(target.image, target.trigger);
};

const onDocumentKeyDown = (event: KeyboardEvent) => {
if (!lightbox.hidden) {
event.stopPropagation();
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
close();
return;
}
if (event.key === 'Tab') {
event.preventDefault();
closeButton.focus();
}
return;
}

if (event.key !== 'Enter' && event.key !== ' ') return;
const target = getImageTarget(event.target);
if (!target) return;
event.preventDefault();
open(target.image, target.trigger);
};

const onLightboxClick = (event: MouseEvent) => {
// The content wrapper fills the dialog, so its empty top and bottom
// areas are visually backdrop even though they are not the dialog
// element itself. Only the image, close control, and fallback count
// as lightbox content that must not dismiss the dialog.
const path = event.composedPath();
const clickedLightboxContent = path.some(
(target) =>
target === expandedImage || target === closeButton || target === fallback
);
if (!clickedLightboxContent) close();
};
const onLightboxCancel = (event: Event) => {
event.preventDefault();
close();
};

const onImageError = () => {
expandedImage.hidden = true;
fallback.hidden = false;
};

const onPageSwap = () => {
close({ restoreFocus: false });
if (!document.body.contains(lightbox)) {
document.body.append(lightbox);
}
enhanceImages();
};

const onCloseButtonClick = () => close();
closeButton.addEventListener('click', onCloseButtonClick);
expandedImage.addEventListener('error', onImageError);
lightbox.addEventListener('click', onLightboxClick);
lightbox.addEventListener('cancel', onLightboxCancel);
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onDocumentKeyDown, true);
document.addEventListener('astro:after-swap', onPageSwap);
document.addEventListener('astro:page-load', onPageSwap);
enhanceImages();

lightboxWindow[cleanupKey] = () => {
close({ restoreFocus: false });
closeButton.removeEventListener('click', onCloseButtonClick);
expandedImage.removeEventListener('error', onImageError);
lightbox.removeEventListener('click', onLightboxClick);
lightbox.removeEventListener('cancel', onLightboxCancel);
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onDocumentKeyDown, true);
document.removeEventListener('astro:after-swap', onPageSwap);
document.removeEventListener('astro:page-load', onPageSwap);
lightbox.remove();
delete lightboxWindow[cleanupKey];
};
}

if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', installArticleImageLightbox, { once: true });
} else {
installArticleImageLightbox();
}
</script>
2 changes: 2 additions & 0 deletions src/components/CustomHead.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Default from '@astrojs/starlight/components/Head.astro';
import Analytics from '@vercel/analytics/astro';
import SpeedInsights from '@vercel/speed-insights/astro';
import RudderStackAnalytics from './RudderStackAnalytics.astro';
import ArticleImageLightbox from './ArticleImageLightbox.astro';

// Note: `<ClientRouter />` (Astro View Transitions) is intentionally NOT
// enabled here. Starlight's sidebar scroll persistence relies on the browser
Expand Down Expand Up @@ -154,6 +155,7 @@ const fontsHref =
onload="this.media='all'"
/>
<RudderStackAnalytics />
<ArticleImageLightbox />

<Analytics />
<SpeedInsights />
Expand Down
Loading
Loading