diff --git a/.env.local.example b/.env.local.example index dae0eb8..a51caa8 100644 --- a/.env.local.example +++ b/.env.local.example @@ -73,3 +73,11 @@ AUTH_ZITADEL_ISSUER=https://auth.catholicdigitalcommons.org # openssl rand -base64 32 # Different value per env (prod / staging / dev); never share between them. AUTH_SECRET= + +# AUTH_URL: only needed if NEXT_PUBLIC_SITE_URL isn't already set to the +# public origin. lib/auth.ts auto-promotes NEXT_PUBLIC_SITE_URL → AUTH_URL +# when AUTH_URL is unset, which covers the Plesk Passenger case where +# Next.js standalone otherwise generates an OIDC redirect_uri pointing at +# the bind address (0.0.0.0:3000) instead of the public hostname. Set this +# explicitly if you need to override (e.g. behind multiple proxies). +# AUTH_URL= diff --git a/app/[lang]/my-bio/page.tsx b/app/[lang]/my-bio/page.tsx new file mode 100644 index 0000000..4fa4473 --- /dev/null +++ b/app/[lang]/my-bio/page.tsx @@ -0,0 +1,104 @@ +import { redirect } from 'next/navigation' +import { getTranslations } from 'next-intl/server' +import { auth } from '@/lib/auth' +import { + BioApiError, + fetchMyTeamMember, + fetchTeamMemberPost, + type BioDiscovery, + type BioPostContent, +} from '@/lib/bio-api' +import BioEditor from '@/components/BioEditor' + +// Server component for /[lang]/my-bio. Resolves the caller's linked +// team_member post, picks an initial language (Zitadel locale → URL +// locale → first available), fetches that language's current content, +// and hands everything to the client-side editor. +// +// Anonymous → redirect to sign-in. +// Authenticated but no team_member link → friendly "contact ops" message +// (no internal error surfaced). +export default async function MyBioPage({ + params, +}: { + params: Promise<{ lang: string }> +}) { + const { lang: urlLocale } = await params + const session = await auth() + if (!session?.user) { + redirect('/api/auth/signin') + } + const t = await getTranslations('MyBio') + + let discovery: BioDiscovery + try { + discovery = await fetchMyTeamMember(session) + } catch (err) { + if (err instanceof BioApiError && (err.status === 401 || err.status === 403)) { + return ( +
+
+

{t('title')}

+

{t('notLinked')}

+
+
+ ) + } + throw err + } + + const available = discovery.available_languages + if (available.length === 0) { + return ( +
+
+

{t('title')}

+

{t('noLanguages')}

+
+
+ ) + } + + // Prefer Zitadel locale claim → URL locale → first available. + const preferred = + pickAvailable(session.user.locale, available) ?? + pickAvailable(urlLocale, available) ?? + available[0] + + let initialPost: BioPostContent + try { + initialPost = await fetchTeamMemberPost(session, preferred.post_id) + } catch (err) { + // Stale group entry — should be caught by the WP-side post-type + // check too, but fall through gracefully here as well. + if (err instanceof BioApiError && err.status === 404) { + return ( +
+
+

{t('title')}

+

{t('loadError')}

+
+
+ ) + } + throw err + } + + return ( +
+ +
+ ) +} + +function pickAvailable( + slug: string | undefined, + list: L[] +): L | undefined { + if (!slug) return undefined + return list.find((entry) => entry.slug === slug) +} diff --git a/app/api/my-bio/check/route.ts b/app/api/my-bio/check/route.ts new file mode 100644 index 0000000..38ab2fb --- /dev/null +++ b/app/api/my-bio/check/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { fetchMyTeamMember, type BioDiscovery } from '@/lib/bio-api' + +// GET /api/my-bio/check +// Tiny proxy used by the AuthButton header dropdown to decide whether +// to show the "Edit my bio" entry. Returns {linked, available_languages} +// rather than just a boolean so the editor page can reuse the same +// fetch without a second round-trip if desired. +// +// Fails soft on EVERY error path: this is a UI-decoration endpoint, not +// a security boundary. Showing or hiding the dropdown entry must never +// surface a 500 to the browser console. Anonymous, unlinked, expired +// token, unreachable WP, missing env vars — all collapse to +// {linked: false} with HTTP 200, and the underlying error is +// console.error'd server-side for diagnosis. The /[lang]/my-bio page +// itself still surfaces real errors as the appropriate localized copy. +export async function GET() { + const session = await auth() + if (!session?.user) { + return NextResponse.json({ linked: false, available_languages: [] }) + } + try { + const discovery: BioDiscovery = await fetchMyTeamMember(session) + return NextResponse.json({ + linked: true, + team_member_id: discovery.team_member_id, + available_languages: discovery.available_languages, + }) + } catch (err) { + console.error('[my-bio/check] discovery failed (degrading to linked=false):', err) + return NextResponse.json({ linked: false, available_languages: [] }) + } +} diff --git a/app/api/my-bio/load/[lang]/route.ts b/app/api/my-bio/load/[lang]/route.ts new file mode 100644 index 0000000..d6b9a65 --- /dev/null +++ b/app/api/my-bio/load/[lang]/route.ts @@ -0,0 +1,46 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { auth } from '@/lib/auth' +import { + BioApiError, + fetchMyTeamMember, + fetchTeamMemberPost, +} from '@/lib/bio-api' + +// GET /api/my-bio/load/{lang} +// Resolves the requested language's post id via the discovery endpoint +// (so the post_id never has to be trusted from the client) and returns +// the editable post content. Used by BioEditor when the user switches +// the language selector. +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ lang: string }> } +) { + const { lang } = await params + if (!/^[a-z]{2}$/.test(lang)) { + return NextResponse.json({ error: 'invalid_lang' }, { status: 400 }) + } + const session = await auth() + if (!session?.user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + try { + const discovery = await fetchMyTeamMember(session) + const entry = discovery.available_languages.find((l) => l.slug === lang) + if (!entry) { + return NextResponse.json( + { error: 'no_translation_for_lang' }, + { status: 404 } + ) + } + const post = await fetchTeamMemberPost(session, entry.post_id) + return NextResponse.json(post) + } catch (err) { + if (err instanceof BioApiError) { + return NextResponse.json( + { error: err.code ?? 'wp_error', message: err.message }, + { status: err.status } + ) + } + throw err + } +} diff --git a/app/api/my-bio/save/route.ts b/app/api/my-bio/save/route.ts new file mode 100644 index 0000000..bb95ac7 --- /dev/null +++ b/app/api/my-bio/save/route.ts @@ -0,0 +1,69 @@ +import { NextResponse, type NextRequest } from 'next/server' +import { auth } from '@/lib/auth' +import { BioApiError, saveMyTeamMember, type BioSavePayload } from '@/lib/bio-api' + +// Allow-list of payload fields we forward to the WP PATCH endpoint. +// Extra keys in the request body are dropped silently (forward-compat +// friendly); any allow-listed field present with a non-string value +// rejects the whole request 400 (catches bug/abuse alike). +const ALLOWED_PAYLOAD_FIELDS = [ + 'content', + 'member_title', + 'member_linkedin_url', + 'member_github_url', +] as const + +// POST /api/my-bio/save +// Forwards the bio editor's payload to the WP PATCH endpoint, attaching +// the user's Zitadel access token server-side so it never reaches the +// browser. The client posts: +// { lang: 'de', content: '...', member_title: '...', ... } +// We pull `lang` out, allow-list + type-check the remaining fields, and +// hand the sanitized object to saveMyTeamMember. WP-side +// sanitize_callbacks (wp_kses_post, sanitize_text_field, esc_url_raw) +// and the URL hostname allow-list run downstream; this is the first +// validation checkpoint. +export async function POST(request: NextRequest) { + const session = await auth() + if (!session?.user) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'invalid_json' }, { status: 400 }) + } + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return NextResponse.json({ error: 'invalid_body' }, { status: 400 }) + } + const record = body as Record + const lang = record.lang + if (typeof lang !== 'string') { + return NextResponse.json({ error: 'missing_lang' }, { status: 400 }) + } + const payload: BioSavePayload = {} + for (const field of ALLOWED_PAYLOAD_FIELDS) { + if (!(field in record)) continue + const value = record[field] + if (typeof value !== 'string') { + return NextResponse.json( + { error: 'invalid_field', field, message: `${field} must be a string` }, + { status: 400 } + ) + } + payload[field] = value + } + try { + const result = await saveMyTeamMember(session, lang, payload) + return NextResponse.json(result) + } catch (err) { + if (err instanceof BioApiError) { + return NextResponse.json( + { error: err.code ?? 'wp_error', message: err.message }, + { status: err.status } + ) + } + throw err + } +} diff --git a/components/AuthButton.tsx b/components/AuthButton.tsx index 69e6701..3d405f6 100644 --- a/components/AuthButton.tsx +++ b/components/AuthButton.tsx @@ -5,19 +5,21 @@ import { useSession, signIn, signOut } from 'next-auth/react' import { useTranslations } from 'next-intl' import { ChevronDownIcon, UserIcon } from '@heroicons/react/24/outline' import clsx from 'clsx' +import { Link } from '@/src/i18n/navigation' /** * Header sign-in / sign-out control. * * Unauthenticated → "Sign in" button kicking off the Zitadel OIDC flow. - * Authenticated → user dropdown with email + sign-out. Future entries - * (e.g. "Edit my bio") will be conditional on session.user.roles or on - * a server-resolved teamMemberId — kept out of this Phase 2 cut. + * Authenticated → user dropdown with email + sign-out, plus an "Edit + * my bio" entry when the caller is linked to a team_member post (resolved + * via the /api/my-bio/check route). */ export default function AuthButton() { const { data: session, status } = useSession() const t = useTranslations('Auth') const [isOpen, setIsOpen] = useState(false) + const [hasBioLink, setHasBioLink] = useState(false) const closeTimeout = useRef | null>(null) const openDropdown = useCallback(() => { @@ -56,6 +58,30 @@ export default function AuthButton() { } }, []) + // Resolve the user's team_member link via the server route once the + // session is authenticated. The route is cheap (it short-circuits to + // 200 for anon and for authenticated-but-not-linked users), so a + // single fire-and-forget request keeps the dropdown decision off the + // hot path. We don't reset hasBioLink when the session disappears — + // the dropdown is gated on `session.user` below, so any stale `true` + // is invisible until the user signs in again, at which point the + // effect re-fires. + useEffect(() => { + if (status !== 'authenticated') return + const controller = new AbortController() + fetch('/api/my-bio/check', { signal: controller.signal, cache: 'no-store' }) + .then((res) => (res.ok ? res.json() : null)) + .then((body) => { + if (body && typeof body === 'object' && 'linked' in body) { + setHasBioLink(Boolean(body.linked)) + } + }) + .catch(() => { + /* aborted or network error — leave hasBioLink as-is */ + }) + return () => controller.abort() + }, [status]) + if (status === 'loading') { return (
@@ -122,6 +148,16 @@ export default function AuthButton() { {session.user.email}
)} + {hasBioLink && ( + setIsOpen(false)} + className="block px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-cdcf-navy" + > + {t('editMyBio')} + + )} + + + ) +} diff --git a/lib/auth.ts b/lib/auth.ts index b85cb0b..e618ddf 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -2,6 +2,21 @@ import NextAuth, { type DefaultSession } from 'next-auth' import type { JWT } from 'next-auth/jwt' import Zitadel from 'next-auth/providers/zitadel' +// Plesk Passenger surfaces the bind address (0.0.0.0:3000) to Next.js +// standalone instead of the public hostname, so Auth.js's redirect_uri +// construction — even with trustHost: true — produces +// https://0.0.0.0:3000/api/auth/callback/zitadel, which Zitadel +// rejects with "invalid_request: requested redirect_uri is missing in +// the client configuration". The canonical fix per Auth.js v5 is to set +// AUTH_URL to the public origin. We already configure NEXT_PUBLIC_SITE_URL +// per environment (prod / staging / dev) at build time + runtime via Plesk's +// app env, so promote it to AUTH_URL when AUTH_URL itself isn't set. +// See `project_plesk_passenger_port_leak` memory + proxy.ts for the +// sibling fix that normalizes redirect-Location leaks the same way. +if (!process.env.AUTH_URL && process.env.NEXT_PUBLIC_SITE_URL) { + process.env.AUTH_URL = process.env.NEXT_PUBLIC_SITE_URL +} + // Augment NextAuth's session/JWT types with the claims we surface. // Kept inline (rather than in a separate .d.ts) so this file is the // single source of truth for the shape we expose to consumers. diff --git a/lib/bio-api.test.ts b/lib/bio-api.test.ts new file mode 100644 index 0000000..892cd5d --- /dev/null +++ b/lib/bio-api.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Session } from 'next-auth' + +// Mock getAccessToken so we can simulate a session with/without a +// usable bearer (the helper checks for an undefined return from this +// function to throw BioApiError(401)). The real auth-utils transitively +// imports next/navigation which fails outside Next; the mock skips that. +vi.mock('@/lib/auth-utils', () => ({ + getAccessToken: vi.fn(), +})) + +import { getAccessToken } from '@/lib/auth-utils' +import { + BioApiError, + fetchMyTeamMember, + fetchTeamMemberPost, + saveMyTeamMember, +} from './bio-api' + +const mockedGetAccessToken = vi.mocked(getAccessToken) + +const session: Session = { + accessToken: 'token-abc', + expires: '2099-01-01T00:00:00.000Z', +} + +let fetchMock: ReturnType + +beforeEach(() => { + vi.resetAllMocks() + process.env.WP_REST_URL = 'https://wp.example.org/wp-json' + // Clear so tests of the fallback branch start from a clean slate. + delete process.env.WP_GRAPHQL_URL + mockedGetAccessToken.mockReturnValue('token-abc') + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) +}) + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +// ─── fetchMyTeamMember ─────────────────────────────────────────────── + +describe('fetchMyTeamMember', () => { + it('attaches the bearer token and returns the parsed discovery payload', async () => { + fetchMock.mockResolvedValue( + jsonResponse(200, { + team_member_id: 702, + available_languages: [ + { slug: 'en', post_id: 702, title: 'Me', status: 'publish' }, + ], + }) + ) + + const result = await fetchMyTeamMember(session) + + expect(result.team_member_id).toBe(702) + expect(fetchMock).toHaveBeenCalledOnce() + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://wp.example.org/wp-json/cdcf/v1/my-team-member') + expect((init as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer token-abc', + }) + }) + + it('throws BioApiError(401) when the session has no access token', async () => { + mockedGetAccessToken.mockReturnValue(undefined) + + await expect(fetchMyTeamMember(session)).rejects.toMatchObject({ + name: 'BioApiError', + status: 401, + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('propagates a 403 from WP as a BioApiError', async () => { + fetchMock.mockResolvedValue( + jsonResponse(403, { + code: 'rest_no_team_member_link', + message: 'Not linked', + }) + ) + + const err = await fetchMyTeamMember(session).catch((e) => e) + expect(err).toBeInstanceOf(BioApiError) + expect(err.status).toBe(403) + expect(err.code).toBe('rest_no_team_member_link') + }) + + it('throws when both WP_REST_URL and WP_GRAPHQL_URL are unset', async () => { + delete process.env.WP_REST_URL + delete process.env.WP_GRAPHQL_URL + + const err = await fetchMyTeamMember(session).catch((e) => e) + expect(err).toBeInstanceOf(BioApiError) + expect(err.status).toBe(500) + expect(err.code).toBe('config_missing') + }) + + it('derives the REST URL from WP_GRAPHQL_URL when WP_REST_URL is unset', async () => { + delete process.env.WP_REST_URL + process.env.WP_GRAPHQL_URL = 'https://cms.example.org/graphql' + fetchMock.mockResolvedValue( + jsonResponse(200, { team_member_id: 1, available_languages: [] }) + ) + + await fetchMyTeamMember(session) + + const [url] = fetchMock.mock.calls[0] + expect(url).toBe('https://cms.example.org/wp-json/cdcf/v1/my-team-member') + }) +}) + +// ─── fetchTeamMemberPost ───────────────────────────────────────────── + +describe('fetchTeamMemberPost', () => { + it('hits /wp/v2 with context=edit and normalises the response', async () => { + fetchMock.mockResolvedValue( + jsonResponse(200, { + id: 703, + title: { raw: 'Mein Name' }, + content: { raw: '

Hallo.

', rendered: '

Hallo.

' }, + acf: { + member_title: 'Theologe', + member_linkedin_url: 'https://linkedin.com/in/me', + member_github_url: '', + }, + }) + ) + + const post = await fetchTeamMemberPost(session, 703) + + expect(post).toEqual({ + id: 703, + title: 'Mein Name', + content: '

Hallo.

', + member_title: 'Theologe', + member_linkedin_url: 'https://linkedin.com/in/me', + member_github_url: '', + }) + const [url] = fetchMock.mock.calls[0] + expect(url).toBe( + 'https://wp.example.org/wp-json/wp/v2/team_member/703?context=edit' + ) + }) + + it('falls back to rendered title/content when raw is absent', async () => { + fetchMock.mockResolvedValue( + jsonResponse(200, { + id: 999, + title: { rendered: 'Rendered Name' }, + content: { rendered: '

Rendered.

' }, + acf: {}, + }) + ) + + const post = await fetchTeamMemberPost(session, 999) + + expect(post.title).toBe('Rendered Name') + expect(post.content).toBe('

Rendered.

') + expect(post.member_title).toBeUndefined() + }) + + it('propagates non-200 errors as BioApiError', async () => { + fetchMock.mockResolvedValue(jsonResponse(404, { code: 'rest_post_invalid_id' })) + + const err = await fetchTeamMemberPost(session, 1).catch((e) => e) + expect(err).toBeInstanceOf(BioApiError) + expect(err.status).toBe(404) + }) +}) + +// ─── saveMyTeamMember ──────────────────────────────────────────────── + +describe('saveMyTeamMember', () => { + it('PATCHes /cdcf/v1/my-team-member/{lang} with the payload', async () => { + fetchMock.mockResolvedValue( + jsonResponse(200, { + post_id: 703, + queued: ['en', 'it', 'es', 'fr', 'pt'], + errors: [], + }) + ) + + const result = await saveMyTeamMember(session, 'de', { + content: '

Bio.

', + member_title: 'Theologe', + }) + + expect(result.queued).toEqual(['en', 'it', 'es', 'fr', 'pt']) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://wp.example.org/wp-json/cdcf/v1/my-team-member/de') + const reqInit = init as RequestInit + expect(reqInit.method).toBe('PATCH') + expect(reqInit.headers).toMatchObject({ + Authorization: 'Bearer token-abc', + 'Content-Type': 'application/json', + }) + expect(JSON.parse(reqInit.body as string)).toEqual({ + content: '

Bio.

', + member_title: 'Theologe', + }) + }) + + it('rejects malformed lang slugs before touching the network', async () => { + const err = await saveMyTeamMember(session, 'deu', {}).catch((e) => e) + expect(err).toBeInstanceOf(BioApiError) + expect(err.status).toBe(400) + expect(err.code).toBe('invalid_lang') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('surfaces the WP-side rest_invalid_url 400', async () => { + fetchMock.mockResolvedValue( + jsonResponse(400, { + code: 'rest_invalid_url', + message: 'LinkedIn URL must point at linkedin.com (or empty to clear).', + }) + ) + + const err = await saveMyTeamMember(session, 'en', { + member_linkedin_url: 'https://evil.example.org/in/me', + }).catch((e) => e) + expect(err).toBeInstanceOf(BioApiError) + expect(err.status).toBe(400) + expect(err.code).toBe('rest_invalid_url') + expect(err.message).toMatch(/LinkedIn/) + }) + + it('surfaces a 403 when the ownership invariant fails', async () => { + fetchMock.mockResolvedValue( + jsonResponse(403, { + code: 'rest_forbidden', + message: 'You do not own this team_member.', + }) + ) + + const err = await saveMyTeamMember(session, 'en', { content: '

x

' }) + .catch((e) => e) + expect(err.status).toBe(403) + expect(err.code).toBe('rest_forbidden') + }) +}) diff --git a/lib/bio-api.ts b/lib/bio-api.ts new file mode 100644 index 0000000..2899b71 --- /dev/null +++ b/lib/bio-api.ts @@ -0,0 +1,198 @@ +// Server-only helpers for the bio self-edit flow. +// +// All three functions attach the caller's Zitadel access token from the +// Auth.js session as a Bearer header. The WP-side bearer validator +// (wordpress/themes/cdcf-headless/includes/auth/zitadel-bearer.php) then +// resolves the email claim to a WP user before the cdcf/v1 handler runs. +// These helpers are how the access token reaches WP without ever passing +// through the browser. + +import 'server-only' +import type { Session } from 'next-auth' +import { getAccessToken } from '@/lib/auth-utils' + +export type BioLanguage = { + slug: string + post_id: number + title: string + status: string +} + +export type BioDiscovery = { + team_member_id: number + available_languages: BioLanguage[] +} + +export type BioPostContent = { + id: number + title: string + content: string + member_title?: string + member_linkedin_url?: string + member_github_url?: string +} + +export type BioSaveResponse = { + post_id: number + queued: string[] + errors: string[] +} + +export class BioApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code?: string + ) { + super(message) + this.name = 'BioApiError' + } +} + +function getWpRestUrl(): string { + // Fall back to WP_GRAPHQL_URL → /wp-json so deploys that only configure + // the GraphQL endpoint (the historical default — WP_REST_URL was added + // later for the Python CLI) still work for these helpers. Both vars + // resolve to the same WordPress origin in every production-shaped + // deploy. + const url = + process.env.WP_REST_URL ?? + process.env.WP_GRAPHQL_URL?.replace(/\/graphql\/?$/, '/wp-json') + if (!url) { + throw new BioApiError( + 'Neither WP_REST_URL nor WP_GRAPHQL_URL is configured', + 500, + 'config_missing' + ) + } + return url.replace(/\/$/, '') +} + +function bearerHeader(session: Session | null | undefined): Record { + const token = getAccessToken(session) + if (!token) { + throw new BioApiError('No access token on session', 401, 'no_token') + } + return { Authorization: `Bearer ${token}` } +} + +async function readJson(response: Response): Promise { + try { + return await response.json() + } catch { + return null + } +} + +function toBioApiError(body: unknown, fallback: string, status: number): BioApiError { + if (body && typeof body === 'object') { + const obj = body as { message?: unknown; code?: unknown } + const message = typeof obj.message === 'string' ? obj.message : fallback + const code = typeof obj.code === 'string' ? obj.code : undefined + return new BioApiError(message, status, code) + } + return new BioApiError(fallback, status) +} + +export async function fetchMyTeamMember(session: Session | null): Promise { + const response = await fetch(`${getWpRestUrl()}/cdcf/v1/my-team-member`, { + headers: bearerHeader(session), + cache: 'no-store', + }) + const body = await readJson(response) + if (!response.ok) { + throw toBioApiError(body, 'Discovery request failed', response.status) + } + return body as BioDiscovery +} + +export async function fetchTeamMemberPost( + session: Session | null, + postId: number +): Promise { + // Hits the core /wp/v2 endpoint (NOT /cdcf/v1) — Polylang exposes the + // language siblings as independent posts so we can fetch the {lang} + // version directly. The ?_embed=false keeps the payload tight. + // Use context=edit so unfiltered content is returned for editing + // (default 'view' returns the rendered/sanitized HTML which we'd + // then re-edit, causing drift). + const response = await fetch( + `${getWpRestUrl()}/wp/v2/team_member/${postId}?context=edit`, + { + headers: bearerHeader(session), + cache: 'no-store', + } + ) + const body = await readJson(response) + if (!response.ok) { + throw toBioApiError(body, 'Failed to load post', response.status) + } + return normaliseTeamMemberPost(body) +} + +function normaliseTeamMemberPost(body: unknown): BioPostContent { + const obj = (body ?? {}) as { + id?: unknown + title?: { rendered?: unknown; raw?: unknown } + content?: { rendered?: unknown; raw?: unknown } + acf?: { + member_title?: unknown + member_linkedin_url?: unknown + member_github_url?: unknown + } + } + const titleSrc = + (typeof obj.title?.raw === 'string' && obj.title.raw) || + (typeof obj.title?.rendered === 'string' && obj.title.rendered) || + '' + const contentSrc = + (typeof obj.content?.raw === 'string' && obj.content.raw) || + (typeof obj.content?.rendered === 'string' && obj.content.rendered) || + '' + return { + id: typeof obj.id === 'number' ? obj.id : 0, + title: titleSrc, + content: contentSrc, + member_title: stringOrUndefined(obj.acf?.member_title), + member_linkedin_url: stringOrUndefined(obj.acf?.member_linkedin_url), + member_github_url: stringOrUndefined(obj.acf?.member_github_url), + } +} + +function stringOrUndefined(v: unknown): string | undefined { + return typeof v === 'string' ? v : undefined +} + +export type BioSavePayload = { + content?: string + member_title?: string + member_linkedin_url?: string + member_github_url?: string +} + +export async function saveMyTeamMember( + session: Session | null, + lang: string, + payload: BioSavePayload +): Promise { + if (!/^[a-z]{2}$/.test(lang)) { + throw new BioApiError('Invalid language slug', 400, 'invalid_lang') + } + const response = await fetch( + `${getWpRestUrl()}/cdcf/v1/my-team-member/${lang}`, + { + method: 'PATCH', + headers: { + ...bearerHeader(session), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + cache: 'no-store', + } + ) + const body = await readJson(response) + if (!response.ok) { + throw toBioApiError(body, 'Save failed', response.status) + } + return body as BioSaveResponse +} diff --git a/messages/de.json b/messages/de.json index 1e39b59..2da539b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -287,6 +287,26 @@ "Auth": { "signIn": "Anmelden", "signOut": "Abmelden", - "loading": "Wird geladen\u2026" + "loading": "Wird geladen\u2026", + "editMyBio": "Meine Bio bearbeiten" + }, + "MyBio": { + "title": "Meine Bio bearbeiten", + "intro": "Aktualisiere deine Bio in der Sprache deiner Wahl \u2014 \u00dcbersetzungen in die anderen 5 Sprachen werden automatisch eingereiht.", + "languageLabel": "Sprache", + "postTitleLabel": "Name", + "contentLabel": "Bio", + "memberTitleLabel": "Position / Zugeh\u00f6rigkeit", + "linkedinLabel": "LinkedIn-URL", + "githubLabel": "GitHub-URL", + "save": "\u00c4nderungen speichern", + "saving": "Wird gespeichert\u2026", + "savedQueued": "Gespeichert. \u00dcbersetzungen eingereiht f\u00fcr {langs}.", + "savedNoChange": "Gespeichert.", + "saveError": "Speichern fehlgeschlagen: {error}", + "unsavedSwitch": "Du hast nicht gespeicherte \u00c4nderungen. Verwerfen und Sprache wechseln?", + "notLinked": "Dein Konto ist mit keinem team_member-Profil verkn\u00fcpft. Wende dich an einen Administrator.", + "noLanguages": "Es sind noch keine Sprachversionen zur Bearbeitung verf\u00fcgbar.", + "loadError": "Diese Version deiner Bio konnte nicht geladen werden." } } diff --git a/messages/en.json b/messages/en.json index 252f1b9..e05d30e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -287,6 +287,26 @@ "Auth": { "signIn": "Sign in", "signOut": "Sign out", - "loading": "Loading\u2026" + "loading": "Loading\u2026", + "editMyBio": "Edit my bio" + }, + "MyBio": { + "title": "Edit my bio", + "intro": "Update your bio in the language you're most comfortable with \u2014 translations into the other 5 languages will be queued automatically.", + "languageLabel": "Language", + "postTitleLabel": "Name", + "contentLabel": "Bio", + "memberTitleLabel": "Position / affiliation", + "linkedinLabel": "LinkedIn URL", + "githubLabel": "GitHub URL", + "save": "Save changes", + "saving": "Saving\u2026", + "savedQueued": "Saved. Translations queued for {langs}.", + "savedNoChange": "Saved.", + "saveError": "Could not save: {error}", + "unsavedSwitch": "You have unsaved changes. Discard them and switch language?", + "notLinked": "Your account isn't linked to a team_member profile. Contact an administrator.", + "noLanguages": "No language versions are available to edit yet.", + "loadError": "Could not load this version of your bio." } } diff --git a/messages/es.json b/messages/es.json index dc69152..acaff2b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -287,6 +287,26 @@ "Auth": { "signIn": "Iniciar sesi\u00f3n", "signOut": "Cerrar sesi\u00f3n", - "loading": "Cargando\u2026" + "loading": "Cargando\u2026", + "editMyBio": "Editar mi biograf\u00eda" + }, + "MyBio": { + "title": "Editar mi biograf\u00eda", + "intro": "Actualiza tu biograf\u00eda en el idioma que prefieras \u2014 las traducciones a los otros 5 idiomas se pondr\u00e1n en cola autom\u00e1ticamente.", + "languageLabel": "Idioma", + "postTitleLabel": "Nombre", + "contentLabel": "Biograf\u00eda", + "memberTitleLabel": "Cargo / afiliaci\u00f3n", + "linkedinLabel": "URL de LinkedIn", + "githubLabel": "URL de GitHub", + "save": "Guardar cambios", + "saving": "Guardando\u2026", + "savedQueued": "Guardado. Traducciones en cola para {langs}.", + "savedNoChange": "Guardado.", + "saveError": "No se pudo guardar: {error}", + "unsavedSwitch": "Tienes cambios sin guardar. \u00bfDescartarlos y cambiar de idioma?", + "notLinked": "Tu cuenta no est\u00e1 vinculada a un perfil de team_member. Contacta a un administrador.", + "noLanguages": "A\u00fan no hay versiones en otros idiomas para editar.", + "loadError": "No se pudo cargar esta versi\u00f3n de tu biograf\u00eda." } } diff --git a/messages/fr.json b/messages/fr.json index 4fcdf47..bd02243 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -287,6 +287,26 @@ "Auth": { "signIn": "Se connecter", "signOut": "Se d\u00e9connecter", - "loading": "Chargement\u2026" + "loading": "Chargement\u2026", + "editMyBio": "Modifier ma bio" + }, + "MyBio": { + "title": "Modifier ma bio", + "intro": "Mettez \u00e0 jour votre bio dans la langue de votre choix \u2014 les traductions dans les 5 autres langues seront mises en file automatiquement.", + "languageLabel": "Langue", + "postTitleLabel": "Nom", + "contentLabel": "Bio", + "memberTitleLabel": "Fonction / affiliation", + "linkedinLabel": "URL LinkedIn", + "githubLabel": "URL GitHub", + "save": "Enregistrer", + "saving": "Enregistrement\u2026", + "savedQueued": "Enregistr\u00e9. Traductions mises en file pour {langs}.", + "savedNoChange": "Enregistr\u00e9.", + "saveError": "\u00c9chec de l'enregistrement : {error}", + "unsavedSwitch": "Vous avez des modifications non enregistr\u00e9es. Les abandonner et changer de langue ?", + "notLinked": "Votre compte n'est pas li\u00e9 \u00e0 un profil team_member. Contactez un administrateur.", + "noLanguages": "Aucune version linguistique n'est disponible \u00e0 la modification.", + "loadError": "Impossible de charger cette version de votre bio." } } diff --git a/messages/it.json b/messages/it.json index 24ef43c..7ba801e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -287,6 +287,26 @@ "Auth": { "signIn": "Accedi", "signOut": "Esci", - "loading": "Caricamento\u2026" + "loading": "Caricamento\u2026", + "editMyBio": "Modifica la mia bio" + }, + "MyBio": { + "title": "Modifica la mia bio", + "intro": "Aggiorna la tua bio nella lingua che preferisci \u2014 le traduzioni nelle altre 5 lingue verranno accodate automaticamente.", + "languageLabel": "Lingua", + "postTitleLabel": "Nome", + "contentLabel": "Bio", + "memberTitleLabel": "Ruolo / affiliazione", + "linkedinLabel": "URL LinkedIn", + "githubLabel": "URL GitHub", + "save": "Salva", + "saving": "Salvataggio\u2026", + "savedQueued": "Salvato. Traduzioni accodate per {langs}.", + "savedNoChange": "Salvato.", + "saveError": "Impossibile salvare: {error}", + "unsavedSwitch": "Hai modifiche non salvate. Scartarle e cambiare lingua?", + "notLinked": "Il tuo account non \u00e8 collegato a un profilo team_member. Contatta un amministratore.", + "noLanguages": "Nessuna versione linguistica disponibile per la modifica.", + "loadError": "Impossibile caricare questa versione della tua bio." } } diff --git a/messages/pt.json b/messages/pt.json index bc3a26a..fcc5299 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -287,6 +287,26 @@ "Auth": { "signIn": "Entrar", "signOut": "Sair", - "loading": "A carregar\u2026" + "loading": "A carregar\u2026", + "editMyBio": "Editar a minha bio" + }, + "MyBio": { + "title": "Editar a minha bio", + "intro": "Atualize a sua bio no idioma que preferir \u2014 as tradu\u00e7\u00f5es para os outros 5 idiomas ser\u00e3o enfileiradas automaticamente.", + "languageLabel": "Idioma", + "postTitleLabel": "Nome", + "contentLabel": "Bio", + "memberTitleLabel": "Cargo / afilia\u00e7\u00e3o", + "linkedinLabel": "URL do LinkedIn", + "githubLabel": "URL do GitHub", + "save": "Guardar altera\u00e7\u00f5es", + "saving": "A guardar\u2026", + "savedQueued": "Guardado. Tradu\u00e7\u00f5es enfileiradas para {langs}.", + "savedNoChange": "Guardado.", + "saveError": "N\u00e3o foi poss\u00edvel guardar: {error}", + "unsavedSwitch": "Tem altera\u00e7\u00f5es por guardar. Descart\u00e1-las e mudar de idioma?", + "notLinked": "A sua conta n\u00e3o est\u00e1 ligada a um perfil team_member. Contacte um administrador.", + "noLanguages": "Ainda n\u00e3o h\u00e1 vers\u00f5es lingu\u00edsticas dispon\u00edveis para edi\u00e7\u00e3o.", + "loadError": "N\u00e3o foi poss\u00edvel carregar esta vers\u00e3o da sua bio." } } diff --git a/package-lock.json b/package-lock.json index 7ea347e..0a3877e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,10 @@ "version": "0.1.0", "dependencies": { "@heroicons/react": "^2.2.0", + "@tiptap/extension-link": "^3.26.0", + "@tiptap/pm": "^3.26.0", + "@tiptap/react": "^3.26.0", + "@tiptap/starter-kit": "^3.26.0", "clsx": "^2.1.1", "next": "^16.2.6", "next-auth": "5.0.0-beta.31", @@ -555,6 +559,34 @@ "npm": ">=6.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT", + "optional": true + }, "node_modules/@formatjs/fast-memoize": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.2.tgz", @@ -3017,6 +3049,435 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, + "node_modules/@tiptap/core": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.26.0.tgz", + "integrity": "sha512-7jTed/RirIVsp+lLdLvGzGqF3EBGpnGHGYKOwz6t28V2BIJLAFdUhfEVdWie7xPxQNWK0TP+fPlsqZS0vxfHBg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.26.0.tgz", + "integrity": "sha512-57accpka9affjiJRjP2LMNCDJDTMjTvO23RJCxtP43sp9cTIZ7YZnyDfRxCINTRBNK0X4o4w2+emOLyRwsk3CA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.26.0.tgz", + "integrity": "sha512-j6CzTMofcGJ5iMoUgDRQpM0FkG00jBID3aKqs+UBbgtzLgtG/CI/91tMFv0XPC30LeFA895qYgvGZtHdejZhiQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.26.0.tgz", + "integrity": "sha512-H2E3Hp0lV79jQV8YGtdDJkXkUalXZeYzKCx+vCZlDpb2ChS7/rNT9YY7poRA1NlJLUO0DH1wbAnFhx9KZMUx5g==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.26.0.tgz", + "integrity": "sha512-Jv7BX+kBB2wUIvO/NhuUjv+T3kAed2Tjr664fgQ2zKT6X69jKIkYuCCedrIHuOyaOQ+SBDuH9h51wYv/E97QgQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.26.0.tgz", + "integrity": "sha512-VJYcV6rvjnENRTroOi9tDcHWW6G0pmCoRETwatlbgfDzuCmkTOwVwQjeJCXOVMMLNPzNiXZzibsRCUt+Azq/jw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.26.0.tgz", + "integrity": "sha512-WPN9iZ3UjeDD2ckDzSs9tleibXv0cLj7j575NxuvjhwZTehYGNeYDSUTi+6DQUG6bKbhGg9Wcei5H0131vvJHg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.26.0.tgz", + "integrity": "sha512-Xhd6DCjaxCN4otQNvV6qra+XuoIjk6Vyjm87E5xn5Y/BMw7UGAG7LTkk3C2IEvxKrVZwJjalfxEqdHOgXQzVfw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.26.0.tgz", + "integrity": "sha512-rhAtp5J/YVDUCUIc5T7b0XY9dLeuI72JgOr53w0QQc0VA0uwbfTn7sx0LI9PDCE9uwmDH8H3snVRZRnAvlM8oA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.26.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.26.0.tgz", + "integrity": "sha512-reQ77NRYAOP7iPudsNbzLBuBTdL2aGxZzjccUFmE2lNdmwP23n9A/JhkuUhshVBs/6IozvahI+smG3Bnea0TCQ==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.26.0.tgz", + "integrity": "sha512-SIe68SDwx2fozt/XKG0FhCwzz/yRN6Bvo4D5TqvfDg6NK3PQb1DS4GN9PilmJqbY+kXryuiWEEJOWi7HpO8SuQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.26.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.26.0.tgz", + "integrity": "sha512-baXvv/rtOTVd2Axjb7Zbb41Y9Qmy3U2fP7EHqLuhViqGxVX8LwQtP0PHUXEZkPokbBpRez10+dmOlvvsYFKAZQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.26.0.tgz", + "integrity": "sha512-qenEQEgzE5FjQay/H6iKOnwIt6DPO27cS+v0mGhXmrL1MjrNER4X0ZkATJbVd0WA6ffsAGaP44NKYDworGeidw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.26.0.tgz", + "integrity": "sha512-a+N/C4wkQV+/8x4ShdoiC2JdTW3Tw84C5cAloYLFMeaWmRa2me9ACSI+zo0SO9bbH9RJwsoRp7eaxBbk27eF1Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.26.0.tgz", + "integrity": "sha512-s8oFpH+0xmhvY19f452/2dExO3p1tjxh761g6cg4irwEUNUEAJKF2VLcjiaeOhNJ+pmnQYxb+VSkwkXvO+7vHQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.26.0.tgz", + "integrity": "sha512-FA/d157aBxyvZFvsdc5eSu46tmHWXebAsqOQSvivOMyw+deBb00VlMsf+iD2J8+sekjbMYwx/hvbsu+xUoX43Q==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.26.0.tgz", + "integrity": "sha512-EM8woyHDNKLEQ+lWUEoDtA4KrwP6fei/mYX1NxseMzKHHo7LFecx7wk6sovAXZrUvdML/yFBihgiMiO5VIsfkg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.26.0.tgz", + "integrity": "sha512-MccGyj9HY4fkl04eIiFoTCkr8067Jku/VVdJNtRWW104Spx43C/7V2zpbxPvpcDhq3dW384fDxYXfpnb186xLg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.0" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.26.0.tgz", + "integrity": "sha512-oBcj6qaNrRHQ+N0+pDuOVAQa4Nx9r8Cm5ANvyM2lTpoy60sOLOizuVvcvw1andVxbSrsZ1N/Sk+RZWyv1uoWyQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.26.0.tgz", + "integrity": "sha512-ItLdFlcMsJz2vhbs1PcUfcN7nzVqGBOwPeCrrWxjrgscp+K3JoOGD+HhVVpBACOMwivUrlh8Ry5Ohvues2nOeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.26.0.tgz", + "integrity": "sha512-h8fYLikg4qN39IghQ1y9g+zzUsgxBpDi5YS3IZbWoxWYYx1YqLL8nAvOiPr7Us14aQ0TjA2/xY7zqmyf29rX1A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.26.0.tgz", + "integrity": "sha512-jUll3Pqhq7u1JKvO0B6USW/bmVmUsO6sRcxo/d5tXqLhS0tWAobOGoGU2IgwXnQDSjf+vF73RYD5tRGDLkRC9Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.26.0.tgz", + "integrity": "sha512-yZXdevp3/8omGbb40Z52VfvID+tsRNhPQ1GNUToD56XSr2BjdJyAzAb9rWGgDKgVMUPLgJ26yT0O278RFqOKhA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.26.0.tgz", + "integrity": "sha512-LlVkivH5cBwov/EMD8BL7ZRcU6YcadiSVIffLW1hyalw9YfhaFzoLxjtWhL7jiU/n2Kg+9dXSZxmV2hTeTwyrQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.26.0.tgz", + "integrity": "sha512-4wajuqnO2X0+LVvsBjW/xk3/tmdb16bNL939QhicAay4YYqXITeV2v3XJsryzmG4L5GkK1yLxvRGk4aLoxWrnA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.26.0.tgz", + "integrity": "sha512-q4RDeWwVrhOL0jJCGRgGxLSdjOYwzQ4h2InURZVhC66433ipcHd6f3bqSOhcXZ4r0sFmMNsuF7aZmUntjWLc7w==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.7", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.0", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.26.0.tgz", + "integrity": "sha512-NLPAG6tk4/AsfOsUNsbGqdgIHuGsD4A/hlYriozuo+LCAAduuluhzsL/MEHZXtFT4GXUOlCdaEqNCOrMuz/zaw==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.26.0", + "@tiptap/extension-floating-menu": "^3.26.0" + }, + "peerDependencies": { + "@tiptap/core": "3.26.0", + "@tiptap/pm": "3.26.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.26.0.tgz", + "integrity": "sha512-o34EtMfqtBaljdmeElZsRG/067oGx9Zcq+j2GWo71KlZe22ga/ALexeTf1c+ETsjCxSTKR6eyQ4RZvz/2JpYfg==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.26.0", + "@tiptap/extension-blockquote": "^3.26.0", + "@tiptap/extension-bold": "^3.26.0", + "@tiptap/extension-bullet-list": "^3.26.0", + "@tiptap/extension-code": "^3.26.0", + "@tiptap/extension-code-block": "^3.26.0", + "@tiptap/extension-document": "^3.26.0", + "@tiptap/extension-dropcursor": "^3.26.0", + "@tiptap/extension-gapcursor": "^3.26.0", + "@tiptap/extension-hard-break": "^3.26.0", + "@tiptap/extension-heading": "^3.26.0", + "@tiptap/extension-horizontal-rule": "^3.26.0", + "@tiptap/extension-italic": "^3.26.0", + "@tiptap/extension-link": "^3.26.0", + "@tiptap/extension-list": "^3.26.0", + "@tiptap/extension-list-item": "^3.26.0", + "@tiptap/extension-list-keymap": "^3.26.0", + "@tiptap/extension-ordered-list": "^3.26.0", + "@tiptap/extension-paragraph": "^3.26.0", + "@tiptap/extension-strike": "^3.26.0", + "@tiptap/extension-text": "^3.26.0", + "@tiptap/extension-underline": "^3.26.0", + "@tiptap/extensions": "^3.26.0", + "@tiptap/pm": "^3.26.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3112,7 +3573,6 @@ "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3122,7 +3582,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -3143,6 +3602,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", @@ -4605,7 +5070,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -5585,6 +6049,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -7387,6 +7860,12 @@ "uc.micro": "^2.0.0" } }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -8870,6 +9349,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/outdent": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", @@ -9201,6 +9686,145 @@ "react-is": "^16.13.1" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.7", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz", + "integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.8", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", + "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/protobufjs": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", @@ -9597,6 +10221,12 @@ "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10866,7 +11496,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "dev": true, "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -11074,6 +11703,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 340510c..3fd532e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ }, "dependencies": { "@heroicons/react": "^2.2.0", + "@tiptap/extension-link": "^3.26.0", + "@tiptap/pm": "^3.26.0", + "@tiptap/react": "^3.26.0", + "@tiptap/starter-kit": "^3.26.0", "clsx": "^2.1.1", "next": "^16.2.6", "next-auth": "5.0.0-beta.31", diff --git a/tests/stubs/server-only.ts b/tests/stubs/server-only.ts new file mode 100644 index 0000000..416997d --- /dev/null +++ b/tests/stubs/server-only.ts @@ -0,0 +1,3 @@ +// no-op stub for Vitest; the real package throws to prevent +// server-only modules from being bundled into the client. +export {} diff --git a/vitest.config.ts b/vitest.config.ts index 350a8c4..af42ddc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,18 @@ import { defineConfig } from 'vitest/config' +import path from 'node:path' export default defineConfig({ + resolve: { + alias: { + // Next.js's `server-only` package throws on import to guard + // against bundling server modules into the client. In Vitest + // (Node, no client bundle) that guard is a false positive, so + // alias the module to an empty stub. + 'server-only': path.resolve(__dirname, 'tests/stubs/server-only.ts'), + // Resolve the `@/` alias used by source for absolute imports. + '@': path.resolve(__dirname), + }, + }, test: { environment: 'node', globals: false,