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');
});
});