feat(site-builder): pre-publish checklist and canonical link

Publish now runs client-side checks (image alt, meta description, title
length, H1, empty links, contrast, noindex landing, empty page) over the
current state and shows a grouped checklist with Fix links and
"Publish anyway". Adds ExportOptions.canonicalUrl -> <link rel="canonical">
after the favicon line in both Preview and export.

Refs #54
This commit is contained in:
shadowdao committed 2026-10-07 12:05:28 -07:00
1 parent 5430f13877
commit c0fd183c11
12 files changed
+913 -13

No files matched your search

+35
View File
@@ -5,6 +5,7 @@ import {
buildAnimationScript,
ExportOptions,
injectAttrs,
buildSeoMeta,
RESPONSIVE_CSS_MINIFIED,
VISIBILITY_CSS_MINIFIED,
} from './html-export';
@@ -738,3 +739,37 @@ describe('injectAttrs skips leading <style> blocks (W0-breakpoints)', () => {
expect(html).toContain('</style><a href="#"');
});
});
/** #54: `<link rel="canonical">`. The backend `generateCompiledHTML` must emit this exact line. */
describe('#54: canonical link in <head>', () => {
const STATE = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
});
const exp = (o: ExportOptions) => exportToHtml(STATE, o).html;
test('emitted only when provided, as the exact line', () => {
expect(exp({ title: 'P', canonicalUrl: 'https://example.com/about' })).toContain('<link rel="canonical" href="https://example.com/about">');
expect(exp({ title: 'P', canonicalUrl: 'https://example.com/' })).toContain('<link rel="canonical" href="https://example.com/">');
expect(exp({ title: 'P' })).not.toContain('canonical');
expect(exp({ title: 'P', canonicalUrl: '' })).not.toContain('canonical');
});
test('without the option the document is byte-identical to one built with canonicalUrl undefined', () => {
const base = { title: 'P', favicon: '/f.png', description: 'd' };
expect(exp(base)).toBe(exp({ ...base, canonicalUrl: undefined }));
expect(exp(base)).not.toContain('<link rel="canonical"');
});
test('placed directly after the favicon line, and after it only', () => {
const block = buildSeoMeta({ title: 'P', favicon: '/f.png', canonicalUrl: 'https://e.com/x' }, 'P');
expect(block.endsWith('<link rel="icon" href="/f.png">\n <link rel="canonical" href="https://e.com/x">')).toBe(true);
// No favicon: canonical is still last.
expect(buildSeoMeta({ canonicalUrl: 'https://e.com/x' }, 'P').endsWith('<link rel="canonical" href="https://e.com/x">')).toBe(true);
});
test('attribute value is escaped; non-http(s) URLs are dropped', () => {
expect(buildSeoMeta({ canonicalUrl: 'https://e.com/a"b<c>&d' }, 'P')).toContain('<link rel="canonical" href="https://e.com/a&quot;b&lt;c&gt;&amp;d">');
expect(buildSeoMeta({ canonicalUrl: 'javascript:alert(1)' }, 'P')).not.toContain('canonical');
expect(buildSeoMeta({ canonicalUrl: '/relative' }, 'P')).not.toContain('canonical');
});
});
+10 -1
View File
@@ -24,6 +24,9 @@ export interface ExportOptions {
noindex?: boolean;
/** SiteDesign.favicon -- site-wide, one per site. */
favicon?: string;
/** Absolute page URL for `<link rel="canonical">` (#54): `https://<fqdn>/` for the
* landing page, `https://<fqdn>/<slug>` otherwise (no `.html`). Omitted => no tag. */
canonicalUrl?: string;
/** Full site design tokens -- drives the :root{} CSS-variable block (contract §2).
* Falls back to DEFAULT_SITE_DESIGN when omitted so every export (including legacy
* callers that don't pass it) still emits the token/base/a11y CSS -- see §7. */
@@ -291,7 +294,7 @@ export function buildTokenCss(design: SiteDesign): string {
/**
* Builds the ordered SEO/meta tag block from `<meta name="robots">` (only
* when noindex) through the favicon `<link>` -- everything between `<title>`
* when noindex) through the favicon and canonical `<link>`s -- everything between `<title>`
* and the Google Fonts link in the contract §4 order. `title` must already
* be the FULLY RESOLVED title (i.e. `seo.metaTitle || page.name`) -- the
* og:title fallback chain in the contract (`ogTitle || metaTitle || TITLE`)
@@ -338,6 +341,12 @@ export function buildSeoMeta(options: ExportOptions, title: string): string {
lines.push(`<link rel="icon" href="${escapeAttr(favicon)}">`);
}
// Absolute http(s) only: anything else would be a broken canonical.
const canonical = options.canonicalUrl && /^https?:\/\//i.test(options.canonicalUrl) ? options.canonicalUrl : '';
if (canonical) {
lines.push(`<link rel="canonical" href="${escapeAttr(canonical)}">`);
}
return lines.join('\n ');
}
+168
View File
@@ -0,0 +1,168 @@
import { describe, test, expect } from 'vitest';
import { runPublishChecks, parseColor, contrastRatio, MAX_CHECKS, type CheckPage } from './publish-checks';
import { DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
import { PLACEHOLDER_SRC } from '../components/media/ImageBlock';
type N = { type: string; props?: any; children?: string[] };
/** Builds a SerializedNodes JSON: ROOT -> given nodes (parent links derived). */
function state(nodes: Record<string, N>, rootChildren: string[] = Object.keys(nodes).filter((k) => !Object.values(nodes).some((n) => n.children?.includes(k)))): string {
const out: Record<string, any> = {
ROOT: { type: { resolvedName: 'Container' }, props: {}, nodes: rootChildren, linkedNodes: {}, parent: null },
};
for (const [id, n] of Object.entries(nodes)) {
const parent = rootChildren.includes(id) ? 'ROOT' : Object.keys(nodes).find((k) => nodes[k].children?.includes(id)) ?? 'ROOT';
out[id] = { type: { resolvedName: n.type }, props: n.props ?? {}, nodes: n.children ?? [], linkedNodes: {}, parent };
}
return JSON.stringify(out);
}
const goodSeo = { metaDescription: 'A fine page.' };
const page = (over: Partial<CheckPage> & { nodes?: Record<string, N> }): CheckPage => ({
id: 'p1', name: 'Home', slug: 'index', seo: goodSeo,
craftState: over.nodes ? state(over.nodes) : over.craftState ?? null,
...(over.id ? { id: over.id } : {}), ...(over.name ? { name: over.name } : {}), ...(over.slug ? { slug: over.slug } : {}),
...('seo' in over ? { seo: over.seo } : {}),
});
const h1: N = { type: 'Heading', props: { level: 'h1', text: 'Hi', style: {} } };
const run = (pages: CheckPage[], zones?: any[]) => runPublishChecks({ pages, design: DEFAULT_SITE_DESIGN, zones });
const ids = (cs: { id: string }[]) => cs.map((c) => c.id);
describe('runPublishChecks', () => {
test('a clean page yields nothing', () => {
expect(run([page({ nodes: { h: h1 } })])).toEqual([]);
});
test('image alt: real src + empty alt warns and targets the node; alt, placeholder, empty src do not', () => {
const cs = run([page({ nodes: {
h: h1,
bad: { type: 'ImageBlock', props: { src: 'assets/a.png', alt: '' } },
ok: { type: 'ImageBlock', props: { src: 'assets/a.png', alt: 'A cat' } },
ph: { type: 'ImageBlock', props: { src: PLACEHOLDER_SRC, alt: '' } },
none: { type: 'ImageBlock', props: { src: '', alt: '' } },
} })]);
expect(cs).toHaveLength(1);
expect(cs[0]).toMatchObject({ id: 'alt:p1:bad', severity: 'warn', pageId: 'p1', nodeId: 'bad', fix: 'select' });
});
test('meta description missing warns with page-settings fix; present does not', () => {
const cs = run([page({ nodes: { h: h1 }, seo: {} })]);
expect(cs).toEqual([expect.objectContaining({ id: 'meta-desc:p1', fix: 'page-settings', pageId: 'p1' })]);
expect(run([page({ nodes: { h: h1 }, seo: { metaDescription: ' ' } })])).toHaveLength(1);
});
test('title over 60 chars (metaTitle wins over name); exactly 60 is fine', () => {
const long = 'x'.repeat(61);
expect(ids(run([page({ nodes: { h: h1 }, seo: { ...goodSeo, metaTitle: long } })]))).toEqual(['title-long:p1']);
expect(ids(run([page({ nodes: { h: h1 }, name: long })]))).toEqual(['title-long:p1']);
expect(run([page({ nodes: { h: h1 }, seo: { ...goodSeo, metaTitle: 'x'.repeat(60) } })])).toEqual([]);
});
test('H1: none warns, two warns, HeroSimple counts as one, h2 does not count', () => {
const h2: N = { type: 'Heading', props: { level: 'h2', text: 'x', style: {} } };
expect(ids(run([page({ nodes: { a: h2, b: h2 } })]))).toEqual(['h1-none:p1']);
expect(ids(run([page({ nodes: { a: h1, b: h1 } })]))).toEqual(['h1-multi:p1']);
expect(run([page({ nodes: { a: { type: 'HeroSimple', props: { heading: 'Welcome', ctas: [{ text: 'Go', href: '/x' }] } }, b: h2 } })])).toEqual([]);
expect(ids(run([page({ nodes: { a: { type: 'HeroSimple', props: { heading: 'Welcome', ctas: [] } }, b: h1 } })]))).toEqual(['h1-multi:p1']);
});
test('links: ButtonLink, Navbar/Menu items and hero CTAs with # or empty href warn; real hrefs and #anchor do not', () => {
const cs = run([page({ nodes: {
h: h1,
b1: { type: 'ButtonLink', props: { text: 'Buy', href: '#' } },
b2: { type: 'ButtonLink', props: { text: 'Ok', href: '/shop' } },
b3: { type: 'ButtonLink', props: { text: 'Anchor', href: '#about' } },
nav: { type: 'Navbar', props: { links: [{ text: 'Home', href: '/' }, { text: 'Blog', href: '' }] } },
menu: { type: 'Menu', props: { links: [{ text: 'X', href: '#' }] } },
hero: { type: 'HeroSimple', props: { heading: 'Two', ctas: [{ text: 'Start', href: '#' }] } },
} })]);
const linkIds = ids(cs).filter((i) => i.startsWith('link:'));
expect(linkIds.sort()).toEqual(['link:p1:b1:btn', 'link:p1:hero:c0', 'link:p1:menu:l0', 'link:p1:nav:l1'].sort());
});
test('header/footer zones: links are checked, H1 and SEO are not', () => {
const cs = run([page({ nodes: { h: h1 } })], [
{ id: '__header__', name: 'Header', craftState: state({ n: { type: 'Navbar', props: { links: [{ text: 'Go', href: '#' }] } } }) },
{ id: '__footer__', name: 'Footer', craftState: null },
]);
expect(cs).toEqual([expect.objectContaining({ id: 'link:__header__:n:l0', pageId: '__header__', nodeId: 'n' })]);
});
test('noindex on the landing page is info; on a later page is not reported', () => {
const cs = run([
page({ nodes: { h: h1 }, seo: { ...goodSeo, noindex: true } }),
page({ id: 'p2', name: 'About', slug: 'about', nodes: { h: h1 }, seo: { ...goodSeo, noindex: true } }),
]);
expect(cs).toEqual([expect.objectContaining({ id: 'noindex-landing:p1', severity: 'info' })]);
});
test('empty page: ROOT with no children, or no saved state', () => {
expect(ids(run([page({ craftState: state({}) }), page({ id: 'p2', name: 'B', slug: 'b', craftState: null })]))).toEqual(['empty:p1', 'empty:p2']);
});
test('unparseable state is treated as empty, never throws', () => {
expect(ids(run([page({ craftState: '{nope' })]))).toEqual(['empty:p1']);
});
describe('contrast', () => {
const sec = (bg: string, child: N, extra: any = {}): Record<string, N> => ({
s: { type: 'Section', props: { style: { backgroundColor: bg }, ...extra }, children: ['t'] },
t: child,
h: h1,
});
const text = (color: string, fontSize = '16px'): N => ({ type: 'TextBlock', props: { text: 'Hello', style: { color, fontSize } } });
test('maths: #ffffff on #ffffff is 1:1, #1f2937 on #ffffff passes, black on white is 21', () => {
expect(contrastRatio(parseColor('#fff')!, parseColor('#ffffff')!)).toBeCloseTo(1, 5);
expect(contrastRatio(parseColor('#000')!, parseColor('#fff')!)).toBeCloseTo(21, 5);
expect(contrastRatio(parseColor('#1f2937')!, parseColor('#ffffff')!)).toBeGreaterThan(4.5);
});
test('white-on-white warns; dark-on-white does not', () => {
expect(ids(run([page({ nodes: sec('#ffffff', text('#ffffff')) })]))).toEqual(['contrast:p1:t']);
expect(run([page({ nodes: sec('#ffffff', text('#1f2937')) })])).toEqual([]);
});
test('threshold depends on size: ~3.5:1 fails at 16px but passes at 24px', () => {
// #767676 on white is 4.54; #8a8a8a is ~3.4
expect(ids(run([page({ nodes: sec('#ffffff', text('#8a8a8a', '16px')) })]))).toEqual(['contrast:p1:t']);
expect(run([page({ nodes: sec('#ffffff', text('#8a8a8a', '24px')) })])).toEqual([]);
expect(ids(run([page({ nodes: sec('#ffffff', text('#cccccc', '40px')) })]))).toEqual(['contrast:p1:t']);
});
test('token colours resolve through the design (text token on bg token)', () => {
const design = { ...DEFAULT_SITE_DESIGN, textColor: '#ffffff', backgroundColor: '#ffffff' };
const cs = runPublishChecks({ pages: [page({ nodes: sec('var(--wsb-bg)', text('var(--wsb-text)')) })], design });
expect(ids(cs)).toEqual(['contrast:p1:t']);
const ok = runPublishChecks({ pages: [page({ nodes: sec('var(--wsb-bg)', text('var(--wsb-text)')) })], design: { ...design, textColor: '#111111' } });
expect(ok).toEqual([]);
});
test('transparent ancestors defer to the next one up', () => {
const nodes: Record<string, N> = {
outer: { type: 'Section', props: { style: { backgroundColor: '#000000' } }, children: ['inner'] },
inner: { type: 'Container', props: { style: { backgroundColor: 'transparent' } }, children: ['t'] },
t: text('#ffffff'),
h: h1,
};
expect(run([page({ nodes })])).toEqual([]);
nodes.outer.props.style.backgroundColor = '#ffffff';
expect(ids(run([page({ nodes })]))).toEqual(['contrast:p1:t']);
});
test('unknown / gradient / image backgrounds and unparseable colours are skipped', () => {
expect(run([page({ nodes: sec('linear-gradient(#fff,#fff)', text('#ffffff')) })])).toEqual([]);
expect(run([page({ nodes: sec('#ffffff', text('#ffffff'), { bgImage: 'a.png' }) })])).toEqual([]);
expect(run([page({ nodes: sec('#ffffff', text('inherit')) })])).toEqual([]);
expect(run([page({ nodes: { t: text('#ffffff'), h: h1 } })])).toEqual([]);
});
});
test('cap and dedupe: 80 bad images produce MAX_CHECKS items', () => {
const nodes: Record<string, N> = { h: h1 };
for (let i = 0; i < 80; i++) nodes['i' + i] = { type: 'ImageBlock', props: { src: 'assets/a.png', alt: '' } };
const cs = run([page({ nodes })]);
expect(cs).toHaveLength(MAX_CHECKS);
expect(new Set(ids(cs)).size).toBe(MAX_CHECKS);
});
});
+306
View File
@@ -0,0 +1,306 @@
import type { SiteDesign } from '../state/SiteDesignContext';
import type { PageSeo } from '../types';
import { resolveTokenValue } from './tokens';
import { PLACEHOLDER_SRC } from '../components/media/ImageBlock';
import { normalizeCtas } from '../components/sections/_cta-helpers';
/**
* Pre-publish checklist (#54). Pure: runs over serialized page states
* (`SerializedNodes` JSON) and the site design, never touches the editor.
* Every check is a warning/info -- v1 has no hard errors, so publishing is
* never blocked, only confirmed.
*/
export type CheckSeverity = 'warn' | 'info';
export type CheckFix = 'select' | 'page-settings' | 'site-design';
export interface Check {
/** Stable and unique per finding (page + node + kind). */
id: string;
severity: CheckSeverity;
message: string;
pageId?: string;
nodeId?: string;
fix?: CheckFix;
}
export interface CheckPage {
id: string;
name: string;
slug: string;
seo?: PageSeo;
craftState: string | null;
}
/** Header/footer zone: not a page (no H1 / SEO checks), but its links count. */
export interface CheckZone {
id: string;
name: string;
craftState: string | null;
}
export interface PublishChecksInput {
pages: CheckPage[];
design: SiteDesign;
zones?: CheckZone[];
}
export const MAX_CHECKS = 50;
const TITLE_MAX = 60;
interface SNode {
type?: { resolvedName?: string };
props?: Record<string, any>;
nodes?: string[];
linkedNodes?: Record<string, string>;
parent?: string | null;
}
type SNodes = Record<string, SNode>;
function parseState(raw: string | null): SNodes | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? (parsed as SNodes) : null;
} catch {
return null;
}
}
/** Node ids reachable from ROOT, in document order. */
function walk(nodes: SNodes): string[] {
const out: string[] = [];
const seen = new Set<string>();
const visit = (id: string) => {
if (seen.has(id) || !nodes[id]) return;
seen.add(id);
out.push(id);
const n = nodes[id];
for (const c of n.nodes ?? []) visit(c);
for (const c of Object.values(n.linkedNodes ?? {})) visit(c);
};
visit('ROOT');
return out;
}
// ---------- colour / contrast ----------
type RGB = [number, number, number];
/** Hex (#rgb, #rrggbb, #rrggbbaa) and rgb()/rgba() -> opaque RGB; null for anything else (gradients, names, alpha 0). */
export function parseColor(input: string): RGB | null {
const v = input.trim().toLowerCase();
let m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(v);
if (m) {
let h = m[1];
if (h.length <= 4) h = h.split('').map((c) => c + c).join('');
if (h.length === 8 && parseInt(h.slice(6), 16) === 0) return null;
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
m = /^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/.exec(v);
if (m) {
if (m[4] !== undefined) {
const a = m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
if (!(a > 0)) return null;
}
return [Math.min(255, +m[1]), Math.min(255, +m[2]), Math.min(255, +m[3])];
}
return null;
}
function luminance([r, g, b]: RGB): number {
const f = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}
/** WCAG 2.x contrast ratio between two opaque colours. */
export function contrastRatio(a: RGB, b: RGB): number {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
function pxSize(v: unknown): number | null {
if (typeof v === 'number') return v;
if (typeof v !== 'string') return null;
const m = /^([\d.]+)(px|rem|em)$/.exec(v.trim());
if (!m) return null;
return m[2] === 'px' ? parseFloat(m[1]) : parseFloat(m[1]) * 16;
}
/** Background colour a node itself paints, or null when unknown / not a flat colour. */
function ownBackground(props: Record<string, any>, design: SiteDesign): RGB | null | 'none' {
// Sections with a gradient/image background: contrast is not computable.
if (props.bgType && props.bgType !== 'color' && props.bgType !== 'solid') return null;
if (props.bgImage) return null;
const raw = props.style?.backgroundColor || props.bgColor;
if (typeof raw !== 'string' || !raw.trim()) return 'none';
const resolved = resolveTokenValue(raw, design);
if (/gradient|url\(/i.test(resolved)) return null;
const rgb = parseColor(resolved);
return rgb ?? 'none'; // transparent -> keep looking upward
}
function effectiveBackground(nodes: SNodes, id: string, design: SiteDesign): RGB | null {
let cur = nodes[id]?.parent;
while (cur && nodes[cur]) {
const bg = ownBackground(nodes[cur].props ?? {}, design);
if (bg === null) return null;
if (bg !== 'none') return bg;
cur = nodes[cur].parent;
}
return null;
}
// ---------- helpers ----------
const isEmptyHref = (h: unknown) => typeof h !== 'string' || h.trim() === '' || h.trim() === '#';
const label = (s: unknown, fallback: string) => {
const t = typeof s === 'string' ? s.trim().replace(/\s+/g, ' ') : '';
return t ? `"${t.length > 30 ? t.slice(0, 29) + '…' : t}"` : fallback;
};
const plain = (s: unknown) => (typeof s === 'string' ? s.replace(/<[^>]*>/g, '') : '');
function linkChecks(nodes: SNodes, ids: string[], pageId: string, where: string, out: Check[]) {
const add = (nodeId: string, suffix: string, text: unknown) =>
out.push({
id: `link:${pageId}:${nodeId}:${suffix}`,
severity: 'warn',
message: `${where}: ${label(text, 'a link')} has no destination (links to #)`,
pageId,
nodeId,
fix: 'select',
});
for (const id of ids) {
const n = nodes[id];
const name = n.type?.resolvedName;
const p = n.props ?? {};
if (name === 'ButtonLink') {
if (isEmptyHref(p.href)) add(id, 'btn', p.text);
} else if (name === 'Navbar' || name === 'Menu') {
(Array.isArray(p.links) ? p.links : []).forEach((l: any, i: number) => {
if (l && isEmptyHref(l.href)) add(id, `l${i}`, l.text);
});
} else if (name === 'HeroSimple' || name === 'CTASection' || name === 'CallToAction') {
normalizeCtas(p as any).forEach((c, i) => {
if (isEmptyHref(c.href)) add(id, `c${i}`, c.text);
});
}
}
}
function h1Count(nodes: SNodes, ids: string[]): number {
let n = 0;
for (const id of ids) {
const node = nodes[id];
const name = node.type?.resolvedName;
const p = node.props ?? {};
if (name === 'Heading' && p.level === 'h1') n++;
// HeroSimple exports its heading as an <h1>.
else if (name === 'HeroSimple' && typeof p.heading === 'string' && p.heading.trim()) n++;
}
return n;
}
/** Runs every pre-publish check; capped at {@link MAX_CHECKS}, deduped by id. */
export function runPublishChecks(input: PublishChecksInput): Check[] {
const { pages, design, zones = [] } = input;
const out: Check[] = [];
pages.forEach((page, index) => {
const nodes = parseState(page.craftState);
const ids = nodes ? walk(nodes) : [];
const where = page.name || page.slug;
const seo = page.seo ?? {};
if (!nodes || (nodes.ROOT?.nodes ?? []).length === 0) {
out.push({ id: `empty:${page.id}`, severity: 'warn', message: `${where} is empty`, pageId: page.id });
}
const title = seo.metaTitle || page.name || '';
if (title.length > TITLE_MAX) {
out.push({
id: `title-long:${page.id}`, severity: 'warn',
message: `${where}: page title is ${title.length} characters (search engines cut it off after ${TITLE_MAX})`,
pageId: page.id, fix: 'page-settings',
});
}
if (!seo.metaDescription || !seo.metaDescription.trim()) {
out.push({
id: `meta-desc:${page.id}`, severity: 'warn',
message: `${where} has no meta description`,
pageId: page.id, fix: 'page-settings',
});
}
if (index === 0 && seo.noindex) {
out.push({
id: `noindex-landing:${page.id}`, severity: 'info',
message: `${where} is the landing page and is set to noindex: search engines will skip it`,
pageId: page.id, fix: 'page-settings',
});
}
if (!nodes) return;
const h1s = h1Count(nodes, ids);
if (h1s === 0 && ids.length > 1) {
out.push({ id: `h1-none:${page.id}`, severity: 'warn', message: `No H1 heading on ${where}`, pageId: page.id });
} else if (h1s > 1) {
out.push({ id: `h1-multi:${page.id}`, severity: 'warn', message: `${where} has ${h1s} H1 headings (use one)`, pageId: page.id });
}
for (const id of ids) {
const n = nodes[id];
const name = n.type?.resolvedName;
const p = n.props ?? {};
if (name === 'ImageBlock') {
const src = typeof p.src === 'string' ? p.src.trim() : '';
if (src && src !== PLACEHOLDER_SRC && !(typeof p.alt === 'string' && p.alt.trim())) {
out.push({
id: `alt:${page.id}:${id}`, severity: 'warn',
message: `${where}: an image is missing alt text`,
pageId: page.id, nodeId: id, fix: 'select',
});
}
} else if (name === 'Heading' || name === 'TextBlock') {
const colorRaw = p.style?.color;
if (typeof colorRaw !== 'string' || !colorRaw) continue;
const fg = parseColor(resolveTokenValue(colorRaw, design));
const bg = fg ? effectiveBackground(nodes, id, design) : null;
if (!fg || !bg) continue;
const size = pxSize(p.style?.fontSize);
const large = size !== null && size >= 24;
const ratio = contrastRatio(fg, bg);
const txt = label(plain(p.text ?? p.html), '');
if (ratio < (large ? 3 : 4.5)) {
out.push({
id: `contrast:${page.id}:${id}`, severity: 'warn',
message: `${where}: low-contrast ${name === 'Heading' ? 'heading' : 'text'}${txt ? ' ' + txt : ''} (${ratio.toFixed(1)}:1, needs ${large ? 3 : 4.5}:1)`,
pageId: page.id, nodeId: id, fix: 'select',
});
}
}
}
linkChecks(nodes, ids, page.id, where, out);
});
for (const zone of zones) {
const nodes = parseState(zone.craftState);
if (!nodes) continue;
linkChecks(nodes, walk(nodes), zone.id, zone.name, out);
}
const seen = new Set<string>();
const unique: Check[] = [];
for (const c of out) {
if (seen.has(c.id)) continue;
seen.add(c.id);
unique.push(c);
if (unique.length >= MAX_CHECKS) break;
}
return unique;
}