From 7cb719ff0504af479ecbfd7be42eb5f66ea46b62 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sat, 5 Sep 2026 11:49:14 -0400 Subject: [PATCH 1/5] Added productImageAlt property to the product card node The product card image had no place to store alt text, so the rendered image could never be described to screen readers. This adds the property to the shared node definition so the renderer, parser and editor can build on it. --- koenig/kg-default-nodes/src/nodes/product/ProductNode.ts | 4 +++- koenig/kg-default-nodes/test/nodes/product.test.ts | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/koenig/kg-default-nodes/src/nodes/product/ProductNode.ts b/koenig/kg-default-nodes/src/nodes/product/ProductNode.ts index 618e7eb860f..c0f1c7b6e45 100644 --- a/koenig/kg-default-nodes/src/nodes/product/ProductNode.ts +++ b/koenig/kg-default-nodes/src/nodes/product/ProductNode.ts @@ -6,6 +6,7 @@ const productProperties = { productImageSrc: {default: '', urlType: 'url'}, productImageWidth: {default: null as number | null}, productImageHeight: {default: null as number | null}, + productImageAlt: {default: ''}, productTitle: {default: '', urlType: 'html', wordCount: true}, productDescription: {default: '', urlType: 'html', wordCount: true}, productRatingEnabled: {default: false}, @@ -25,7 +26,7 @@ export class ProductNode extends generateDecoratorNode({ /* override */ exportJSON() { // checks if src is a data string - const {productImageSrc, productImageWidth, productImageHeight, productTitle, productDescription, productRatingEnabled, productStarRating, productButtonEnabled, productButton, productUrl} = this; + const {productImageSrc, productImageWidth, productImageHeight, productImageAlt, productTitle, productDescription, productRatingEnabled, productStarRating, productButtonEnabled, productButton, productUrl} = this; const isBlob = productImageSrc && productImageSrc.startsWith('data:'); const dataset = { @@ -34,6 +35,7 @@ export class ProductNode extends generateDecoratorNode({ productImageSrc: isBlob ? '' : productImageSrc, productImageWidth, productImageHeight, + productImageAlt, productTitle, productDescription, productRatingEnabled, diff --git a/koenig/kg-default-nodes/test/nodes/product.test.ts b/koenig/kg-default-nodes/test/nodes/product.test.ts index f619a7edad5..43052ef36f4 100644 --- a/koenig/kg-default-nodes/test/nodes/product.test.ts +++ b/koenig/kg-default-nodes/test/nodes/product.test.ts @@ -30,6 +30,7 @@ describe('ProductNode', function () { expect(productNode.productImageSrc).toBe(data.productImageSrc); expect(productNode.productImageWidth!).toBe(data.productImageWidth); expect(productNode.productImageHeight!).toBe(data.productImageHeight); + expect(productNode.productImageAlt).toBe(data.productImageAlt); expect(productNode.productTitle).toBe(data.productTitle); expect(productNode.productDescription).toBe(data.productDescription); expect(productNode.productRatingEnabled).toBe(true); @@ -46,6 +47,7 @@ describe('ProductNode', function () { productImageSrc: '/content/images/2022/11/koenig-lexical.jpg', productImageWidth: 200, productImageHeight: 100, + productImageAlt: 'A camera on a wooden table', productTitle: 'This is a title', productDescription: 'This is a description', productRatingEnabled: true, @@ -86,6 +88,10 @@ describe('ProductNode', function () { productNode.productImageHeight = 700; expect(productNode.productImageHeight).toBe(700); + expect(productNode.productImageAlt).toBe(''); + productNode.productImageAlt = 'A camera on a wooden table'; + expect(productNode.productImageAlt).toBe('A camera on a wooden table'); + expect(productNode.productTitle).toBe(''); productNode.productTitle = 'Title'; expect(productNode.productTitle).toBe('Title'); @@ -184,6 +190,7 @@ describe('ProductNode', function () { productImageSrc: dataset.productImageSrc, productImageWidth: dataset.productImageWidth, productImageHeight: dataset.productImageHeight, + productImageAlt: dataset.productImageAlt, productTitle: dataset.productTitle, productDescription: dataset.productDescription, productRatingEnabled: dataset.productRatingEnabled, From 23c8551f220cf2380dedbef17384f470df6269a3 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sat, 5 Sep 2026 15:35:01 -0400 Subject: [PATCH 2/5] Added alt attribute to rendered product card images Product card images rendered with no alt attribute at all, which accessibility checkers flag and screen readers read as the file name. The attribute is now always present, empty when unset so the image is treated as decorative, and escaped so quotes in alt text cannot break the markup. The email golden post carries an alt value so the Core email snapshot proves the attribute renders end to end. --- .../__snapshots__/cards.test.js.snap | 4 +- .../fixtures/email-service/golden-post.json | 1 + .../src/nodes/product/product-renderer.ts | 6 ++- .../test/nodes/product.test.ts | 46 ++++++++++++++++++- .../test/renderers/product-renderer.test.ts | 23 ++++++++-- 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap b/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap index b8dd2f65667..fce07aae195 100644 --- a/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap +++ b/ghost/core/test/integration/services/email-service/__snapshots__/cards.test.js.snap @@ -8444,7 +8444,7 @@ Ghost: Independent technology for modern publishingBeautiful, modern publishing - + \\"Ghost @@ -10030,7 +10030,7 @@ Beautiful, modern publishing with newsletters and premium subscriptions built-in - + \\"Ghost diff --git a/ghost/core/test/utils/fixtures/email-service/golden-post.json b/ghost/core/test/utils/fixtures/email-service/golden-post.json index 6e5d08749b0..690c7a34f3c 100644 --- a/ghost/core/test/utils/fixtures/email-service/golden-post.json +++ b/ghost/core/test/utils/fixtures/email-service/golden-post.json @@ -239,6 +239,7 @@ "productImageSrc": "https://main.ghost.org/content/images/2023/12/ghost-logo.png", "productImageWidth": 800, "productImageHeight": 257, + "productImageAlt": "Ghost logo", "productTitle": "Make a blog!", "productDescription": "

with Ghost

", "productRatingEnabled": true, diff --git a/koenig/kg-default-nodes/src/nodes/product/product-renderer.ts b/koenig/kg-default-nodes/src/nodes/product/product-renderer.ts index d66050e9a0d..16834229366 100644 --- a/koenig/kg-default-nodes/src/nodes/product/product-renderer.ts +++ b/koenig/kg-default-nodes/src/nodes/product/product-renderer.ts @@ -4,6 +4,7 @@ import {renderEmptyContainer} from '../../utils/render-empty-container.js'; import {getFirstHtmlElement} from '../../utils/get-first-html-element.js'; import {getResizedImageDimensions} from '../../utils/get-resized-image-dimensions.js'; import {renderEmailButton} from '../../utils/render-helpers/email-button.js'; +import {escapeHtml} from '../../utils/escape-html.js'; interface ProductNodeData { productStarRating: number; @@ -19,6 +20,7 @@ interface ProductTemplateBaseData { productImageSrc: string; productImageWidth: number | null; productImageHeight: number | null; + productImageAlt: string; productTitle: string; productDescription: string; productRatingEnabled: boolean; @@ -86,7 +88,7 @@ export function cardTemplate({data}: {data: ProductTemplateData; feature?: Expor `
- ${data.productImageSrc ? `` : ''} + ${data.productImageSrc ? `${escapeHtml(data.productImageAlt)}` : ''}

${data.productTitle}

@@ -139,7 +141,7 @@ export function emailCardTemplate({data}: {data: ProductTemplateData; feature?: ${data.productImageSrc ? ` - + ${escapeHtml(data.productImageAlt)} ` : ''} diff --git a/koenig/kg-default-nodes/test/nodes/product.test.ts b/koenig/kg-default-nodes/test/nodes/product.test.ts index 43052ef36f4..6b7222d06ac 100644 --- a/koenig/kg-default-nodes/test/nodes/product.test.ts +++ b/koenig/kg-default-nodes/test/nodes/product.test.ts @@ -255,7 +255,7 @@ describe('ProductNode', function () { const element = result.element as HTMLElement; assertPrettifiesTo(element.outerHTML, ` -

Product title!

This product is ok
Click me
+

Product title!

This product is ok
Click me
`); })); @@ -280,9 +280,51 @@ describe('ProductNode', function () { const element = result.element as HTMLElement; assertPrettifiesTo(element.outerHTML, ` -

Product title!

This product is ok
Click me
+

Product title!

This product is ok
Click me
`); })); + + it('renders image alt text', editorTest(function () { + const productNode = $createProductNode({ + productImageSrc: 'https://example.com/images/ok.jpg', + productImageAlt: 'A camera on a wooden table', + productTitle: 'Product title!' + }); + const result = productNode.exportDOM(editor, exportOptions); + const element = result.element as HTMLElement; + const img = element.querySelector('img.kg-product-card-image')!; + + expect(img.getAttribute('alt')).toBe('A camera on a wooden table'); + })); + + it('escapes image alt text', editorTest(function () { + const productNode = $createProductNode({ + productImageSrc: 'https://example.com/images/ok.jpg', + productImageAlt: 'Fits "most" cameras & lenses', + productTitle: 'Product title!' + }); + const result = productNode.exportDOM(editor, exportOptions); + const element = result.element as HTMLElement; + const img = element.querySelector('img.kg-product-card-image')!; + + // unescaped quotes would truncate the attribute when the HTML is parsed + expect(img.getAttribute('alt')).toBe('Fits "most" cameras & lenses'); + expect(element.outerHTML).toContain('alt="Fits "most" cameras & lenses"'); + })); + + it('renders escaped image alt text in email', editorTest(function () { + const productNode = $createProductNode({ + productImageSrc: 'https://example.com/images/ok.jpg', + productImageAlt: 'Fits "most" cameras & lenses', + productTitle: 'Product title!' + }); + const result = productNode.exportDOM(editor, {...exportOptions, target: 'email'}); + const element = result.element as HTMLElement; + const img = element.querySelector('td.kg-product-image img')!; + + expect(img.getAttribute('alt')).toBe('Fits "most" cameras & lenses'); + expect(element.outerHTML).toContain('alt="Fits "most" cameras & lenses"'); + })); }); describe('importDOM', function () { diff --git a/koenig/kg-default-nodes/test/renderers/product-renderer.test.ts b/koenig/kg-default-nodes/test/renderers/product-renderer.test.ts index d1ff9331ad7..c25757f58e7 100644 --- a/koenig/kg-default-nodes/test/renderers/product-renderer.test.ts +++ b/koenig/kg-default-nodes/test/renderers/product-renderer.test.ts @@ -35,7 +35,7 @@ describe('renderers/product-renderer', function () { assertPrettifiesTo(result.html, html`
- +

This is a title

@@ -74,6 +74,14 @@ describe('renderers/product-renderer', function () { `); }); + it('renders escaped image alt text', function () { + const result = renderForWeb(getTestData({productImageAlt: 'Fits "most" cameras & lenses'})); + + assertPrettifiedIncludes(result.html, html` + Fits "most" cameras & lenses + `); + }); + it('renders nothing with a missing data', function () { const result = renderForWeb(getTestData({isEmpty: () => true})); assert.equal(result.html, ''); @@ -176,6 +184,7 @@ describe('renderers/product-renderer', function () { src="/content/images/2022/11/koenig-lexical.jpg" width="200" height="100" + alt="" border="0" /> @@ -222,6 +231,14 @@ describe('renderers/product-renderer', function () { `); }); + it('renders escaped image alt text', function () { + const result = renderForEmail(getTestData({productImageAlt: 'Fits "most" cameras & lenses'}), {feature: {}}); + + assertPrettifiedIncludes(result.html, html` + Fits "most" cameras & lenses + `); + }); + it('renders nothing with a missing data', function () { const result = renderForEmail(getTestData({isEmpty: () => true}), {feature: {}}); assert.equal(result.html, ''); @@ -266,7 +283,7 @@ describe('renderers/product-renderer', function () { - + @@ -322,7 +339,7 @@ describe('renderers/product-renderer', function () { - + From dd0c6a1a9fca719080c0cbdf6dae7a66a12a375b Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sat, 5 Sep 2026 15:35:22 -0400 Subject: [PATCH 3/5] Added product card image alt parsing from HTML HTML import and pasting product card markup dropped the image alt attribute because the parser never read it. Reading it keeps alt text intact for posts imported from HTML and for round trips through the rendered markup, which the html-to-lexical test now covers. The escaping fixtures also include angle brackets. --- .../src/nodes/product/product-parser.ts | 4 +++ .../test/nodes/product.test.ts | 29 +++++++++++++++---- .../test/html-to-lexical.test.ts | 29 +++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/koenig/kg-default-nodes/src/nodes/product/product-parser.ts b/koenig/kg-default-nodes/src/nodes/product/product-parser.ts index 03eaf2a3637..3fc7e9e5db8 100644 --- a/koenig/kg-default-nodes/src/nodes/product/product-parser.ts +++ b/koenig/kg-default-nodes/src/nodes/product/product-parser.ts @@ -22,6 +22,10 @@ export function parseProductNode(ProductNode: new (data: Record if (img && img.getAttribute('src')) { payload.productImageSrc = img.getAttribute('src'); + if (img.getAttribute('alt')) { + payload.productImageAlt = img.getAttribute('alt'); + } + if (img.getAttribute('width')) { const productImageWidth = Number(img.getAttribute('width')); diff --git a/koenig/kg-default-nodes/test/nodes/product.test.ts b/koenig/kg-default-nodes/test/nodes/product.test.ts index 6b7222d06ac..888f608db85 100644 --- a/koenig/kg-default-nodes/test/nodes/product.test.ts +++ b/koenig/kg-default-nodes/test/nodes/product.test.ts @@ -300,7 +300,7 @@ describe('ProductNode', function () { it('escapes image alt text', editorTest(function () { const productNode = $createProductNode({ productImageSrc: 'https://example.com/images/ok.jpg', - productImageAlt: 'Fits "most" cameras & lenses', + productImageAlt: 'Fits "most" cameras & ', productTitle: 'Product title!' }); const result = productNode.exportDOM(editor, exportOptions); @@ -308,22 +308,22 @@ describe('ProductNode', function () { const img = element.querySelector('img.kg-product-card-image')!; // unescaped quotes would truncate the attribute when the HTML is parsed - expect(img.getAttribute('alt')).toBe('Fits "most" cameras & lenses'); - expect(element.outerHTML).toContain('alt="Fits "most" cameras & lenses"'); + expect(img.getAttribute('alt')).toBe('Fits "most" cameras & '); + expect(element.outerHTML).toContain('alt="Fits "most" cameras & '); })); it('renders escaped image alt text in email', editorTest(function () { const productNode = $createProductNode({ productImageSrc: 'https://example.com/images/ok.jpg', - productImageAlt: 'Fits "most" cameras & lenses', + productImageAlt: 'Fits "most" cameras & ', productTitle: 'Product title!' }); const result = productNode.exportDOM(editor, {...exportOptions, target: 'email'}); const element = result.element as HTMLElement; const img = element.querySelector('td.kg-product-image img')!; - expect(img.getAttribute('alt')).toBe('Fits "most" cameras & lenses'); - expect(element.outerHTML).toContain('alt="Fits "most" cameras & lenses"'); + expect(img.getAttribute('alt')).toBe('Fits "most" cameras & '); + expect(element.outerHTML).toContain('alt="Fits "most" cameras & '); })); }); @@ -339,6 +339,7 @@ describe('ProductNode', function () { expect($isProductNode(productNode)).toBe(true); expect(productNode.productImageSrc).toBe('https://example.com/images/ok.jpg'); + expect(productNode.productImageAlt).toBe(''); expect(productNode.productTitle).toBe('Product title!'); expect(productNode.productDescription).toBe('This product is ok'); expect(productNode.productRatingEnabled).toBe(true); @@ -418,6 +419,22 @@ describe('ProductNode', function () { expect(productNode.productImageHeight).toBe(null); })); + it('parses product card image alt text', editorTest(function () { + const document = createDocument(html` +
Fits "most" cameras & <lenses>

Product title!

This product is ok
+ `); + const nodes = $generateNodesFromDOM(editor, document); + expect(nodes.length).toBe(1); + + const productNode = nodes[0] as ProductNode; + expect($isProductNode(productNode)).toBe(true); + + expect(productNode.productImageSrc).toBe('https://example.com/images/ok.jpg'); + expect(productNode.productImageAlt).toBe('Fits "most" cameras & '); + expect(productNode.productImageWidth!).toBe(200); + expect(productNode.productImageHeight!).toBe(100); + })); + it('handles arbitrary whitespace in button content', editorTest(function () { const document = createDocument(html`
diff --git a/koenig/kg-html-to-lexical/test/html-to-lexical.test.ts b/koenig/kg-html-to-lexical/test/html-to-lexical.test.ts index 896a8ecb0e2..54ff21f2998 100644 --- a/koenig/kg-html-to-lexical/test/html-to-lexical.test.ts +++ b/koenig/kg-html-to-lexical/test/html-to-lexical.test.ts @@ -1022,6 +1022,7 @@ describe('HTMLtoLexical', function () { src="__GHOST_URL__/content/images/2023/10/CleanShot-2023-07-19-at-12.57.37@2x.png" width="724" height="76" + alt="product image" class="kg-product-card-image" loading="lazy" /> @@ -1216,6 +1217,34 @@ describe('HTMLtoLexical', function () { 'signup', ]); }); + + it('keeps product card image alt text', function () { + const html = ` +
+
+ A camera on a wooden table +
+

Product title

+
+
Product description
+
+
+ `; + + const lexical = htmlToLexical(html, options); + const [product] = lexical.root.children; + + assert.equal(product.type, 'product'); + assert.equal(product.productImageSrc, 'https://example.com/images/camera.jpg'); + assert.equal(product.productImageAlt, 'A camera on a wooden table'); + }); }); describe('BRs', function () { From 964eebcb171274279e473cc8a05bb0d8901f800a Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sat, 5 Sep 2026 15:35:40 -0400 Subject: [PATCH 4/5] Added conversion and import coverage for product card image alt text Both mobiledoc/lexical directions copy card payloads generically and the content importer's media inliner only rewrites URL and HTML fields, so no code change is needed for the new productImageAlt property. These tests pin that behaviour so a per-card property map added later cannot drop it. --- .../content-import/import/media.test.ts | 2 + .../test/lexical-to-mobiledoc.test.ts | 61 +++++++++++++++++++ .../test/mobiledoc-to-lexical.test.ts | 61 +++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/ghost/core/test/unit/server/services/content-import/import/media.test.ts b/ghost/core/test/unit/server/services/content-import/import/media.test.ts index a5000c17440..d8cc5ac8d62 100644 --- a/ghost/core/test/unit/server/services/content-import/import/media.test.ts +++ b/ghost/core/test/unit/server/services/content-import/import/media.test.ts @@ -83,6 +83,7 @@ describe('PostMediaInliner', function () { { type: 'product', productImageSrc: 'https://assets.test/product.jpg', + productImageAlt: 'Product photo', productUrl: 'https://example.com/product', }, { @@ -156,6 +157,7 @@ describe('PostMediaInliner', function () { assert.equal(children[4].src, '__GHOST_URL__/content/files/guide.pdf'); assert.equal(children[5].productImageSrc, '__GHOST_URL__/content/files/product.jpg'); assert.equal(children[5].productUrl, 'https://example.com/product'); + assert.equal(children[5].productImageAlt, 'Product photo'); assert.equal(children[6].backgroundImageSrc, '__GHOST_URL__/content/files/header.jpg'); assert.equal(children[6].buttonUrl, 'https://example.com/header-button'); assert.equal(children[7].backgroundImageSrc, '__GHOST_URL__/content/files/signup.jpg'); diff --git a/koenig/kg-converters/test/lexical-to-mobiledoc.test.ts b/koenig/kg-converters/test/lexical-to-mobiledoc.test.ts index c77011bbf2f..c428e49502e 100644 --- a/koenig/kg-converters/test/lexical-to-mobiledoc.test.ts +++ b/koenig/kg-converters/test/lexical-to-mobiledoc.test.ts @@ -3066,6 +3066,67 @@ describe('lexicalToMobiledoc', function () { ); }); + it('keeps product card image alt text', function () { + const result = lexicalToMobiledoc( + JSON.stringify({ + root: { + children: [ + { + type: 'product', + version: 1, + productImageSrc: 'https://example.com/images/camera.jpg', + productImageWidth: 724, + productImageHeight: 76, + productImageAlt: 'A camera on a wooden table', + productTitle: 'Camera', + productDescription: 'A nice camera', + productRatingEnabled: false, + productStarRating: 5, + productButtonEnabled: false, + productButton: '', + productUrl: '', + }, + ], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }), + ); + + assert.equal( + result, + JSON.stringify({ + version: MOBILEDOC_VERSION, + ghostVersion: GHOST_VERSION, + atoms: [], + cards: [ + [ + 'product', + { + version: 1, + productImageSrc: 'https://example.com/images/camera.jpg', + productImageWidth: 724, + productImageHeight: 76, + productImageAlt: 'A camera on a wooden table', + productTitle: 'Camera', + productDescription: 'A nice camera', + productRatingEnabled: false, + productStarRating: 5, + productButtonEnabled: false, + productButton: '', + productUrl: '', + }, + ], + ], + markups: [], + sections: [[10, 0]], + }), + ); + }); + it('renames cards', function () { const result = lexicalToMobiledoc( JSON.stringify({ diff --git a/koenig/kg-converters/test/mobiledoc-to-lexical.test.ts b/koenig/kg-converters/test/mobiledoc-to-lexical.test.ts index 16003f24f08..fdd77b8e800 100644 --- a/koenig/kg-converters/test/mobiledoc-to-lexical.test.ts +++ b/koenig/kg-converters/test/mobiledoc-to-lexical.test.ts @@ -2438,6 +2438,67 @@ describe('mobiledocToLexical', function () { ); }); + it('keeps product card image alt text', function () { + const result = mobiledocToLexical( + JSON.stringify({ + version: MOBILEDOC_VERSION, + ghostVersion: GHOST_VERSION, + atoms: [], + cards: [ + [ + 'product', + { + version: 1, + productImageSrc: 'https://example.com/images/camera.jpg', + productImageWidth: 724, + productImageHeight: 76, + productImageAlt: 'A camera on a wooden table', + productTitle: 'Camera', + productDescription: 'A nice camera', + productRatingEnabled: false, + productStarRating: 5, + productButtonEnabled: false, + productButton: '', + productUrl: '', + }, + ], + ], + markups: [], + sections: [[10, 0]], + }), + ); + + assert.equal( + result, + JSON.stringify({ + root: { + children: [ + { + type: 'product', + version: 1, + productImageSrc: 'https://example.com/images/camera.jpg', + productImageWidth: 724, + productImageHeight: 76, + productImageAlt: 'A camera on a wooden table', + productTitle: 'Camera', + productDescription: 'A nice camera', + productRatingEnabled: false, + productStarRating: 5, + productButtonEnabled: false, + productButton: '', + productUrl: '', + }, + ], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }), + ); + }); + it('renames cards', function () { const result = mobiledocToLexical( JSON.stringify({ From 017ba1cc6f285e3131ba8b87cf1ad4a9e7e72419 Mon Sep 17 00:00:00 2001 From: Cathy Sarisky Date: Sat, 5 Sep 2026 15:35:57 -0400 Subject: [PATCH 5/5] Added alt text control to the product card editor Editors had no way to describe a product card image, and the preview hardcoded a placeholder alt. The node's alt text now flows down to the image component, which shows an Alt toggle at the bottom-right of the image while the card is in edit mode. Clicking it reveals an uncontrolled input under the image, so the caret does not jump while typing; the toggle stacks under the settings panel when they overlap, exposes its pressed state, and the input has an accessible name. Removing the image clears the alt so a replacement never inherits a stale description. The interaction mirrors the image card's existing Alt toggle. --- .changeset/product-card-image-alt-tests.md | 6 + .changeset/product-card-image-alt.md | 6 + .../ui/cards/ProductCard.stories.tsx | 3 +- .../src/components/ui/cards/ProductCard.tsx | 6 + .../ui/cards/ProductCard/ProductCardImage.tsx | 198 +++++++++++------- .../koenig-lexical/src/nodes/ProductNode.tsx | 1 + .../src/nodes/ProductNodeComponent.tsx | 11 + .../test/e2e/cards/product-card.test.ts | 112 +++++++++- .../test/unit/productCard.test.ts | 15 ++ 9 files changed, 282 insertions(+), 76 deletions(-) create mode 100644 .changeset/product-card-image-alt-tests.md create mode 100644 .changeset/product-card-image-alt.md diff --git a/.changeset/product-card-image-alt-tests.md b/.changeset/product-card-image-alt-tests.md new file mode 100644 index 00000000000..558e0787983 --- /dev/null +++ b/.changeset/product-card-image-alt-tests.md @@ -0,0 +1,6 @@ +--- +"@tryghost/kg-html-to-lexical": none +"@tryghost/kg-converters": none +--- + +Added test coverage for product card image alt text; no runtime change diff --git a/.changeset/product-card-image-alt.md b/.changeset/product-card-image-alt.md new file mode 100644 index 00000000000..47e2db869a9 --- /dev/null +++ b/.changeset/product-card-image-alt.md @@ -0,0 +1,6 @@ +--- +"@tryghost/kg-default-nodes": minor +"@tryghost/koenig-lexical": minor +--- + +Added alt text support for the product card image: a productImageAlt property on the product node, rendered as the image's alt attribute on web and email, parsed from HTML on import, and editable from an Alt toggle on the card in the editor diff --git a/koenig/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx b/koenig/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx index b732c448266..c2220e13a94 100644 --- a/koenig/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx +++ b/koenig/koenig-lexical/src/components/ui/cards/ProductCard.stories.tsx @@ -143,5 +143,6 @@ Populated.args = { buttonUrl: 'https://ghost.org/', rating: 4, imgMimeTypes: ['image/*'], - imgSrc: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg' + imgSrc: 'https://static.ghost.org/v5.0.0/images/publication-cover.jpg', + imgAlt: 'Fujifilm X100V camera on a wooden table' }; diff --git a/koenig/koenig-lexical/src/components/ui/cards/ProductCard.tsx b/koenig/koenig-lexical/src/components/ui/cards/ProductCard.tsx index 26439243e74..89266f83fed 100644 --- a/koenig/koenig-lexical/src/components/ui/cards/ProductCard.tsx +++ b/koenig/koenig-lexical/src/components/ui/cards/ProductCard.tsx @@ -9,6 +9,7 @@ import {isEditorEmpty} from '../../../utils/isEditorEmpty'; export function ProductCard({ isEditing, + imgAlt, imgSrc, isButtonEnabled, buttonText, @@ -20,6 +21,7 @@ export function ProductCard({ onButtonUrlChange, onRatingToggle, imgDragHandler, + onImgAltChange, onImgChange, imgMimeTypes, imgUploader, @@ -38,6 +40,7 @@ export function ProductCard({ <>
@@ -137,6 +141,7 @@ export function ProductCard({ ProductCard.propTypes = { isEditing: PropTypes.bool, + imgAlt: PropTypes.string, imgSrc: PropTypes.string, isButtonEnabled: PropTypes.bool, buttonText: PropTypes.string, @@ -147,6 +152,7 @@ ProductCard.propTypes = { onButtonTextChange: PropTypes.func, onButtonUrlChange: PropTypes.func, onRatingToggle: PropTypes.func, + onImgAltChange: PropTypes.func, onImgChange: PropTypes.func, onRemoveImage: PropTypes.func, imgDragHandler: PropTypes.object, diff --git a/koenig/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx b/koenig/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx index 94f18920d7e..9b5f48e4ea0 100644 --- a/koenig/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx +++ b/koenig/koenig-lexical/src/components/ui/cards/ProductCard/ProductCardImage.tsx @@ -4,13 +4,16 @@ import WandIcon from '../../../../assets/icons/kg-wand.svg?react'; import {IconButton} from '../../IconButton.jsx'; import {MediaPlaceholder} from '../../MediaPlaceholder.jsx'; import {ProgressBar} from '../../ProgressBar.jsx'; +import {TextInput} from '../../TextInput'; import {openFileSelection} from '../../../../utils/openFileSelection.js'; export function ProductCardImage({ imgSrc, + imgAlt = '', imgUploader = {}, imgDragHandler = {}, onImgChange, + onImgAltChange, imgMimeTypes, onRemoveImage, isPinturaEnabled, @@ -18,95 +21,148 @@ export function ProductCardImage({ isEditing }) { const fileInputRef = React.useRef(null); + const [isEditingAlt, setIsEditingAlt] = React.useState(false); + + // always close the alt input when leaving edit mode or when the image goes away + React.useEffect(() => { + if (!isEditing || !imgSrc) { + setIsEditingAlt(false); + } + }, [isEditing, imgSrc]); const onRemove = (e) => { e.stopPropagation(); // prevents card from losing selected state onRemoveImage(); }; + const toggleIsEditingAlt = (e) => { + e.stopPropagation(); // prevents card from losing editing state + setIsEditingAlt(!isEditingAlt); + }; + + const handleAltChange = (e) => { + onImgAltChange?.(e.target.value); + }; + const showPlaceholder = imgDragHandler.isDraggedOver || !imgSrc; + const showAltToggle = isEditing && !showPlaceholder; + const showAltInput = showAltToggle && isEditingAlt; const progressStyle = { width: `${imgUploader.progress?.toFixed(0)}%` }; return ( -
- { - showPlaceholder - ? ( - <> - openFileSelection({fileInputRef})} - icon='product' - isDraggedOver={imgDragHandler.isDraggedOver} - placeholderRef={imgDragHandler.setRef} - size='small' - /> - -
- +
+ { + showPlaceholder + ? ( + <> + openFileSelection({fileInputRef})} + icon='product' + isDraggedOver={imgDragHandler.isDraggedOver} + placeholderRef={imgDragHandler.setRef} + size='small' /> - - - ) - : ( - <> - Product thumbnail - { - isEditing && ( - <> -
- - ) - } +
+ +
+ + ) + : ( + <> + {imgAlt} - { - isEditing && ( - <> -
- -
- - ) - } + { + isEditing && ( + <> +
+ + ) + } + + { + isEditing && ( + <> +
+ +
+ + ) + } - { - isEditing && isPinturaEnabled && ( - <> -
- openImageEditor({ - image: imgSrc, - handleSave: (editedImage) => { - onImgChange({ - target: { - files: [editedImage] - } - }); - } - })} /> + { + isEditing && isPinturaEnabled && ( + <> +
+ openImageEditor({ + image: imgSrc, + handleSave: (editedImage) => { + onImgChange({ + target: { + files: [editedImage] + } + }); + } + })} /> +
+ + ) + } + + { + showAltToggle && ( + + ) + } + + { + imgUploader.isLoading && ( +
+
- - ) - } + ) + } + + ) + } +
- { - imgUploader.isLoading && ( -
- -
- ) - } - - ) + { + showAltInput && ( +
+ +
+ ) } -
+ ); } diff --git a/koenig/koenig-lexical/src/nodes/ProductNode.tsx b/koenig/koenig-lexical/src/nodes/ProductNode.tsx index c5fe35dd06c..823045a140f 100644 --- a/koenig/koenig-lexical/src/nodes/ProductNode.tsx +++ b/koenig/koenig-lexical/src/nodes/ProductNode.tsx @@ -91,6 +91,7 @@ export class ProductNode extends BaseProductNode { description={this.productDescription} descriptionEditor={this.__productDescriptionEditor} descriptionEditorInitialState={this.__productDescriptionEditorInitialState} + imgAlt={this.productImageAlt} imgHeight={this.productImageHeight} imgSrc={this.productImageSrc} imgWidth={this.productImageWidth} diff --git a/koenig/koenig-lexical/src/nodes/ProductNodeComponent.tsx b/koenig/koenig-lexical/src/nodes/ProductNodeComponent.tsx index a2a58aef904..d09116857b3 100644 --- a/koenig/koenig-lexical/src/nodes/ProductNodeComponent.tsx +++ b/koenig/koenig-lexical/src/nodes/ProductNodeComponent.tsx @@ -15,6 +15,7 @@ export function ProductNodeComponent({ nodeKey, buttonText, buttonUrl, + imgAlt, imgHeight, imgSrc, imgWidth, @@ -76,6 +77,14 @@ export function ProductNodeComponent({ editor.update(() => { const node = $getNodeByKey(nodeKey); node.productImageSrc = ''; + node.productImageAlt = ''; + }); + }; + + const handleImgAltChange = (value) => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + node.productImageAlt = value; }); }; @@ -132,6 +141,7 @@ export function ProductNodeComponent({ description={description} descriptionEditor={descriptionEditor} descriptionEditorInitialState={descriptionEditorInitialState} + imgAlt={imgAlt} imgDragHandler={imgDragHandler} imgHeight={imgHeight} imgMimeTypes={imgMimeTypes} @@ -150,6 +160,7 @@ export function ProductNodeComponent({ onButtonTextChange={handleButtonTextChange} onButtonToggle={handleButtonToggle} onButtonUrlChange={handleButtonUrlChange} + onImgAltChange={handleImgAltChange} onImgChange={handleImgChange} onRatingChange={handleRatingChange} onRatingToggle={handleRatingToggle} diff --git a/koenig/koenig-lexical/test/e2e/cards/product-card.test.ts b/koenig/koenig-lexical/test/e2e/cards/product-card.test.ts index 66aaf5de4a9..2fb5bbd01a9 100644 --- a/koenig/koenig-lexical/test/e2e/cards/product-card.test.ts +++ b/koenig/koenig-lexical/test/e2e/cards/product-card.test.ts @@ -1,5 +1,5 @@ import path from 'path'; -import {assertHTML, createDataTransfer, createSnippet, focusEditor, html, initialize, insertCard, isMac} from '../../utils/e2e'; +import {assertHTML, createDataTransfer, createSnippet, focusEditor, getEditorStateJSON, html, initialize, insertCard, isMac} from '../../utils/e2e'; import {expect, test} from '@playwright/test'; import {fileURLToPath} from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -54,7 +54,7 @@ test.describe('Product card', async () => {
Product thumbnail
@@ -192,6 +192,98 @@ test.describe('Product card', async () => { await expect(await page.getByTestId('media-placeholder-errors')).toBeVisible(); }); + test('can set image alt text', async function () { + await withWideViewport(page, async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await uploadImg(page); + await expect(page.getByTestId('product-card-image')).toBeVisible(); + + // toggle is visible while editing with an image, input stays hidden until toggled + const altToggle = page.getByTestId('product-image-alt-toggle'); + const altInput = page.getByTestId('product-image-alt-input'); + await expect(altToggle).toBeVisible(); + await expect(altInput).toBeHidden(); + + await altToggle.click(); + await expect(altInput).toBeVisible(); + await altInput.fill('A camera on a wooden table'); + + // preview image and serialized node both carry the alt text + await expect(page.getByTestId('product-card-image')).toHaveAttribute('alt', 'A camera on a wooden table'); + const editorState = JSON.parse(await getEditorStateJSON(page)); + expect(editorState.root.children[0].productImageAlt).toEqual('A camera on a wooden table'); + + // toggling again hides the input but keeps the value + await altToggle.click(); + await expect(altInput).toBeHidden(); + await expect(page.getByTestId('product-card-image')).toHaveAttribute('alt', 'A camera on a wooden table'); + }); + }); + + test('hides alt input when leaving edit mode', async function () { + await withWideViewport(page, async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await uploadImg(page); + await expect(page.getByTestId('product-card-image')).toBeVisible(); + + await page.getByTestId('product-image-alt-toggle').click(); + await page.getByTestId('product-image-alt-input').fill('A camera on a wooden table'); + + await page.keyboard.press('Escape'); + await expect(page.locator('[data-kg-card="product"]')).toHaveAttribute('data-kg-card-editing', 'false'); + await expect(page.getByTestId('product-image-alt-input')).toBeHidden(); + await expect(page.getByTestId('product-image-alt-toggle')).toBeHidden(); + + // re-entering edit mode shows the toggle but not the input + await page.getByTestId('edit-product-card').click(); + await expect(page.getByTestId('product-image-alt-toggle')).toBeVisible(); + await expect(page.getByTestId('product-image-alt-input')).toBeHidden(); + }); + }); + + test('clears alt text when image is removed', async function () { + await withWideViewport(page, async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await uploadImg(page); + await expect(page.getByTestId('product-card-image')).toBeVisible(); + + await page.getByTestId('product-image-alt-toggle').click(); + await page.getByTestId('product-image-alt-input').fill('A camera on a wooden table'); + + await page.getByTestId('replace-product-image').click(); + await expect(page.getByTestId('media-placeholder')).toBeVisible(); + await expect(page.getByTestId('product-image-alt-input')).toBeHidden(); + + const editorState = JSON.parse(await getEditorStateJSON(page)); + expect(editorState.root.children[0].productImageSrc).toEqual(''); + expect(editorState.root.children[0].productImageAlt).toEqual(''); + }); + }); + + test('keeps the caret position while editing alt text', async function () { + await withWideViewport(page, async () => { + await focusEditor(page); + await insertCard(page, {cardName: 'product'}); + await uploadImg(page); + await expect(page.getByTestId('product-card-image')).toBeVisible(); + + await page.getByTestId('product-image-alt-toggle').click(); + const altInput = page.getByTestId('product-image-alt-input'); + await altInput.fill('camera on a table'); + + // move the caret to the start and type: each character must land at the caret + await page.keyboard.press('Home'); + await page.keyboard.type('A '); + + await expect(altInput).toHaveValue('A camera on a table'); + const editorState = JSON.parse(await getEditorStateJSON(page)); + expect(editorState.root.children[0].productImageAlt).toEqual('A camera on a table'); + }); + }); + test('can show/hide rating starts if rating enabled/disabled', async function () { await focusEditor(page); await insertCard(page, {cardName: 'product'}); @@ -445,7 +537,7 @@ test.describe('Product card', async () => {
Product thumbnail
@@ -538,7 +630,7 @@ test.describe('Product card', async () => {
Product thumbnail
@@ -713,3 +805,15 @@ async function uploadImg(page, src = 'large-image.png') { const fileChooser = await fileChooserPromise; await fileChooser.setFiles([imagePath]); } + +// the settings panel overlaps the image's bottom-right corner at the default +// test viewport; the alt tests need it to sit beside the card instead +async function withWideViewport(page, fn) { + const originalViewport = page.viewportSize(); + await page.setViewportSize({width: 1400, height: 1000}); + try { + await fn(); + } finally { + await page.setViewportSize(originalViewport); + } +} diff --git a/koenig/koenig-lexical/test/unit/productCard.test.ts b/koenig/koenig-lexical/test/unit/productCard.test.ts index 02d18f9e201..6848bdc9a5e 100644 --- a/koenig/koenig-lexical/test/unit/productCard.test.ts +++ b/koenig/koenig-lexical/test/unit/productCard.test.ts @@ -72,4 +72,19 @@ describe('ProductNode', function () { expect(title).toEqual('Hello title land baaaabeee.'); })); }); + + describe('image alt text', function () { + it('exports productImageAlt', editorTest(function () { + dataset.productImageAlt = 'A camera on a wooden table'; + const productNode = $createProductNode(dataset); + const json = productNode.exportJSON(); + expect(json.productImageAlt).toEqual('A camera on a wooden table'); + })); + + it('defaults productImageAlt to an empty string', editorTest(function () { + const productNode = $createProductNode(dataset); + const json = productNode.exportJSON(); + expect(json.productImageAlt).toEqual(''); + })); + }); }); \ No newline at end of file