// No @wordpress/* type packages are installed (this plugin talks to the WP // globals directly, same convention as widget/ and frontend-overlay.ts), so // blocks and the wp global are read as loosely-typed data here. // eslint-disable-next-line @typescript-eslint/no-explicit-any type WpBlock = any; export type ContentIssueType = 'alt_text' | 'heading' | 'link_text' | 'video_caption'; export interface ContentIssue { id: string; type: ContentIssueType; message: string; fixable: boolean; clientId?: string; current?: string; context?: string; url?: string; /** core/image's attachment ID, when the image was picked from the media library (not an external URL). */ attachmentId?: number; } // Generic link text that doesn't tell a screen-reader user (or anyone // scanning link text out of context) where the link actually goes. const GENERIC_LINK_PHRASES = [ 'click here', 'here', 'read more', 'learn more', 'this link', 'more', 'this page', 'link', ]; function flattenBlocks(blocks: WpBlock[]): WpBlock[] { return blocks.flatMap((block) => [block, ...flattenBlocks(block.innerBlocks ?? [])]); } function checkAltText(blocks: WpBlock[]): ContentIssue[] { return blocks .filter((b) => b.name === 'core/image' && !(b.attributes?.alt ?? '').trim()) .map((b) => ({ id: `alt-${b.clientId}`, type: 'alt_text' as const, message: 'Image is missing alt text.', fixable: true, clientId: b.clientId, context: b.attributes?.caption || b.attributes?.url || '', attachmentId: typeof b.attributes?.id === 'number' ? b.attributes.id : undefined, })); } function checkHeadingHierarchy(blocks: WpBlock[]): ContentIssue[] { const issues: ContentIssue[] = []; let prevLevel = 0; blocks .filter((b) => b.name === 'core/heading') .forEach((b) => { const level: number = b.attributes?.level ?? 2; if (prevLevel > 0 && level > prevLevel + 1) { issues.push({ id: `heading-${b.clientId}`, type: 'heading', message: `Heading level jumps from H${prevLevel} to H${level} — screen reader users lose the outline structure.`, fixable: false, }); } prevLevel = level; }); return issues; } // core/paragraph and core/heading store their text as an HTML string in // attributes.content — that's the same string WordPress saves to the post, // so parsing it here (rather than requiring the separate wp-blocks package // just to re-derive rendered HTML) is both simpler and exact. function checkLinkText(blocks: WpBlock[]): ContentIssue[] { const issues: ContentIssue[] = []; blocks .filter((b) => ['core/paragraph', 'core/heading'].includes(b.name) && typeof b.attributes?.content === 'string') .forEach((b) => { const doc = new DOMParser().parseFromString(b.attributes.content, 'text/html'); const context = (doc.body.textContent ?? '').slice(0, 200); doc.querySelectorAll('a').forEach((a, i) => { const text = (a.textContent ?? '').trim(); if (GENERIC_LINK_PHRASES.includes(text.toLowerCase())) { issues.push({ id: `link-${b.clientId}-${i}`, type: 'link_text', message: `Generic link text "${text}" doesn't describe where it goes.`, fixable: true, clientId: b.clientId, current: text, context, url: a.getAttribute('href') ?? '', }); } }); }); return issues; } // Best-effort: WordPress core doesn't expose a reliable "has captions" flag // on the video block's own attributes in every version, so this can miss // captions added via a different route — flagged as manual-review, not // AI-fixable, same as the heading check. function checkVideoCaptions(blocks: WpBlock[]): ContentIssue[] { return blocks .filter((b) => b.name === 'core/video' && b.attributes?.src && !(b.attributes?.tracks?.length)) .map((b) => ({ id: `video-${b.clientId}`, type: 'video_caption' as const, message: 'Video has no caption/subtitle track — verify manually.', fixable: false, })); } export function runChecks(): ContentIssue[] { // eslint-disable-next-line @typescript-eslint/no-explicit-any const wp = (window as any).wp; const blocks: WpBlock[] = wp?.data?.select?.('core/block-editor')?.getBlocks?.() ?? []; const flat = flattenBlocks(blocks); return [ ...checkAltText(flat), ...checkHeadingHierarchy(flat), ...checkLinkText(flat), ...checkVideoCaptions(flat), ]; }