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:
1 parent
5430f13877
commit
c0fd183c11
12 files changed
+913
-13
No files matched your search
@@ -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"b<c>&d">');
|
||||
expect(buildSeoMeta({ canonicalUrl: 'javascript:alert(1)' }, 'P')).not.toContain('canonical');
|
||||
expect(buildSeoMeta({ canonicalUrl: '/relative' }, 'P')).not.toContain('canonical');
|
||||
});
|
||||
});
|
||||
@@ -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 ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user