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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion apps/web/src/components/docs/extensions/page-embed-node.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Unit tests for page-embed-node.tsx
*
* Focuses on pure exported helpers — isCircularEmbed.
* Focuses on pure exported helpers — isCircularEmbed and attribute parseHTML logic.
* The Tiptap extension itself and the React node view require a browser
* environment (jsdom + full editor setup) and are covered by smoke testing.
*/
Expand Down Expand Up @@ -74,3 +74,86 @@ describe('isCircularEmbed — indirect cycle (A→B→A via embedChain)', () =>
expect(isCircularEmbed('doc-a', 'doc-a', [])).toBe(true);
});
});

// ---------------------------------------------------------------------------
// addAttributes parseHTML — round-trip simulation
//
// The per-attribute parseHTML functions must correctly extract attrs from HTML
// produced by the turndown pageEmbed rule (data-* only, no docid/title/etc.).
// This is the regression that caused the "새로고침 시 picker 복귀" smoke failure.
// ---------------------------------------------------------------------------

/**
* Simulate what Tiptap's per-attribute parseHTML receives.
* vitest runs in Node (no DOM), so we mock only getAttribute.
*/
function makeEl(attrs: Record<string, string>): Element {
return {
getAttribute: (name: string) => attrs[name] ?? null,
} as unknown as Element;
}

describe('addAttributes parseHTML — markdown round-trip (data-* attrs only)', () => {
// These are the parseHTML functions from addAttributes — tested as standalone lambdas
// mirroring the exact attribute definitions in page-embed-node.tsx.
const parseDocId = (el: Element) => el.getAttribute('data-doc-id') || el.getAttribute('docid') || null;
const parseTitle = (el: Element) => el.getAttribute('data-title') || null;
const parseIcon = (el: Element) => el.getAttribute('data-icon') || null;
const parseSlug = (el: Element) => el.getAttribute('data-slug') || null;

it('reads docId from data-doc-id (markdown round-trip path)', () => {
const el = makeEl({ 'data-page-embed': '', 'data-doc-id': 'abc-123', 'data-title': 'My Doc', 'data-icon': '📄', 'data-slug': 'my-doc' });
expect(parseDocId(el)).toBe('abc-123');
});

it('reads docId from legacy docid attr (HTML format path)', () => {
const el = makeEl({ 'data-page-embed': '', docid: 'abc-123' });
expect(parseDocId(el)).toBe('abc-123');
});

it('returns null when neither data-doc-id nor docid present', () => {
const el = makeEl({ 'data-page-embed': '' });
expect(parseDocId(el)).toBeNull();
});

it('reads title from data-title', () => {
const el = makeEl({ 'data-title': 'My Document' });
expect(parseTitle(el)).toBe('My Document');
});

it('returns null for title when attr absent', () => {
expect(parseTitle(makeEl({}))).toBeNull();
});

it('reads icon from data-icon', () => {
const el = makeEl({ 'data-icon': '📄' });
expect(parseIcon(el)).toBe('📄');
});

it('returns null for icon when attr absent', () => {
expect(parseIcon(makeEl({}))).toBeNull();
});

it('reads slug from data-slug', () => {
const el = makeEl({ 'data-slug': 'my-doc' });
expect(parseSlug(el)).toBe('my-doc');
});

it('returns null for slug when attr absent', () => {
expect(parseSlug(makeEl({}))).toBeNull();
});

it('full markdown round-trip: all four attrs survive data-* only element', () => {
const el = makeEl({
'data-page-embed': '',
'data-doc-id': 'doc-xyz',
'data-title': 'API Reference',
'data-icon': '📚',
'data-slug': 'api-reference',
});
expect(parseDocId(el)).toBe('doc-xyz');
expect(parseTitle(el)).toBe('API Reference');
expect(parseIcon(el)).toBe('📚');
expect(parseSlug(el)).toBe('api-reference');
});
});
38 changes: 24 additions & 14 deletions apps/web/src/components/docs/extensions/page-embed-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,10 +273,27 @@ export const PageEmbedExtension = Node.create<PageEmbedOptions>({

addAttributes() {
return {
docId: { default: null },
title: { default: null },
icon: { default: null },
slug: { default: null },
docId: {
default: null,
// Read from data-doc-id (markdown round-trip) or legacy docid attr (HTML format)
parseHTML: (el) => el.getAttribute('data-doc-id') || el.getAttribute('docid') || null,
renderHTML: (attrs) => ({ 'data-doc-id': attrs.docId ?? '' }),
},
title: {
default: null,
parseHTML: (el) => el.getAttribute('data-title') || null,
renderHTML: (attrs) => ({ 'data-title': attrs.title ?? '' }),
},
icon: {
default: null,
parseHTML: (el) => el.getAttribute('data-icon') || null,
renderHTML: (attrs) => ({ 'data-icon': attrs.icon ?? '' }),
},
slug: {
default: null,
parseHTML: (el) => el.getAttribute('data-slug') || null,
renderHTML: (attrs) => ({ 'data-slug': attrs.slug ?? '' }),
},
};
},

Expand All @@ -285,16 +302,9 @@ export const PageEmbedExtension = Node.create<PageEmbedOptions>({
},

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'] ?? '',
}),
];
// Per-attribute renderHTML already maps docId→data-doc-id etc.
// HTMLAttributes here contains data-doc-id, data-title, data-icon, data-slug.
return ['div', mergeAttributes(HTMLAttributes, { 'data-page-embed': '' })];
},

addCommands() {
Expand Down
Loading