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/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/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..6f65b92
--- /dev/null
+++ b/apps/web/src/components/docs/extensions/page-embed-node.test.ts
@@ -0,0 +1,76 @@
+/**
+ * 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 β direct self-embed (A embeds A)', () => {
+ it('returns true when docId matches currentDocId', () => {
+ 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);
+ });
+});
+
+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
new file mode 100644
index 0000000..b46a92a
--- /dev/null
+++ b/apps/web/src/components/docs/extensions/page-embed-node.tsx
@@ -0,0 +1,312 @@
+'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 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 {
+ if (!docId || !currentDocId) return false;
+ if (docId === currentDocId) return true;
+ return embedChain.includes(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;
+ embedChain: 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 ?? '', 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(
+ async (slugOrId: string) => {
+ setLoading(true);
+ setError(null);
+ try {
+ 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);
+ return;
+ }
+ 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 {
+ setError('Failed to load document');
+ } finally {
+ setLoading(false);
+ }
+ },
+ [updateAttributes, currentDocId],
+ );
+
+ // 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 (direct: A embeds A) ---
+ if (circular) {
+ return (
+
+
+
+
Circular embed detected β a document cannot embed itself.
+
+
+ );
+ }
+
+ // --- No doc selected β show picker ---
+ if (!docId) {
+ return (
+
+
+
+ );
+ }
+
+ // --- Loading ---
+ if (loading) {
+ return (
+
+
+
+ Loading documentβ¦
+
+
+ );
+ }
+
+ // --- Error / unavailable / circular (indirect) ---
+ 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 {
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')