diff --git a/.changeset/big-cooks-admire.md b/.changeset/big-cooks-admire.md new file mode 100644 index 000000000..6f9cacde0 --- /dev/null +++ b/.changeset/big-cooks-admire.md @@ -0,0 +1,6 @@ +--- +"myst-transforms": patch +"myst-common": patch +--- + +Add warnings for missing alt-text and auto-generated alt-text. diff --git a/packages/myst-common/src/ruleids.ts b/packages/myst-common/src/ruleids.ts index d833a0dd0..8330994d3 100644 --- a/packages/myst-common/src/ruleids.ts +++ b/packages/myst-common/src/ruleids.ts @@ -48,6 +48,8 @@ export enum RuleId { imageFormatConverts = 'image-format-converts', imageCopied = 'image-copied', imageFormatOptimizes = 'image-format-optimizes', + imageHasAltText = 'image-has-alt-text', + imageAltTextGenerated = 'image-alt-text-generated', // Math rules mathLabelLifted = 'math-label-lifted', mathEquationEnvRemoved = 'math-equation-env-removed', diff --git a/packages/myst-transforms/src/basic.ts b/packages/myst-transforms/src/basic.ts index 2a0bd2c81..832c0d864 100644 --- a/packages/myst-transforms/src/basic.ts +++ b/packages/myst-transforms/src/basic.ts @@ -7,7 +7,7 @@ import { captionParagraphTransform } from './caption.js'; import { admonitionBlockquoteTransform, admonitionHeadersTransform } from './admonitions.js'; import { blockMetadataTransform, blockNestingTransform, blockToFigureTransform } from './blocks.js'; import { htmlIdsTransform } from './htmlIds.js'; -import { imageAltTextTransform } from './images.js'; +import { imageAltTextTransform, imageNoAltTextTransform } from './images.js'; import { mathLabelTransform, mathNestingTransform, subequationTransform } from './math.js'; import { blockquoteTransform } from './blockquote.js'; import { codeBlockToDirectiveTransform, inlineCodeFlattenTransform } from './code.js'; @@ -40,6 +40,7 @@ export function basicTransformations(tree: GenericParent, file: VFile, opts?: Re containerChildrenTransform(tree, file); htmlIdsTransform(tree); imageAltTextTransform(tree); + imageNoAltTextTransform(tree, file); blockquoteTransform(tree); removeUnicodeTransform(tree); headingDepthTransform(tree, file, opts); diff --git a/packages/myst-transforms/src/images.spec.ts b/packages/myst-transforms/src/images.spec.ts new file mode 100644 index 000000000..cce71f021 --- /dev/null +++ b/packages/myst-transforms/src/images.spec.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from 'vitest'; +import { VFile } from 'vfile'; + +import { imageNoAltTextTransform } from './images.js'; + +describe('Test imageNoAltTextTransform', () => { + test('image without alt text generates warning', () => { + const mdast = { + type: 'root', + children: [ + { + type: 'image', + url: 'https://images.com/cats', + align: 'center', + }, + ], + }; + const file = new VFile(); + imageNoAltTextTransform(mdast, file); + expect(mdast).toEqual({ + type: 'root', + children: [ + { + type: 'image', + url: 'https://images.com/cats', + align: 'center', + }, + ], + }); + // A warning was created + expect(file.messages.length).toBe(1); + expect(file.messages[0].message.includes('missing alt text')).toBe(true); + }); + test('image without alt text does not generate a warning', () => { + const mdast = { + type: 'root', + children: [ + { + type: 'image', + url: 'https://images.com/cats', + alt: 'I have alt text', + align: 'center', + }, + ], + }; + const file = new VFile(); + imageNoAltTextTransform(mdast, file); + expect(mdast).toEqual({ + type: 'root', + children: [ + { + type: 'image', + url: 'https://images.com/cats', + alt: 'I have alt text', + align: 'center', + }, + ], + }); + // A warning was created + expect(file.messages.length).toBe(0); + }); + test('image inside captioned figure warns about generated alt text', () => { + const mdast = { + type: 'container', + kind: 'figure', + children: [ + { + type: 'image', + url: 'https://images.com/cats', + alt: 'I don’t have alt text, but I do have a caption', + data: { + altTextIsAutoGenerated: true, + }, + }, + { + type: 'caption', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'I don’t have alt text, but I do have a caption', + }, + ], + }, + ], + }, + ], + enumerator: '1', + }; + const file = new VFile(); + imageNoAltTextTransform(mdast, file); + expect(mdast).toEqual({ + type: 'container', + kind: 'figure', + children: [ + { + type: 'image', + url: 'https://images.com/cats', + alt: 'I don’t have alt text, but I do have a caption', + data: { + altTextIsAutoGenerated: true, + }, + }, + { + type: 'caption', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'I don’t have alt text, but I do have a caption', + }, + ], + }, + ], + }, + ], + enumerator: '1', + }); + // A warning was created + expect(file.messages.length).toBe(1); + expect(file.messages[0].message.includes('was auto-generated')).toBe(true); + }); + test('image inside output does not generate warning', () => { + const mdast = { + type: 'root', + children: [ + { + type: 'output', + children: [ + { + type: 'image', + url: 'https://images.com/cats', + align: 'center', + }, + ], + }, + ], + }; + const file = new VFile(); + imageNoAltTextTransform(mdast, file); + expect(mdast).toEqual({ + type: 'root', + children: [ + { + type: 'output', + children: [ + { + type: 'image', + url: 'https://images.com/cats', + align: 'center', + }, + ], + }, + ], + }); + // A warning was created + expect(file.messages.length).toBe(0); + }); +}); diff --git a/packages/myst-transforms/src/images.ts b/packages/myst-transforms/src/images.ts index de0e3214d..47bed3a0b 100644 --- a/packages/myst-transforms/src/images.ts +++ b/packages/myst-transforms/src/images.ts @@ -1,8 +1,12 @@ import type { Plugin } from 'unified'; import type { Container, Paragraph, PhrasingContent, Image } from 'myst-spec'; +import type { VFile } from 'vfile'; import { select, selectAll } from 'unist-util-select'; +import { visit, SKIP } from 'unist-util-visit'; import type { GenericParent } from 'myst-common'; -import { toText } from 'myst-common'; +import { fileWarn, toText, RuleId } from 'myst-common'; + +const TRANSFORM_SOURCE = 'myst-transforms:images'; /** * Generate image alt text from figure caption @@ -29,6 +33,36 @@ export function imageAltTextTransform(tree: GenericParent) { }); } -export const imageAltTextPlugin: Plugin<[], GenericParent, GenericParent> = () => (tree) => { +export function imageNoAltTextTransform(tree: GenericParent, file: VFile) { + visit(tree, ['output', 'image'], (node) => { + switch (node.type) { + // Do not recurse into outputs, as they rarely have alt-texts and are usually embedded + // into a figure that does + case 'output': { + return SKIP; + } + case 'image': { + if (node.alt == null) { + fileWarn(file, `missing alt text for ${node.url}`, { + ruleId: RuleId.imageHasAltText, + node: node, + source: TRANSFORM_SOURCE, + }); + } + if (node.data?.altTextIsAutoGenerated) { + fileWarn(file, `alt text for ${node.url} was auto-generated`, { + ruleId: RuleId.imageAltTextGenerated, + node: node, + source: TRANSFORM_SOURCE, + note: 'You can remove this warning by writing your own alt text', + }); + } + } + } + }); +} + +export const imageAltTextPlugin: Plugin<[], GenericParent, GenericParent> = () => (tree, file) => { imageAltTextTransform(tree); + imageNoAltTextTransform(tree, file); };