Skip to content
Open
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
19 changes: 19 additions & 0 deletions .vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,19 @@ const tutorialItems = [
{ slug: 'auth', text: 'Authentication' },
{ slug: 'caching', text: 'Caching' },
{ slug: 'cookies', text: 'Cookies' },
{ slug: 'decorators', text: 'Decorators' },
{ slug: 'lifecycle-hooks', text: 'Lifecycle Hooks' },
{ slug: 'logging', text: 'Logging' },
{ slug: 'plugins', text: 'Plugins' },
{ slug: 'response-marshalling', text: 'Response Marshalling' },
{ slug: 'routing', text: 'Routing' },
{ slug: 'server-methods', text: 'Server Methods' },
{ slug: 'serving-files', text: 'Serving Files' },
{ slug: 'sse', text: 'Server-Sent Events' },
{ slug: 'testing', text: 'Testing' },
{ slug: 'validation', text: 'Validation' },
{ slug: 'views', text: 'Views' },
{ slug: 'websockets', text: 'WebSockets' },
{ slug: 'community', text: 'Community' },
{ slug: 'express-to-hapi', text: 'Express to hapi' },
];
Expand All @@ -88,6 +93,17 @@ export default defineConfig({
markdown: {
config(md) {
md.use(groupIconMdPlugin);

// ```mermaid fences render client-side through components/Mermaid.vue.
const fence = md.renderer.rules.fence!;
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
const token = tokens[idx];
if (token.info.trim() !== 'mermaid') {
return fence(tokens, idx, options, env, self);
}

return `<Mermaid code="${encodeURIComponent(token.content)}" />`;
};
},
lineNumbers: true,
theme: {
Expand Down Expand Up @@ -186,6 +202,7 @@ export default defineConfig({
},
],
'/tutorials/en_US/': getTutorialSidebar('en_US'),
'/tutorials/es_ES/': getTutorialSidebar('es_ES'),
'/tutorials/ko_KR/': getTutorialSidebar('ko_KR'),
'/tutorials/pt_BR/': getTutorialSidebar('pt_BR'),
'/tutorials/tr_TR/': getTutorialSidebar('tr_TR'),
Expand All @@ -204,6 +221,8 @@ export default defineConfig({
title: 'hapi.dev',
titleTemplate: 'hapi.dev - :title',
vite: {
// Pre-bundled at startup; otherwise the first page with a diagram triggers a re-optimization and full reload.
optimizeDeps: { include: ['mermaid'] },
plugins: [groupIconVitePlugin()],
},
});
7 changes: 6 additions & 1 deletion .vitepress/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import './variables.css';
import './main.css';
import 'virtual:group-icons.css';

import { setupMermaidZoom } from './mermaid-zoom.js';
import { getRedirectPath } from './redirect.js';

import type { Theme } from 'vitepress';

const ApiOutline = defineAsyncComponent(() => import('../../components/ApiOutline.vue'));
const CarbonAds = defineAsyncComponent(() => import('../../components/CarbonAds.vue'));
const HomeContent = defineAsyncComponent(() => import('../../components/HomeContent.vue'));
const Mermaid = defineAsyncComponent(() => import('../../components/Mermaid.vue'));
const ModuleIndex = defineAsyncComponent(() => import('../../components/ModuleIndex.vue'));
const PluginsDirectory = defineAsyncComponent(() => import('../../components/PluginsDirectory.vue'));
const StatusContent = defineAsyncComponent(() => import('../../components/StatusContent.vue'));
Expand All @@ -27,12 +29,15 @@ export default {
enhanceApp({ app, router }) {
app.component('ApiOutline', ApiOutline);
app.component('HomeContent', HomeContent);
app.component('Mermaid', Mermaid);
app.component('ModuleIndex', ModuleIndex);
app.component('PluginsDirectory', PluginsDirectory);
app.component('StatusContent', StatusContent);
app.component('TutorialLang', TutorialLang);

if (typeof window !== 'undefined') {
setupMermaidZoom();

router.onBeforeRouteChange = (to) => {
const target = getRedirectPath(to);
if (target) {
Expand All @@ -41,7 +46,7 @@ export default {
}
};

const initialTarget = getRedirectPath(window.location.pathname);
const initialTarget = getRedirectPath(window.location.pathname + window.location.hash);
if (initialTarget) {
router.go(initialTarget);
}
Expand Down
74 changes: 74 additions & 0 deletions .vitepress/theme/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -421,3 +421,77 @@ select.form-control {
border: 1px solid var(--vp-c-divider);
margin: 0 0 0 20px;
}

/* ---------- Mermaid ---------- */
.vp-doc .mermaid {
display: flex;
justify-content: center;
margin: 16px 0;
padding: 24px 16px;
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
cursor: zoom-in;
}

/* Mermaid sizes label boxes at its own line-height, outside .vp-doc. */
.vp-doc .mermaid foreignObject p {
line-height: inherit;
}

.hapi-mermaid-lightbox {
position: fixed;
inset: 0;
z-index: 2000;
overflow: hidden;
touch-action: none;
user-select: none;
background: color-mix(in srgb, var(--vp-c-bg) 90%, transparent);
backdrop-filter: blur(4px);
cursor: grab;
}

.hapi-mermaid-lightbox:active {
cursor: grabbing;
}

.hapi-mermaid-lightbox-stage {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}

.hapi-mermaid-lightbox-stage svg {
display: block;
}

.hapi-mermaid-lightbox-close {
position: fixed;
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
font-size: 22px;
line-height: 1;
cursor: pointer;
}

.hapi-mermaid-lightbox-close:hover {
border-color: var(--vp-c-brand-1);
color: var(--vp-c-brand-1);
}

.hapi-mermaid-lightbox-hint {
position: fixed;
bottom: 16px;
left: 0;
right: 0;
text-align: center;
font-size: 12px;
color: var(--vp-c-text-3);
pointer-events: none;
}
184 changes: 184 additions & 0 deletions .vitepress/theme/mermaid-zoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
const SCALE_MIN = 0.1;
const SCALE_MAX = 40;

interface PointerPosition {
x: number;
y: number;
}

function naturalSize(svg: SVGSVGElement): { width: number; height: number } {
const viewBox = svg.viewBox.baseVal;
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
return { width: viewBox.width, height: viewBox.height };
}
const rect = svg.getBoundingClientRect();
return { width: Math.max(rect.width, 1), height: Math.max(rect.height, 1) };
}

function openLightbox(svg: SVGSVGElement, opener: HTMLElement) {
const { width, height } = naturalSize(svg);

const overlay = document.createElement('div');
overlay.className = 'hapi-mermaid-lightbox';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Diagram viewer');

const stage = document.createElement('div');
stage.className = 'hapi-mermaid-lightbox-stage';

// The diagram's styles and marker refs are scoped by its svg id; a renamed copy keeps them
// without colliding with the original on the page.
const markup = svg.id ? svg.outerHTML.split(svg.id).join(`${svg.id}-lightbox`) : svg.outerHTML;
stage.innerHTML = markup;

const clone = stage.querySelector('svg');
if (!clone) {
return;
}
clone.style.width = `${width}px`;
clone.style.height = `${height}px`;
clone.style.maxWidth = 'none';

const close = document.createElement('button');
close.className = 'hapi-mermaid-lightbox-close';
close.setAttribute('aria-label', 'Close diagram viewer');
close.textContent = '×';

const hint = document.createElement('div');
hint.className = 'hapi-mermaid-lightbox-hint';
hint.textContent = 'scroll or pinch to zoom · drag to pan · double-click to reset · esc to close';

overlay.append(stage, close, hint);

let scale = 1;
let tx = 0;
let ty = 0;

const apply = () => {
stage.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
};

const fit = () => {
scale = Math.min((window.innerWidth * 0.92) / width, (window.innerHeight * 0.92) / height);
tx = (window.innerWidth - width * scale) / 2;
ty = (window.innerHeight - height * scale) / 2;
apply();
};

const zoomAt = (clientX: number, clientY: number, factor: number) => {
const next = Math.min(SCALE_MAX, Math.max(SCALE_MIN, scale * factor));
const applied = next / scale;
tx = clientX - (clientX - tx) * applied;
ty = clientY - (clientY - ty) * applied;
scale = next;
apply();
};

const pointers = new Map<number, PointerPosition>();

const onWheel = (event: WheelEvent) => {
event.preventDefault();
zoomAt(event.clientX, event.clientY, Math.exp(-event.deltaY * 0.002));
};

const onPointerDown = (event: PointerEvent) => {
overlay.setPointerCapture(event.pointerId);
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
};

const onPointerMove = (event: PointerEvent) => {
const previous = pointers.get(event.pointerId);
if (!previous) {
return;
}
const current = { x: event.clientX, y: event.clientY };

if (pointers.size === 1) {
tx += current.x - previous.x;
ty += current.y - previous.y;
apply();
} else if (pointers.size === 2) {
const other = [...pointers.entries()].find(([id]) => id !== event.pointerId);
if (other) {
const [, anchor] = other;
const previousDistance = Math.hypot(previous.x - anchor.x, previous.y - anchor.y);
const currentDistance = Math.hypot(current.x - anchor.x, current.y - anchor.y);
if (previousDistance > 0) {
const midX = (current.x + anchor.x) / 2;
const midY = (current.y + anchor.y) / 2;
zoomAt(midX, midY, currentDistance / previousDistance);
}
}
}
pointers.set(event.pointerId, current);
};

const onPointerEnd = (event: PointerEvent) => {
pointers.delete(event.pointerId);
};

const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
teardown();
}

// The close button is the only control, so Tab stays on it instead of reaching the page behind.
if (event.key === 'Tab') {
event.preventDefault();
close.focus();
}
};

const teardown = () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('resize', fit);
overlay.remove();
document.body.style.removeProperty('overflow');
opener.focus();
};

overlay.addEventListener('wheel', onWheel, { passive: false });
overlay.addEventListener('pointerdown', onPointerDown);
overlay.addEventListener('pointermove', onPointerMove);
overlay.addEventListener('pointerup', onPointerEnd);
overlay.addEventListener('pointercancel', onPointerEnd);
overlay.addEventListener('dblclick', fit);
close.addEventListener('click', teardown);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('resize', fit);

document.body.style.overflow = 'hidden';
document.body.appendChild(overlay);
close.focus();
fit();
}

function openFrom(target: EventTarget | null) {
if (!(target instanceof Element)) {
return false;
}
const container = target.closest('.mermaid');
if (!(container instanceof HTMLElement) || container.closest('.hapi-mermaid-lightbox')) {
return false;
}
const svg = container.querySelector('svg');
if (!svg) {
return false;
}
openLightbox(svg, container);
return true;
}

/** Opens a rendered Mermaid diagram in a full-screen viewer with zoom and pan, by click, Enter, or Space. */
export function setupMermaidZoom() {
document.addEventListener('click', (event) => {
openFrom(event.target);
});

document.addEventListener('keydown', (event) => {
if ((event.key === 'Enter' || event.key === ' ') && openFrom(event.target)) {
event.preventDefault();
}
});
}
2 changes: 1 addition & 1 deletion .vitepress/theme/redirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const getRedirectPath = (toPath: string) => {
if (tutorialMatch) {
const oldName = tutorialMatch.at(1)!;
// If it's a locale code, skip (it's a directory)
if (['en_US', 'ko_KR', 'pt_BR', 'tr_TR', 'zh_CN'].includes(oldName)) {
if (['en_US', 'es_ES', 'ko_KR', 'pt_BR', 'tr_TR', 'zh_CN'].includes(oldName)) {
return null;
}
const newName = tutorialRenames[oldName] ?? oldName;
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ components/ # Vue 3 components
docs/ # VitePress markdown pages and static assets
├── api/ # hapi core API docs (dynamic routes)
├── module/ # Module pages (install, API, changelog)
├── tutorials/ # Tutorials in 5 languages (en_US, ko_KR, pt_BR, tr_TR, zh_CN)
├── tutorials/ # Tutorials in 6 languages (en_US, es_ES, ko_KR, pt_BR, tr_TR, zh_CN)
├── policies/ # Policy pages
├── resources/ # Resources (changelog, status, books, etc.)
└── public/ # Static assets (images, plugins.json)
Expand All @@ -81,10 +81,12 @@ generated/ # Auto-generated data (from pnpm fetch-data)

## Tutorials

Tutorials live in `docs/tutorials/{locale}/` with one markdown file per tutorial. Available locales: `en_US`, `ko_KR`, `pt_BR`, `tr_TR`, `zh_CN`.
Tutorials live in `docs/tutorials/{locale}/` with one markdown file per tutorial. Available locales: `en_US`, `es_ES`, `ko_KR`, `pt_BR`, `tr_TR`, `zh_CN`.

To add a new translation, create a directory under `docs/tutorials/` with the locale code (e.g. `fr_FR`) and add markdown files matching the English tutorial filenames.

A ` ```mermaid ` code block renders as a diagram (`components/Mermaid.vue`), loaded only on pages that have one; clicking a diagram opens it in a zoomable viewer. Mark key flowchart nodes with `class A,B highlight` instead of inline styles, so they follow the site theme in light and dark mode, and say in the text which nodes are highlighted and why, since color alone carries no meaning for every reader.

## Plugins

hapi.dev maintains a list of community-created plugins at [hapi.dev/plugins](http://hapi.dev/plugins). The plugin data lives in `docs/public/lib/plugins.json`. To add a plugin, send a [pull request](https://github.com/hapijs/hapi.dev/blob/master/docs/public/lib/plugins.json) with your addition. Please keep plugins in alphabetical order within their category.
Expand Down
Loading
Loading