fix(words): normalize handles image blocks + 3 image tests (F3.11 partial)

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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 2a51adc00f
commit 86b47cef25

@ -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 = '<figure><img src="https://example.com/x.png" alt="alt"><figcaption>cap</figcaption></figure>';
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('<figure');
expect(out).toContain('<img src="https://example.com/x.png"');
expect(out).toContain('<figcaption>cap</figcaption>');
});
it('edits table rows and columns from the active cell', () => { it('edits table rows and columns from the active cell', () => {
let state = createWordsState( let state = createWordsState(
documentWithChildren([ documentWithChildren([

@ -30,6 +30,7 @@ import {
type WordsTableCell, type WordsTableCell,
type WordsTableRow type WordsTableRow
} from './document'; } from './document';
import { createImage, isWordsImageAlign } from '../extensions/image';
export type WordsNormalizeReason = export type WordsNormalizeReason =
| 'invalid-document' | 'invalid-document'
@ -143,6 +144,35 @@ function normalizeBlock(
compact: source.compact === true 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: default:
issues.push({ reason: 'invalid-block', path, value: source.type }); issues.push({ reason: 'invalid-block', path, value: source.type });
return undefined; return undefined;

@ -24,7 +24,9 @@ import { createImage } from './factories';
type ErasedFactory = (...args: ReadonlyArray<unknown>) => WordsExtensionNode; type ErasedFactory = (...args: ReadonlyArray<unknown>) => WordsExtensionNode;
const imageFactory = ((...args: ReadonlyArray<unknown>) => const imageFactory = ((...args: ReadonlyArray<unknown>) =>
createImage(...(args as Parameters<typeof createImage>))) as ErasedFactory; createImage(
...(args as Parameters<typeof createImage>)
)) as unknown as ErasedFactory;
export const imageExtension: WordsExtension = { export const imageExtension: WordsExtension = {
name: 'image', name: 'image',

Loading…
Cancel
Save

Powered by TurnKey Linux.