From 86b47cef2508b7532db31ead94056eca72d4a381 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 27 May 2026 20:22:25 +0200 Subject: [PATCH] fix(words): normalize handles image blocks + 3 image tests (F3.11 partial) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeBlock had no case for type='image' — image blocks coming from parseWordsMarkdown / parseWordsHtml were silently dropped at the default branch. Now: - normalize.ts case 'image': validates src (non-empty + sanitizeWordsUrl), passes through alt / caption / width (numeric) / height (numeric) / align (validated via isWordsImageAlign) / status ('pending' | 'error'). - normalize.ts imports createImage + isWordsImageAlign from the extension. Image-extension cast widened with `as unknown as ErasedFactory` because WordsImageBlock (no `children` field) doesn't satisfy the contract's node shape strictly; the double-cast is the same pattern table-extension already uses for typed factories. 3 new engine tests cover the F3 happy path: - parses a block-level markdown image (![alt](src "caption") on its own line yields a type:'image' block) - round-trips an image through markdown (serialize → parse → matches) - round-trips an image through HTML (figure>img+figcaption → ImageBlock → serialize back) 85/85 engine tests pass (was 82; +3 image tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/words/engine/engine.test.ts | 39 +++++++++++++++++++ .../soma/components/words/engine/normalize.ts | 30 ++++++++++++++ .../words/extensions/image/image-extension.ts | 4 +- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/uix/soma/components/words/engine/engine.test.ts b/src/uix/soma/components/words/engine/engine.test.ts index d5814631c..9ac7c3e47 100644 --- a/src/uix/soma/components/words/engine/engine.test.ts +++ b/src/uix/soma/components/words/engine/engine.test.ts @@ -892,6 +892,45 @@ describe('Words engine', () => { }); }); + it('parses a block-level markdown image', () => { + const md = '# Title\n\n![A cat](https://example.com/cat.jpg "Cute kitten")\n\nAfter.'; + const doc = parseWordsMarkdown(md); + expect(doc.children).toHaveLength(3); + expect(doc.children[0]).toMatchObject({ type: 'heading' }); + expect(doc.children[1]).toMatchObject({ + type: 'image', + src: 'https://example.com/cat.jpg', + alt: 'A cat', + caption: 'Cute kitten' + }); + expect(doc.children[2]).toMatchObject({ type: 'paragraph' }); + }); + + it('round-trips an image through markdown', () => { + const original = parseWordsMarkdown('![alt](https://example.com/x.png)'); + const back = parseWordsMarkdown(serializeWordsMarkdown(original)); + expect(back.children[0]).toMatchObject({ + type: 'image', + src: 'https://example.com/x.png', + alt: 'alt' + }); + }); + + it('round-trips an image through HTML', () => { + const html = '
alt
cap
'; + const doc = parseWordsHtml(html); + expect(doc.children[0]).toMatchObject({ + type: 'image', + src: 'https://example.com/x.png', + alt: 'alt', + caption: 'cap' + }); + const out = serializeWordsHtml(doc); + expect(out).toContain('cap'); + }); + it('edits table rows and columns from the active cell', () => { let state = createWordsState( documentWithChildren([ diff --git a/src/uix/soma/components/words/engine/normalize.ts b/src/uix/soma/components/words/engine/normalize.ts index 80ae05688..47fd1c10a 100644 --- a/src/uix/soma/components/words/engine/normalize.ts +++ b/src/uix/soma/components/words/engine/normalize.ts @@ -30,6 +30,7 @@ import { type WordsTableCell, type WordsTableRow } from './document'; +import { createImage, isWordsImageAlign } from '../extensions/image'; export type WordsNormalizeReason = | 'invalid-document' @@ -143,6 +144,35 @@ function normalizeBlock( compact: source.compact === true }); } + case 'image': { + const src = typeof source.src === 'string' ? source.src : ''; + if (!src) { + issues.push({ reason: 'invalid-block', path, value: 'image missing src' }); + return undefined; + } + const sanitized = sanitizeWordsUrl(src); + if (!sanitized) { + issues.push({ reason: 'unsafe-url', path, value: src }); + return undefined; + } + return createImage(sanitized, { + alt: typeof source.alt === 'string' ? source.alt : undefined, + caption: typeof source.caption === 'string' ? source.caption : undefined, + width: + typeof source.width === 'number' && Number.isFinite(source.width) + ? source.width + : undefined, + height: + typeof source.height === 'number' && Number.isFinite(source.height) + ? source.height + : undefined, + align: isWordsImageAlign(source.align) ? source.align : undefined, + status: + source.status === 'pending' || source.status === 'error' + ? source.status + : undefined + }); + } default: issues.push({ reason: 'invalid-block', path, value: source.type }); return undefined; diff --git a/src/uix/soma/components/words/extensions/image/image-extension.ts b/src/uix/soma/components/words/extensions/image/image-extension.ts index 7ddb77100..e3d960bed 100644 --- a/src/uix/soma/components/words/extensions/image/image-extension.ts +++ b/src/uix/soma/components/words/extensions/image/image-extension.ts @@ -24,7 +24,9 @@ import { createImage } from './factories'; type ErasedFactory = (...args: ReadonlyArray) => WordsExtensionNode; const imageFactory = ((...args: ReadonlyArray) => - createImage(...(args as Parameters))) as ErasedFactory; + createImage( + ...(args as Parameters) + )) as unknown as ErasedFactory; export const imageExtension: WordsExtension = { name: 'image',