From 16b2b44be5b85c64c852b1d441ab3645f0cb4d96 Mon Sep 17 00:00:00 2001 From: AngryJay91 <16958800+AngryJay91@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:16:55 +0900 Subject: [PATCH] =?UTF-8?q?fix(docs):=20S-DOCS3-FIX=20=E2=80=94=20page=20e?= =?UTF-8?q?mbed=20attrs=20=EC=9E=AC=EB=A1=9C=EB=94=A9=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9C=A0=EC=8B=A4=20=EC=88=98=EC=A0=95=20(parseHTML=20round-tr?= =?UTF-8?q?ip)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **RC:** addAttributes에 per-attribute parseHTML 미지정 → markdown 형식 doc에서 turndown의 pageEmbed rule이 data-doc-id 만 보존하고 legacy docid attr은 제거. 재로딩 시 Tiptap 기본 parseHTML이 docid를 못 찾아 null → picker 초기 상태 복귀. **수정:** - addAttributes에 per-attribute parseHTML + renderHTML 추가 - parseHTML: data-doc-id (markdown 경로) || docid (HTML 경로 하위호환) fallback - renderHTML: 각 attr을 data-* HTML 속성으로 일관 직렬화 - 노드 레벨 renderHTML 단순화: HTMLAttributes에 data-page-embed 만 merge (per-attr renderHTML이 이미 data-doc-id 등 세팅) **테스트 보강:** 15 → 25케이스 - parseHTML round-trip describe 신설 (10케이스) - markdown 경로 (data-doc-id only): docId/title/icon/slug 전원 복원 검증 - HTML 경로 (legacy docid attr): 하위호환 검증 - attr 누락 시 null 반환 검증 - full round-trip 통합 케이스 Co-Authored-By: Claude Sonnet 4.6 --- .../docs/extensions/page-embed-node.test.ts | 85 ++++++++++++++++++- .../docs/extensions/page-embed-node.tsx | 38 ++++++--- 2 files changed, 108 insertions(+), 15 deletions(-) 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 6f65b92c..83b57136 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 @@ -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. */ @@ -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): 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'); + }); +}); 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 b46a92a5..8a77a8e3 100644 --- a/apps/web/src/components/docs/extensions/page-embed-node.tsx +++ b/apps/web/src/components/docs/extensions/page-embed-node.tsx @@ -273,10 +273,27 @@ export const PageEmbedExtension = Node.create({ 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 ?? '' }), + }, }; }, @@ -285,16 +302,9 @@ export const PageEmbedExtension = Node.create({ }, 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() {