diff --git a/.changeset/floppy-bobcats-spend.md b/.changeset/floppy-bobcats-spend.md new file mode 100644 index 00000000000..f3efe4dcaf6 --- /dev/null +++ b/.changeset/floppy-bobcats-spend.md @@ -0,0 +1,5 @@ +--- +"@tryghost/kg-default-nodes": patch +--- + +Fixed header cards losing their layout when imported from HTML. diff --git a/koenig/kg-default-nodes/src/nodes/header/parsers/header-parser.ts b/koenig/kg-default-nodes/src/nodes/header/parsers/header-parser.ts index efbe55ef021..5ecb0a1c4af 100644 --- a/koenig/kg-default-nodes/src/nodes/header/parsers/header-parser.ts +++ b/koenig/kg-default-nodes/src/nodes/header/parsers/header-parser.ts @@ -1,5 +1,38 @@ import type {LexicalNode} from 'lexical'; +/** + * Reverses the class names applied by the v2 renderer's `getCardClasses`. + * + * The layout is encoded in the card's classes, so inferring it from the presence of an image alone collapses + * every regular/wide/full card into split when rendered HTML is imported again (e.g. via the Admin API with + * `?source=html`). + */ +function getHeaderV2Layout(div: HTMLElement): string { + // Split has to be checked first — split cards also carry `kg-width-full`. + if (div.classList.contains('kg-layout-split')) { + return 'split'; + } + if (div.classList.contains('kg-width-full')) { + return 'full'; + } + if (div.classList.contains('kg-width-wide')) { + return 'wide'; + } + if (div.classList.contains('kg-width-regular')) { + return 'regular'; + } + + // Hand-authored HTML may omit the width classes. The renderer only nests the image inside the content + // wrapper for split layouts, so the structure is the next best signal. + const image = div.querySelector('.kg-header-card-image'); + const content = div.querySelector('.kg-header-card-content'); + if (image && content?.contains(image)) { + return 'split'; + } + + return 'full'; +} + export function parseHeaderNode(HeaderNode: new (data: Record) => LexicalNode) { return { div: (nodeElem: HTMLElement) => { @@ -53,7 +86,7 @@ export function parseHeaderNode(HeaderNode: new (data: Record) const buttonElement = div.querySelector('.kg-header-card-button'); const alignment = div.classList.contains('kg-align-center') ? 'center' : ''; const backgroundImageSrc = div.querySelector('.kg-header-card-image')?.getAttribute('src'); - const layout = backgroundImageSrc ? 'split' : ''; + const layout = getHeaderV2Layout(div); const backgroundColor = div.classList.contains('kg-style-accent') ? 'accent' : div.getAttribute('data-background-color'); const buttonColor = buttonElement?.getAttribute('data-button-color') || ''; const textColor = headerElement?.getAttribute('data-text-color') || ''; diff --git a/koenig/kg-default-nodes/test/nodes/header.test.ts b/koenig/kg-default-nodes/test/nodes/header.test.ts index 8d6aca226af..07a8474126e 100644 --- a/koenig/kg-default-nodes/test/nodes/header.test.ts +++ b/koenig/kg-default-nodes/test/nodes/header.test.ts @@ -331,7 +331,7 @@ describe('HeaderNode', function () { expect(node.buttonColor).toBe('#abcdef'); expect(node.alignment).toBe('center'); expect(node.backgroundImageSrc).toBe('https://example.com/image.jpg'); - expect(node.layout).toBe('split'); + expect(node.layout).toBe('full'); expect(node.textColor).toBe('#abcdef'); expect(node.header).toBe('Header'); expect(node.subheader).toBe('Subheader'); @@ -341,6 +341,83 @@ describe('HeaderNode', function () { expect(node.buttonTextColor).toBe('#abcdef'); })); + it('parses the layout from the card classes', editorTest(function () { + const cards: [string, string][] = [ + ['kg-width-regular', 'regular'], + ['kg-width-wide', 'wide'], + ['kg-width-full kg-content-wide', 'full'], + ['kg-layout-split kg-width-full', 'split'] + ]; + + cards.forEach(([classes, expected]) => { + const htmlstring = ` +
+ +
+
+

Header

+
+
+
`; + const document = createDocument(htmlstring); + const nodes = $generateNodesFromDOM(editor, document) as HeaderNode[]; + expect(nodes.length).toBe(1); + expect(nodes[0].layout).toBe(expected); + }); + })); + + it('round-trips every layout with and without a background image', editorTest(function () { + const layouts = ['regular', 'wide', 'full', 'split']; + const backgroundImages = ['', 'https://example.com/image.jpg']; + + layouts.forEach((layout) => { + backgroundImages.forEach((backgroundImageSrc) => { + const headerNode = $createHeaderNode({...dataset, layout, backgroundImageSrc}); + const {element} = headerNode.exportDOM(editor, exportOptions); + const document = createDocument((element as HTMLElement).outerHTML); + const nodes = $generateNodesFromDOM(editor, document) as HeaderNode[]; + + expect(nodes.length).toBe(1); + expect(nodes[0].layout).toBe(layout); + expect(nodes[0].backgroundImageSrc).toBe(backgroundImageSrc); + }); + }); + })); + + it('does not force split layout when a full-width card has a background image', editorTest(function () { + const htmlstring = ` +
+ +
+
+

Title

+

Subtitle

+
+
+
`; + const document = createDocument(htmlstring); + const nodes = $generateNodesFromDOM(editor, document) as HeaderNode[]; + expect(nodes.length).toBe(1); + expect(nodes[0].layout).toBe('full'); + expect(nodes[0].backgroundImageSrc).toBe('https://example.com/image.jpg'); + })); + + it('falls back to the image position when the layout classes are missing', editorTest(function () { + const htmlstring = ` +
+
+ +
+

Header

+
+
+
`; + const document = createDocument(htmlstring); + const nodes = $generateNodesFromDOM(editor, document) as HeaderNode[]; + expect(nodes.length).toBe(1); + expect(nodes[0].layout).toBe('split'); + })); + it('does not parse a v1 header as v2', editorTest(function () { const htmlstring = `