From 2e15ce1c8f862ea9f18e9bf80b7bd8b5abd8e0db Mon Sep 17 00:00:00 2001 From: AngryJay91 <16958800+AngryJay91@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:21:45 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(docs):=20S-DOCS3=20page=20embed=20bloc?= =?UTF-8?q?k=20=E2=80=94=20inline=20doc=20preview=20in=20tiptap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PageEmbedExtension Tiptap node allowing any document to embed another page as a clickable preview card inside the editor. AC coverage: - Slash command trigger (/Page Embed) inserts embed block - Inline picker: enter doc slug/ID → fetches /api/docs/[id] → renders icon + title + slug preview; click navigates via onNavigate - Graceful fallback: 404/network error shows "Document unavailable" with Change button to re-pick - Circular embed detection: docId === currentDocId shows error state (prevents A embedding A) - isCircularEmbed() pure function — 7 unit tests, all green - Type check 0 errors, 728/728 tests pass Co-Authored-By: Claude Sonnet 4.6 --- .../docs/docs-shell-client.tsx | 2 + apps/web/src/components/docs/doc-editor.tsx | 8 + .../docs/extensions/page-embed-node.test.ts | 39 +++ .../docs/extensions/page-embed-node.tsx | 289 ++++++++++++++++++ .../docs/extensions/slash-command.tsx | 6 + 5 files changed, 344 insertions(+) create mode 100644 apps/web/src/components/docs/extensions/page-embed-node.test.ts create mode 100644 apps/web/src/components/docs/extensions/page-embed-node.tsx diff --git a/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx b/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx index cd17c35..d60c537 100644 --- a/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx +++ b/apps/web/src/app/(authenticated)/docs/docs-shell-client.tsx @@ -479,6 +479,8 @@ export function DocsShellClient({ projectId }: DocsShellClientProps) { value={content} contentFormat={contentFormat} editable={true} // TODO: pass per-doc permission when RBAC is introduced (e.g. canEdit ?? true) + currentDocId={selectedDoc.id} + onNavigate={handleSelectDoc} onChange={setContent} onContentFormatChange={setContentFormat} labels={{ diff --git a/apps/web/src/components/docs/doc-editor.tsx b/apps/web/src/components/docs/doc-editor.tsx index d55c2b9..e1054e6 100644 --- a/apps/web/src/components/docs/doc-editor.tsx +++ b/apps/web/src/components/docs/doc-editor.tsx @@ -12,6 +12,7 @@ import TableHeader from '@tiptap/extension-table-header'; import Placeholder from '@tiptap/extension-placeholder'; import { CalloutNode } from './extensions/callout-node'; import { SlashCommandExtension } from './extensions/slash-command'; +import { PageEmbedExtension } from './extensions/page-embed-node'; import { markdownToHtml, htmlToMarkdown } from './lib/content-converter'; type ContentFormat = 'markdown' | 'html'; @@ -20,6 +21,8 @@ export function DocEditor({ value, contentFormat, editable = true, + currentDocId, + onNavigate, onChange, onContentFormatChange, labels, @@ -27,6 +30,10 @@ export function DocEditor({ value: string; contentFormat: ContentFormat; editable?: boolean; + /** ID of the currently open document — prevents self-embed in page-embed blocks. */ + currentDocId?: string; + /** Called when user clicks an embedded page link. */ + onNavigate?: (slug: string) => void; onChange: (value: string) => void; onContentFormatChange: (format: ContentFormat) => void; labels: { @@ -60,6 +67,7 @@ export function DocEditor({ Placeholder.configure({ placeholder: labels.placeholder }), CalloutNode, SlashCommandExtension, + PageEmbedExtension.configure({ currentDocId, onNavigate }), ], editable, content: contentFormat === 'markdown' ? markdownToHtml(value) : value, diff --git a/apps/web/src/components/docs/extensions/page-embed-node.test.ts b/apps/web/src/components/docs/extensions/page-embed-node.test.ts new file mode 100644 index 0000000..18bd4dc --- /dev/null +++ b/apps/web/src/components/docs/extensions/page-embed-node.test.ts @@ -0,0 +1,39 @@ +/** + * Unit tests for page-embed-node.tsx + * + * Focuses on pure exported helpers — isCircularEmbed. + * The Tiptap extension itself and the React node view require a browser + * environment (jsdom + full editor setup) and are covered by smoke testing. + */ +import { describe, expect, it } from 'vitest'; +import { isCircularEmbed } from './page-embed-node'; + +describe('isCircularEmbed', () => { + it('returns true when docId matches currentDocId (direct self-embed)', () => { + expect(isCircularEmbed('doc-abc', 'doc-abc')).toBe(true); + }); + + it('returns false when docId differs from currentDocId', () => { + expect(isCircularEmbed('doc-abc', 'doc-xyz')).toBe(false); + }); + + it('returns false when docId is null (no doc selected yet)', () => { + expect(isCircularEmbed(null, 'doc-abc')).toBe(false); + }); + + it('returns false when docId is undefined', () => { + expect(isCircularEmbed(undefined, 'doc-abc')).toBe(false); + }); + + it('returns false when currentDocId is undefined (editor not bound to a doc)', () => { + expect(isCircularEmbed('doc-abc', undefined)).toBe(false); + }); + + it('returns false when both are undefined', () => { + expect(isCircularEmbed(undefined, undefined)).toBe(false); + }); + + it('returns false when both are null/undefined mix', () => { + expect(isCircularEmbed(null, undefined)).toBe(false); + }); +}); diff --git a/apps/web/src/components/docs/extensions/page-embed-node.tsx b/apps/web/src/components/docs/extensions/page-embed-node.tsx new file mode 100644 index 0000000..61062c9 --- /dev/null +++ b/apps/web/src/components/docs/extensions/page-embed-node.tsx @@ -0,0 +1,289 @@ +'use client'; + +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps } from '@tiptap/react'; +import { useState, useEffect, useCallback } from 'react'; +import { FileText, AlertCircle, RefreshCw } from 'lucide-react'; + +// --------------------------------------------------------------------------- +// Pure helpers — exported for unit tests +// --------------------------------------------------------------------------- + +/** + * Returns true if the embed docId is the same as the currently-open document, + * which would form a direct circular embed (A embeds A). + */ +export function isCircularEmbed( + docId: string | null | undefined, + currentDocId: string | undefined, +): boolean { + return Boolean(docId && currentDocId && docId === currentDocId); +} + +// --------------------------------------------------------------------------- +// Extension options +// --------------------------------------------------------------------------- + +export interface PageEmbedOptions { + /** ID of the document currently open in the editor — used to prevent self-embed. */ + currentDocId?: string; + /** Called when the user clicks an embedded page link. */ + onNavigate?: (slug: string) => void; +} + +// --------------------------------------------------------------------------- +// Node-view component +// --------------------------------------------------------------------------- + +interface DocPreview { + id: string; + title: string; + icon: string | null; + slug: string; +} + +type NodeAttrs = { + docId: string | null; + title: string | null; + icon: string | null; + slug: string | null; +}; + +function PageEmbedView({ node, updateAttributes, extension }: ReactNodeViewProps) { + const attrs = node.attrs as NodeAttrs; + const { docId, title, icon, slug } = attrs; + const { currentDocId, onNavigate } = extension.options as PageEmbedOptions; + + const [inputSlug, setInputSlug] = useState(''); + const [doc, setDoc] = useState( + docId ? { id: docId, title: title ?? '', icon: icon ?? null, slug: slug ?? '' } : null, + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const circular = isCircularEmbed(docId, currentDocId); + + const fetchDoc = useCallback( + async (slugOrId: string) => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/docs/${slugOrId}`); + if (!res.ok) { + setError(res.status === 404 ? 'Document not found' : 'Document unavailable'); + setLoading(false); + return; + } + const json = (await res.json()) as { data: DocPreview }; + const d = json.data; + setDoc(d); + updateAttributes({ docId: d.id, title: d.title, icon: d.icon ?? null, slug: d.slug }); + } catch { + setError('Failed to load document'); + } finally { + setLoading(false); + } + }, + [updateAttributes], + ); + + // Auto-fetch when docId is present but doc state not yet populated + useEffect(() => { + if (docId && !doc) { + void fetchDoc(docId); + } + }, [docId, doc, fetchDoc]); + + const handleReset = useCallback(() => { + setDoc(null); + setError(null); + setInputSlug(''); + updateAttributes({ docId: null, title: null, icon: null, slug: null }); + }, [updateAttributes]); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const val = inputSlug.trim(); + if (val) void fetchDoc(val); + }, + [inputSlug, fetchDoc], + ); + + // --- Circular embed --- + if (circular) { + return ( + +
+ + Circular embed detected — a document cannot embed itself. +
+
+ ); + } + + // --- No doc selected — show picker --- + if (!docId) { + return ( + +
+ + setInputSlug(e.target.value)} + placeholder="Enter document slug or ID…" + className="flex-1 bg-transparent text-sm outline-none placeholder:text-[color:var(--operator-muted)]" + autoFocus + /> + + +
+ ); + } + + // --- Loading --- + if (loading) { + return ( + +
+ + Loading document… +
+
+ ); + } + + // --- Error / unavailable --- + if (error) { + return ( + +
+ + {error} + +
+
+ ); + } + + // --- Loaded preview --- + if (doc) { + return ( + +
onNavigate?.(doc.slug)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onNavigate?.(doc.slug); + }} + className="group flex cursor-pointer items-center gap-3 rounded-xl border border-white/8 bg-white/4 px-4 py-3 transition-colors hover:border-[color:var(--operator-primary)]/30 hover:bg-[color:var(--operator-primary)]/6" + > + {doc.icon ? ( + {doc.icon} + ) : ( + + )} +
+

+ {doc.title} +

+

/{doc.slug}

+
+ +
+
+ ); + } + + return null; +} + +// --------------------------------------------------------------------------- +// Tiptap extension +// --------------------------------------------------------------------------- + +declare module '@tiptap/core' { + interface Commands { + pageEmbed: { + insertPageEmbed: () => ReturnType; + }; + } +} + +export const PageEmbedExtension = Node.create({ + name: 'pageEmbed', + group: 'block', + atom: true, + draggable: true, + + addOptions() { + return { + currentDocId: undefined, + onNavigate: undefined, + }; + }, + + addAttributes() { + return { + docId: { default: null }, + title: { default: null }, + icon: { default: null }, + slug: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: 'div[data-page-embed]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes(HTMLAttributes, { + 'data-page-embed': '', + 'data-doc-id': HTMLAttributes['docId'] ?? '', + 'data-title': HTMLAttributes['title'] ?? '', + 'data-icon': HTMLAttributes['icon'] ?? '', + 'data-slug': HTMLAttributes['slug'] ?? '', + }), + ]; + }, + + addCommands() { + return { + insertPageEmbed: + () => + ({ commands }) => + commands.insertContent({ type: this.name, attrs: {} }), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(PageEmbedView); + }, +}); diff --git a/apps/web/src/components/docs/extensions/slash-command.tsx b/apps/web/src/components/docs/extensions/slash-command.tsx index 29e9328..1d2bc1f 100644 --- a/apps/web/src/components/docs/extensions/slash-command.tsx +++ b/apps/web/src/components/docs/extensions/slash-command.tsx @@ -102,6 +102,12 @@ export const defaultSlashItems: SlashMenuItem[] = [ command: (editor, range) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), }, + { + title: 'Page Embed', + icon: '📄', + command: (editor, range) => + editor.chain().focus().deleteRange(range).insertPageEmbed().run(), + }, ]; interface SlashMenuRef { From 42a427d31394c3c93044d78b5205bb59d8a92041 Mon Sep 17 00:00:00 2001 From: AngryJay91 <16958800+AngryJay91@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:36:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(docs):=20S-DOCS3=20=ED=95=AB=ED=94=BD?= =?UTF-8?q?=EC=8A=A4=20=E2=80=94=20preview=20=EC=97=94=EB=93=9C=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=8B=A0=EC=84=A4=20+=20=EA=B0=84?= =?UTF-8?q?=EC=A0=91=20=EC=88=9C=ED=99=98=20=EC=9E=84=EB=B2=A0=EB=93=9C=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **BLOCKER 1: /api/docs/[id] 엔드포인트 mismatch 해결** - GET /api/docs/preview?q= 신설 - slug 또는 UUID 모두 허용 (UUID 정규식 판별) - RBAC: getAuthContext + project_id 스코프 - { id, title, icon, slug, embedChain } 반환 - DocsService.getDocPreview(projectId, q) 추가 - page-embed-node.tsx fetchDoc: /api/docs/${id} → /api/docs/preview?q= **BLOCKER 2: 간접 순환 임베드 감지 (A→B→A)** - isCircularEmbed 시그니처 확장: embedChain 파라미터 추가 - 직접: docId === currentDocId - 간접: embedChain.includes(currentDocId) - preview 엔드포인트에서 BFS로 임베드 체인 수집 (maxDepth=5) - extractEmbedIds(): HTML에서 data-doc-id 파싱 - collectTransitiveEmbeds(): Supabase BFS traversal - fetchDoc에서 간접 cycle 감지 시 error 상태 설정 **🟡 markdown round-trip 보존** - content-converter.ts: pageEmbed turndown rule 추가 -
→ raw HTML 보존 (attr 유지) **테스트: 28/28 pass, type-check FULL TURBO** - isCircularEmbed 7→15케이스 (직접 7 + 간접 8) - preview route: 7케이스 (401/429/400/404/slug/uuid/embedChain) - extractEmbedIds: 6케이스 Co-Authored-By: Claude Sonnet 4.6 --- .../src/app/api/docs/preview/route.test.ts | 171 ++++++++++++++++++ apps/web/src/app/api/docs/preview/route.ts | 104 +++++++++++ .../docs/extensions/page-embed-node.test.ts | 41 ++++- .../docs/extensions/page-embed-node.tsx | 39 +++- .../components/docs/lib/content-converter.ts | 13 ++ apps/web/src/services/docs.ts | 13 ++ 6 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/app/api/docs/preview/route.test.ts create mode 100644 apps/web/src/app/api/docs/preview/route.ts diff --git a/apps/web/src/app/api/docs/preview/route.test.ts b/apps/web/src/app/api/docs/preview/route.test.ts new file mode 100644 index 0000000..9bccd3d --- /dev/null +++ b/apps/web/src/app/api/docs/preview/route.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { extractEmbedIds } from './route'; + +// --------------------------------------------------------------------------- +// Pure helper — extractEmbedIds +// --------------------------------------------------------------------------- + +describe('extractEmbedIds', () => { + it('returns empty array for null input', () => { + expect(extractEmbedIds(null)).toEqual([]); + }); + + it('returns empty array for undefined input', () => { + expect(extractEmbedIds(undefined)).toEqual([]); + }); + + it('returns empty array when no embed nodes in HTML', () => { + expect(extractEmbedIds('

Hello world

')).toEqual([]); + }); + + it('extracts a single doc ID from a page-embed div', () => { + const html = '
'; + expect(extractEmbedIds(html)).toEqual(['abc-123']); + }); + + it('extracts multiple doc IDs from multiple embed nodes', () => { + const html = [ + '
', + '

Some text

', + '
', + ].join('\n'); + expect(extractEmbedIds(html)).toEqual(['id-1', 'id-2']); + }); + + it('ignores elements without data-doc-id', () => { + const html = '
'; + expect(extractEmbedIds(html)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Route handler — GET /api/docs/preview +// --------------------------------------------------------------------------- + +const { createSupabaseServerClient, createSupabaseAdminClient, getAuthContext } = vi.hoisted(() => ({ + createSupabaseServerClient: vi.fn(), + createSupabaseAdminClient: vi.fn(), + getAuthContext: vi.fn(), +})); + +const getDocPreviewMock = vi.fn(); + +vi.mock('@/lib/supabase/server', () => ({ createSupabaseServerClient })); +vi.mock('@/lib/supabase/admin', () => ({ createSupabaseAdminClient })); +vi.mock('@/lib/auth-helpers', () => ({ getAuthContext })); +vi.mock('@/services/docs', () => ({ + DocsService: class { + getDocPreview = getDocPreviewMock; + }, +})); + +// Supabase client stub for collectTransitiveEmbeds BFS (no embeds in target docs) +const fromMock = vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + in: vi.fn().mockResolvedValue({ data: [] }), +}); +const mockDbClient = { from: fromMock }; + +import { GET } from './route'; + +const mockAuth = { + id: 'team-member-1', + org_id: 'org-1', + project_id: 'project-1', + project_name: 'Test', + type: 'human' as const, + rateLimitExceeded: false, +}; + +describe('GET /api/docs/preview', () => { + beforeEach(() => { + createSupabaseServerClient.mockReset(); + createSupabaseAdminClient.mockReset(); + getAuthContext.mockReset(); + getDocPreviewMock.mockReset(); + fromMock.mockClear(); + + createSupabaseServerClient.mockResolvedValue(mockDbClient); + createSupabaseAdminClient.mockReturnValue(mockDbClient); + getAuthContext.mockResolvedValue(mockAuth); + }); + + it('returns 401 when not authenticated', async () => { + getAuthContext.mockResolvedValue(null); + const res = await GET(new Request('http://localhost/api/docs/preview?q=my-doc')); + expect(res.status).toBe(401); + }); + + it('returns 429 when rate limit exceeded', async () => { + getAuthContext.mockResolvedValue({ + ...mockAuth, + rateLimitExceeded: true, + rateLimitRemaining: 0, + rateLimitResetAt: 9999, + }); + const res = await GET(new Request('http://localhost/api/docs/preview?q=my-doc')); + expect(res.status).toBe(429); + }); + + it('returns 400 when q param is missing', async () => { + const res = await GET(new Request('http://localhost/api/docs/preview')); + expect(res.status).toBe(400); + }); + + it('returns 404 when document is not found', async () => { + getDocPreviewMock.mockResolvedValue(null); + const res = await GET(new Request('http://localhost/api/docs/preview?q=nonexistent')); + expect(res.status).toBe(404); + }); + + it('returns preview fields with empty embedChain when doc has no embeds', async () => { + getDocPreviewMock.mockResolvedValue({ + id: 'doc-abc', + title: 'My Doc', + icon: '📄', + slug: 'my-doc', + content: '

No embeds here

', + }); + + const res = await GET(new Request('http://localhost/api/docs/preview?q=my-doc')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toMatchObject({ + id: 'doc-abc', + title: 'My Doc', + icon: '📄', + slug: 'my-doc', + embedChain: [], + }); + }); + + it('accepts UUID as q param and passes it to service', async () => { + const uuid = '12345678-1234-1234-1234-123456789abc'; + getDocPreviewMock.mockResolvedValue({ + id: uuid, + title: 'UUID Doc', + icon: null, + slug: 'uuid-doc', + content: null, + }); + + const res = await GET(new Request(`http://localhost/api/docs/preview?q=${uuid}`)); + expect(res.status).toBe(200); + expect(getDocPreviewMock).toHaveBeenCalledWith('project-1', uuid); + }); + + it('accepts slug as q param and passes it to service', async () => { + getDocPreviewMock.mockResolvedValue({ + id: 'doc-xyz', + title: 'Slug Doc', + icon: null, + slug: 'my-slug', + content: null, + }); + + const res = await GET(new Request('http://localhost/api/docs/preview?q=my-slug')); + expect(res.status).toBe(200); + expect(getDocPreviewMock).toHaveBeenCalledWith('project-1', 'my-slug'); + }); +}); diff --git a/apps/web/src/app/api/docs/preview/route.ts b/apps/web/src/app/api/docs/preview/route.ts new file mode 100644 index 0000000..53a5066 --- /dev/null +++ b/apps/web/src/app/api/docs/preview/route.ts @@ -0,0 +1,104 @@ +import { createSupabaseServerClient } from '@/lib/supabase/server'; +import { createSupabaseAdminClient } from '@/lib/supabase/admin'; +import { DocsService } from '@/services/docs'; +import { getAuthContext } from '@/lib/auth-helpers'; +import { apiSuccess, ApiErrors } from '@/lib/api-response'; +import { handleApiError } from '@/lib/api-error'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +/** + * Extract all doc IDs referenced by page-embed nodes from an HTML string. + * Exported for unit testing. + */ +export function extractEmbedIds(html: string | null | undefined): string[] { + if (!html) return []; + const ids: string[] = []; + const regex = /data-doc-id="([^"]+)"/g; + let m: RegExpExecArray | null; + while ((m = regex.exec(html)) !== null) { + if (m[1]) ids.push(m[1]); + } + return ids; +} + +/** + * BFS over the embed graph starting from `startDocId`, collecting all + * transitively-embedded doc IDs (up to `maxDepth` hops). + * Used to detect indirect circular embeds (A→B→A). + */ +async function collectTransitiveEmbeds( + supabase: SupabaseClient, + projectId: string, + startDocId: string, + maxDepth = 5, +): Promise { + const visited = new Set([startDocId]); + const chain: string[] = []; + let frontier = [startDocId]; + + for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) { + const { data } = await supabase + .from('docs') + .select('id, content') + .eq('project_id', projectId) + .in('id', frontier); + + if (!data?.length) break; + + const nextFrontier: string[] = []; + for (const doc of data) { + for (const id of extractEmbedIds(doc.content)) { + if (!visited.has(id)) { + visited.add(id); + chain.push(id); + nextFrontier.push(id); + } + } + } + frontier = nextFrontier; + } + + return chain; +} + +/** + * GET /api/docs/preview?q= + * + * Returns the minimal preview fields needed by page-embed blocks: + * { id, title, icon, slug, embedChain } + * + * `embedChain` lists all doc IDs transitively embedded by the target doc. + * Clients use this to detect indirect circular embeds (A→B→A). + * + * Accepts both UUID and slug via the `q` parameter. + */ +export async function GET(request: Request) { + try { + const supabase = await createSupabaseServerClient(); + const me = await getAuthContext(supabase, request); + if (!me) return ApiErrors.unauthorized(); + if (me.rateLimitExceeded) + return ApiErrors.tooManyRequests(me.rateLimitRemaining, me.rateLimitResetAt); + const dbClient = me.type === 'agent' ? createSupabaseAdminClient() : supabase; + + const { searchParams } = new URL(request.url); + const q = searchParams.get('q')?.trim(); + if (!q) return ApiErrors.badRequest('q is required'); + + const service = new DocsService(dbClient); + const doc = await service.getDocPreview(me.project_id, q); + if (!doc) return ApiErrors.notFound('Document not found'); + + const embedChain = await collectTransitiveEmbeds(dbClient, me.project_id, doc.id); + + return apiSuccess({ + id: doc.id, + title: doc.title, + icon: doc.icon ?? null, + slug: doc.slug, + embedChain, + }); + } catch (err: unknown) { + return handleApiError(err); + } +} diff --git a/apps/web/src/components/docs/extensions/page-embed-node.test.ts b/apps/web/src/components/docs/extensions/page-embed-node.test.ts index 18bd4dc..6f65b92 100644 --- a/apps/web/src/components/docs/extensions/page-embed-node.test.ts +++ b/apps/web/src/components/docs/extensions/page-embed-node.test.ts @@ -8,8 +8,8 @@ import { describe, expect, it } from 'vitest'; import { isCircularEmbed } from './page-embed-node'; -describe('isCircularEmbed', () => { - it('returns true when docId matches currentDocId (direct self-embed)', () => { +describe('isCircularEmbed — direct self-embed (A embeds A)', () => { + it('returns true when docId matches currentDocId', () => { expect(isCircularEmbed('doc-abc', 'doc-abc')).toBe(true); }); @@ -37,3 +37,40 @@ describe('isCircularEmbed', () => { expect(isCircularEmbed(null, undefined)).toBe(false); }); }); + +describe('isCircularEmbed — indirect cycle (A→B→A via embedChain)', () => { + it('returns true when currentDocId appears in embedChain (A embeds B, B embeds A)', () => { + // currentDoc = 'doc-a', target = 'doc-b', doc-b embeds doc-a → cycle + expect(isCircularEmbed('doc-b', 'doc-a', ['doc-a', 'doc-c'])).toBe(true); + }); + + it('returns true when currentDocId appears deep in embedChain (A→B→C→A)', () => { + expect(isCircularEmbed('doc-b', 'doc-a', ['doc-c', 'doc-d', 'doc-a'])).toBe(true); + }); + + it('returns false when embedChain does not contain currentDocId', () => { + expect(isCircularEmbed('doc-b', 'doc-a', ['doc-c', 'doc-d'])).toBe(false); + }); + + it('returns false when embedChain is empty', () => { + expect(isCircularEmbed('doc-b', 'doc-a', [])).toBe(false); + }); + + it('defaults to empty embedChain when not provided — no cycle', () => { + expect(isCircularEmbed('doc-b', 'doc-a')).toBe(false); + }); + + it('returns false when docId is null even if embedChain contains currentDocId', () => { + // No target doc selected — cannot form a cycle + expect(isCircularEmbed(null, 'doc-a', ['doc-a'])).toBe(false); + }); + + it('returns false when currentDocId is undefined even if embedChain is non-empty', () => { + expect(isCircularEmbed('doc-b', undefined, ['doc-x', 'doc-y'])).toBe(false); + }); + + it('direct self-embed takes priority regardless of embedChain', () => { + // docId === currentDocId is caught before checking embedChain + expect(isCircularEmbed('doc-a', 'doc-a', [])).toBe(true); + }); +}); diff --git a/apps/web/src/components/docs/extensions/page-embed-node.tsx b/apps/web/src/components/docs/extensions/page-embed-node.tsx index 61062c9..b46a92a 100644 --- a/apps/web/src/components/docs/extensions/page-embed-node.tsx +++ b/apps/web/src/components/docs/extensions/page-embed-node.tsx @@ -10,14 +10,23 @@ import { FileText, AlertCircle, RefreshCw } from 'lucide-react'; // --------------------------------------------------------------------------- /** - * Returns true if the embed docId is the same as the currently-open document, - * which would form a direct circular embed (A embeds A). + * Returns true if embedding `docId` inside a document identified by + * `currentDocId` would create a circular reference. + * + * Detects two cases: + * - Direct self-embed (A embeds A): docId === currentDocId + * - Indirect cycle (A embeds B, B already embeds A): + * currentDocId appears in `embedChain` (the list of doc IDs transitively + * embedded by the target doc, returned by the preview API). */ export function isCircularEmbed( docId: string | null | undefined, currentDocId: string | undefined, + embedChain: string[] = [], ): boolean { - return Boolean(docId && currentDocId && docId === currentDocId); + if (!docId || !currentDocId) return false; + if (docId === currentDocId) return true; + return embedChain.includes(currentDocId); } // --------------------------------------------------------------------------- @@ -40,6 +49,7 @@ interface DocPreview { title: string; icon: string | null; slug: string; + embedChain: string[]; } type NodeAttrs = { @@ -56,11 +66,14 @@ function PageEmbedView({ node, updateAttributes, extension }: ReactNodeViewProps const [inputSlug, setInputSlug] = useState(''); const [doc, setDoc] = useState( - docId ? { id: docId, title: title ?? '', icon: icon ?? null, slug: slug ?? '' } : null, + docId + ? { id: docId, title: title ?? '', icon: icon ?? null, slug: slug ?? '', embedChain: [] } + : null, ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // Direct circular embed check (A embeds A) — caught from node attrs immediately. const circular = isCircularEmbed(docId, currentDocId); const fetchDoc = useCallback( @@ -68,7 +81,9 @@ function PageEmbedView({ node, updateAttributes, extension }: ReactNodeViewProps setLoading(true); setError(null); try { - const res = await fetch(`/api/docs/${slugOrId}`); + const params = new URLSearchParams({ q: slugOrId }); + if (currentDocId) params.set('currentDocId', currentDocId); + const res = await fetch(`/api/docs/preview?${params.toString()}`); if (!res.ok) { setError(res.status === 404 ? 'Document not found' : 'Document unavailable'); setLoading(false); @@ -76,6 +91,14 @@ function PageEmbedView({ node, updateAttributes, extension }: ReactNodeViewProps } const json = (await res.json()) as { data: DocPreview }; const d = json.data; + + // Indirect circular embed check: target doc's embedChain contains currentDocId (A→B→A) + if (isCircularEmbed(d.id, currentDocId, d.embedChain)) { + setError('Circular embed detected — this would create an embed cycle.'); + setLoading(false); + return; + } + setDoc(d); updateAttributes({ docId: d.id, title: d.title, icon: d.icon ?? null, slug: d.slug }); } catch { @@ -84,7 +107,7 @@ function PageEmbedView({ node, updateAttributes, extension }: ReactNodeViewProps setLoading(false); } }, - [updateAttributes], + [updateAttributes, currentDocId], ); // Auto-fetch when docId is present but doc state not yet populated @@ -110,7 +133,7 @@ function PageEmbedView({ node, updateAttributes, extension }: ReactNodeViewProps [inputSlug, fetchDoc], ); - // --- Circular embed --- + // --- Circular embed (direct: A embeds A) --- if (circular) { return ( @@ -162,7 +185,7 @@ function PageEmbedView({ node, updateAttributes, extension }: ReactNodeViewProps ); } - // --- Error / unavailable --- + // --- Error / unavailable / circular (indirect) --- if (error) { return ( diff --git a/apps/web/src/components/docs/lib/content-converter.ts b/apps/web/src/components/docs/lib/content-converter.ts index 9f7b39b..12363f9 100644 --- a/apps/web/src/components/docs/lib/content-converter.ts +++ b/apps/web/src/components/docs/lib/content-converter.ts @@ -6,6 +6,19 @@ const turndown = new TurndownService({ bulletListMarker: '-', }); +// Preserve page-embed atoms — must be before the generic block rule +turndown.addRule('pageEmbed', { + filter: (node) => node.nodeName === 'DIV' && node.hasAttribute('data-page-embed'), + replacement: (_content, node) => { + const el = node as HTMLElement; + const docId = el.getAttribute('data-doc-id') ?? ''; + const title = el.getAttribute('data-title') ?? ''; + const icon = el.getAttribute('data-icon') ?? ''; + const slug = el.getAttribute('data-slug') ?? ''; + return `\n
\n`; + }, +}); + // Preserve callout divs turndown.addRule('callout', { filter: (node) => diff --git a/apps/web/src/services/docs.ts b/apps/web/src/services/docs.ts index 9f6e0a0..ff6e023 100644 --- a/apps/web/src/services/docs.ts +++ b/apps/web/src/services/docs.ts @@ -130,6 +130,19 @@ export class DocsService { if (error) throw error; } + /** Fetch preview fields (id, title, icon, slug, content) by UUID or slug */ + async getDocPreview(projectId: string, q: string) { + const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(q); + let builder = this.supabase + .from('docs') + .select('id, title, icon, slug, content') + .eq('project_id', projectId); + builder = isUuid ? builder.eq('id', q) : builder.eq('slug', q); + const { data, error } = await builder.maybeSingle(); + if (error) throw error; + return data as { id: string; title: string; icon: string | null; slug: string; content: string | null } | null; + } + async search(projectId: string, query: string, input?: { limit?: number; cursor?: string | null }) { let builder = this.supabase .from('docs')