1#!/usr/bin/env node2// Check a page description BEFORE anything is written to Harmony, and settle its layout.3//4// node scripts/copy/check-description.mjs <page.desc.json> <outDir>5// --capabilities capabilities.json what the live editor accepts (caps.js snapshot)6// --fonts font-ledger.json Harmony family -> source family, for the local render7// [--source <url>] the (local) source page, for fonts and the pixel diff8// [--plate plate.png] the decorative plate, drawn behind the components9// [--heights editor-heights.json] text heights measured by the editor (key -> px), overriding the local render's10// [--write] store the settled heights and mobile layout back11//12// 1. Compliance: every component type, style key, link type and value shape is one the13// snapshot lists (compliance guard 1); line heights and inline sizes are not px; rich
38// the cutover hand-off must match the links as they are now (build-description writes it)
39if (JSON.stringify(desc.openItems || []) !== JSON.stringify(openItems(desc))) errors.push({ key: 'openItems', error: 'desc.openItems is missing or stale against the component links: re-run build-description' });
40// pages the copy lacks must stay in the hand-off with their source URL, whatever stand-in page the link points at
41if (JSON.stringify(desc.pendingItems || []) !== JSON.stringify(pendingItems(desc))) errors.push({ key: 'pendingItems', error: 'desc.pendingItems is missing or stale against the component links: re-run build-description' });
42for (const p of pendingItems(desc)) warnings.push({ key: 'page-not-built', warning: `${p.url} is not built (links go to ${p.standInFor}): ${p.reason}` });
43// A copy must never send a visitor back to the source site. A link to a store category, a product or any page the
44// copy replaces has to be mapped (build-description --linkmap) to the new site's own page. Only links to pages that
45// are deliberately not built yet may stay, and then the run says so with --allow-old-links "<reason>".
46{
47 const old = openItems(desc);
48 if (old.length && !opt('allow-old-links')) errors.push({ key: 'old-site-links', error: `${old.length} link(s) still point at the source site (${old.slice(0, 3).map((o) => o.url).join(', ')}${old.length > 3 ? ', ...' : ''}): map them to the new site's store category/product/pages with build-description --linkmap, or, for pages that are not built yet, pass --allow-old-links "<reason>" (it is recorded in the report)` });
49 else if (old.length) warnings.push({ key: 'old-site-links', warning: `${old.length} link(s) left pointing at the source site on purpose: ${opt('allow-old-links')}` });
50}
51// ---- 0. the editor code this pipeline sends uses only methods the editor has (lint-api.mjs) ----
54 const here = path.dirname(new URL(import.meta.url).pathname);
55 for (const f of ['deploy-description.mjs', 'export-page.mjs', 'snapshot-capabilities.mjs', 'mobile-stage.mjs']) {
56 for (const p of lintApi(fs.readFileSync(path.join(here, f), 'utf8'), caps, f).problems) (p.error ? errors : warnings).push({ key: 'editor-code', [p.error ? 'error' : 'warning']: `${p.at}: ${p.error || p.warning}` });
57 }
58}
59
60// ---- 1. compliance against the editor's own snapshot -------------------------------------
61for (const c of desc.components) {
62 const t = caps.types[c.type];
63 if (!caps.addable.includes(c.type) || !t) { errors.push({ key: c.key, error: `type ${c.type} is not addable in this editor` }); continue; }
64 for (const [el, props] of Object.entries(c.styles || {})) {
65 const allowed = t.styles[el];
66 if (!allowed) { errors.push({ key: c.key, error: `element '${el}' takes no styles on ${c.type}` }); continue; }
67 for (const [k, v] of Object.entries(props)) {
68 if (!allowed.includes(k)) errors.push({ key: c.key, error: `style '${k}' is not writable on ${c.type} ${el}` });
69 if (k === 'lineHeight' && /px$/.test(v)) errors.push({ key: c.key, error: `lineHeight ${v} is px: it would not scale (use em)` });
70 }
71 }
72 if (c.link && t.links && !t.links.includes(c.link.type)) errors.push({ key: c.key, error: `link type ${c.link.type} not accepted by ${c.type}` });
73 if (c.html !== undefined) {
74 if (!/^<(p|h[1-6])>[\s\S]*<\/\1>$/.test(c.html) || /<\/(p|h[1-6])>[\s\S]*<(p|h[1-6])>/.test(c.html)) errors.push({ key: c.key, error: 'rich text needs exactly one <p> or <h1-6> root' });
75 if (/font-size:\s*[\d.]+px/.test(c.html)) errors.push({ key: c.key, error: 'inline font-size in px does not scale (use em)' });
76 }
77 if (c.kind === 'image') {
78 if (!t.content.some((f) => f.key === 'displayMode' && (!f.options || f.options.includes(c.displayMode)))) errors.push({ key: c.key, error: `displayMode ${c.displayMode} not offered` });
79 if (!c.image.uri && !c.image.source) errors.push({ key: c.key, error: 'image has neither a Harmony uri nor a source URL to import' });
80 if (!c.image.uri) warnings.push({ key: c.key, warning: `media not imported yet: ${c.image.source}` });
83 if (fam && !Object.values(ledger).includes(fam) && !/^(courier new|helvetica-w01-(roman|bold)|arial|georgia|times new roman)$/.test(fam)) warnings.push({ key: c.key, warning: `font '${fam}' is neither uploaded (ledger) nor a known catalog font` });
84}
85
86// ---- 2. local render with the source's fonts --------------------------------------------
87const back = Object.fromEntries(Object.entries(ledger).map(([src, fam]) => [fam, src]));
145 const load = (d) => new Promise((ok) => { const i = new Image(); i.onload = () => ok(i); i.src = d; });
146 const [ia, ib] = await Promise.all([load(a), load(b)]); const w = ia.width, h = Math.min(ia.height, ib.height);
147 const px = (img) => { const c = new OffscreenCanvas(w, h); const x = c.getContext('2d'); x.drawImage(img, 0, 0); return x.getImageData(0, 0, w, h).data; };
148 const da = px(ia), db = px(ib); let d = 0; for (let i = 0; i < da.length; i += 4) if (Math.max(Math.abs(da[i] - db[i]), Math.abs(da[i + 1] - db[i + 1]), Math.abs(da[i + 2] - db[i + 2])) > 40) d++;