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
776 lines
35 KiB
TypeScript
776 lines
35 KiB
TypeScript
import { describe, test, expect } from 'vitest';
|
|
import {
|
|
exportBodyHtml,
|
|
exportToHtml,
|
|
buildAnimationScript,
|
|
ExportOptions,
|
|
injectAttrs,
|
|
buildSeoMeta,
|
|
RESPONSIVE_CSS_MINIFIED,
|
|
VISIBILITY_CSS_MINIFIED,
|
|
} from './html-export';
|
|
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
|
|
|
|
/**
|
|
* C2: buildDataAttrs (internal to html-export.ts) previously interpolated
|
|
* `props.animation` / `props.animationDelay` directly into
|
|
* `data-animation="${...}"` / `data-animation-delay="${...}"` with no
|
|
* escaping. Runs for EVERY node (via renderNode -> buildDataAttrs ->
|
|
* injectAttrs) and is reachable via AI `update_props` + deserialized saved
|
|
* state. A malicious value like `x"><img src=y onerror=alert(1)>` breaks
|
|
* out of the attribute and injects a live element.
|
|
*/
|
|
describe('buildDataAttrs escapes animation/animationDelay (C2)', () => {
|
|
const makeState = (props: Record<string, unknown>) =>
|
|
JSON.stringify({
|
|
ROOT: {
|
|
type: { resolvedName: 'Container' },
|
|
isCanvas: true,
|
|
props: { tag: 'div', style: {}, ...props },
|
|
displayName: 'Container',
|
|
custom: {},
|
|
hidden: false,
|
|
nodes: [],
|
|
linkedNodes: {},
|
|
},
|
|
});
|
|
|
|
test('malicious animation value is escaped -- no raw breakout, no injected <img>', () => {
|
|
const payload = 'x"><img src=y onerror=alert(1)>';
|
|
const state = makeState({ animation: payload });
|
|
const { html } = exportBodyHtml(state);
|
|
|
|
expect(html).not.toContain('<img src=y onerror=alert(1)>');
|
|
expect(html).not.toMatch(/data-animation="x">/);
|
|
// The attribute value must be escaped, so no raw `"` or `<` survives
|
|
// inside the data-animation attribute region.
|
|
expect(html).toContain('data-animation="x"><img src=y onerror=alert(1)>"');
|
|
});
|
|
|
|
test('malicious animationDelay value is escaped -- no raw breakout', () => {
|
|
const payload = 'x"><script>alert(1)</script>';
|
|
const state = makeState({ animation: 'fade-in', animationDelay: payload });
|
|
const { html } = exportBodyHtml(state);
|
|
|
|
expect(html).not.toContain('<script>alert(1)</script>');
|
|
expect(html).toContain('data-animation-delay="x"><script>alert(1)</script>"');
|
|
});
|
|
|
|
test('normal animation value still emits data-animation="fade-in" unchanged', () => {
|
|
const state = makeState({ animation: 'fade-in' });
|
|
const { html } = exportBodyHtml(state);
|
|
expect(html).toContain('data-animation="fade-in"');
|
|
});
|
|
|
|
test('normal animationDelay value still emits unchanged', () => {
|
|
const state = makeState({ animation: 'fade-in', animationDelay: '0.5s' });
|
|
const { html } = exportBodyHtml(state);
|
|
expect(html).toContain('data-animation-delay="0.5s"');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Adversarial re-review of C1: the `typeName === 'Container' || typeName ===
|
|
* 'div'` fallback in renderNode (hit when a node's `resolvedName` isn't in
|
|
* the component resolver, e.g. legacy/tampered saved state) interpolated
|
|
* `props.tag` raw into `<${tag}` with no validation -- the same breakout
|
|
* class as the fixed Container.toHtml bug, reachable via deserialized saved
|
|
* state.
|
|
*/
|
|
describe('renderNode div-fallback allowlists props.tag', () => {
|
|
const makeUnresolvedDivState = (tag: unknown) =>
|
|
JSON.stringify({
|
|
ROOT: {
|
|
type: { resolvedName: 'div' }, // not in componentResolver -> hits the fallback branch
|
|
isCanvas: true,
|
|
props: { tag, style: {} },
|
|
displayName: 'div',
|
|
custom: {},
|
|
hidden: false,
|
|
nodes: [],
|
|
linkedNodes: {},
|
|
},
|
|
});
|
|
|
|
test('a malicious tag value falls back to div -- no injected <img>', () => {
|
|
const state = makeUnresolvedDivState('div><img src=x onerror=alert(1)');
|
|
const { html } = exportBodyHtml(state);
|
|
expect(html).not.toContain('<img');
|
|
expect(html).not.toContain('onerror');
|
|
expect(html.startsWith('<div')).toBe(true);
|
|
});
|
|
|
|
test('a valid tag (section) still emits <section', () => {
|
|
const state = makeUnresolvedDivState('section');
|
|
const { html } = exportBodyHtml(state);
|
|
expect(html).toContain('<section');
|
|
expect(html).toContain('</section>');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* PKG-H contract §2-4: per-page SEO/meta, favicon, and design-token CSS
|
|
* variables emitted into `wrapInDocument`'s <head> (exercised via
|
|
* `exportToHtml`, since `exportBodyHtml` intentionally stays body-only --
|
|
* contract §7). These assert the exact "only emit when set" / sanitization
|
|
* / ordering rules the backend implementer's `generateCompiledHTML` must
|
|
* mirror byte-for-byte for editor Preview === published output.
|
|
*/
|
|
describe('PKG-H: SEO/meta + favicon + design-token <head> emission', () => {
|
|
const EMPTY_ROOT_STATE = JSON.stringify({
|
|
ROOT: {
|
|
type: { resolvedName: 'Container' },
|
|
isCanvas: true,
|
|
props: { style: {}, tag: 'div' },
|
|
displayName: 'Container',
|
|
custom: {},
|
|
hidden: false,
|
|
nodes: [],
|
|
linkedNodes: {},
|
|
},
|
|
});
|
|
|
|
function exportWith(options: ExportOptions): string {
|
|
return exportToHtml(EMPTY_ROOT_STATE, options).html;
|
|
}
|
|
|
|
describe('back-compat: no seo, no favicon, no design passed', () => {
|
|
const html = exportWith({ title: 'Legacy Page' });
|
|
|
|
test('title falls back to the passed title, no robots/description/og/twitter/favicon tags appear', () => {
|
|
expect(html).toContain('<title>Legacy Page</title>');
|
|
expect(html).not.toContain('name="robots"');
|
|
expect(html).not.toContain('name="description"');
|
|
expect(html).not.toContain('property="og:description"');
|
|
expect(html).not.toContain('property="og:image"');
|
|
expect(html).not.toContain('rel="icon"');
|
|
// og:title still always emits (falls back to title) -- og:type is a
|
|
// fixed tag too -- neither depends on seo being set.
|
|
expect(html).toContain('<meta property="og:title" content="Legacy Page">');
|
|
expect(html).toContain('<meta property="og:type" content="website">');
|
|
// twitter:card only emits when ogImage or description is set -- neither
|
|
// is here, so it must be absent.
|
|
expect(html).not.toContain('name="twitter:card"');
|
|
});
|
|
|
|
test('still emits the :root token block + token-base CSS + a11y/perf CSS using design defaults (§7 intended change)', () => {
|
|
expect(html).toContain(':root{--wsb-primary:');
|
|
expect(html).toContain('--wsb-button-radius:');
|
|
expect(html).toContain('body{font-family:var(--wsb-body-font)');
|
|
expect(html).toContain('a:focus-visible');
|
|
expect(html).toContain('prefers-reduced-motion:reduce');
|
|
});
|
|
});
|
|
|
|
describe('meta/og/twitter/robots/favicon emit ONLY when their source is set', () => {
|
|
test('metaDescription set -> description + og:description + twitter:card (summary) all appear', () => {
|
|
const html = exportWith({ title: 'Page', description: 'A great page.' });
|
|
expect(html).toContain('<meta name="description" content="A great page.">');
|
|
expect(html).toContain('<meta property="og:description" content="A great page.">');
|
|
expect(html).toContain('<meta name="twitter:card" content="summary">');
|
|
});
|
|
|
|
test('ogImage set (no description) -> og:image + twitter:card defaults to summary_large_image', () => {
|
|
const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg' });
|
|
expect(html).toContain('<meta property="og:image" content="/uploads/hero.jpg">');
|
|
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
|
|
expect(html).not.toContain('name="description"');
|
|
});
|
|
|
|
test('explicit twitterCard overrides the derived default', () => {
|
|
const html = exportWith({ title: 'Page', ogImage: '/uploads/hero.jpg', twitterCard: 'summary' });
|
|
expect(html).toContain('<meta name="twitter:card" content="summary">');
|
|
});
|
|
|
|
test('ogTitle set -> og:title uses it instead of the page title', () => {
|
|
const html = exportWith({ title: 'Page Title', ogTitle: 'Custom Share Title' });
|
|
expect(html).toContain('<meta property="og:title" content="Custom Share Title">');
|
|
expect(html).not.toContain('<meta property="og:title" content="Page Title">');
|
|
});
|
|
|
|
test('noindex true -> robots meta appears; false/absent -> it does not', () => {
|
|
expect(exportWith({ title: 'Page', noindex: true })).toContain('<meta name="robots" content="noindex, nofollow">');
|
|
expect(exportWith({ title: 'Page', noindex: false })).not.toContain('name="robots"');
|
|
expect(exportWith({ title: 'Page' })).not.toContain('name="robots"');
|
|
});
|
|
|
|
test('favicon set -> icon link appears with the exact URL', () => {
|
|
const html = exportWith({ title: 'Page', favicon: '/uploads/favicon.png' });
|
|
expect(html).toContain('<link rel="icon" href="/uploads/favicon.png">');
|
|
});
|
|
|
|
test('favicon absent -> no icon link', () => {
|
|
expect(exportWith({ title: 'Page' })).not.toContain('rel="icon"');
|
|
});
|
|
});
|
|
|
|
describe('sanitization neutralizes malicious values', () => {
|
|
test('a javascript: og:image / favicon URL is dropped entirely (no tag emitted)', () => {
|
|
const html = exportWith({
|
|
title: 'Page',
|
|
ogImage: 'javascript:alert(1)',
|
|
favicon: 'javascript:alert(1)',
|
|
description: 'x', // keep twitter:card path alive to prove ogImage really resolved empty
|
|
});
|
|
expect(html).not.toContain('javascript:alert');
|
|
expect(html).not.toContain('property="og:image"');
|
|
expect(html).not.toContain('rel="icon"');
|
|
// og:image resolved empty -> twitter:card falls back to 'summary' (description-only path), not 'summary_large_image'.
|
|
expect(html).toContain('<meta name="twitter:card" content="summary">');
|
|
});
|
|
|
|
test('a data:image/png og:image URL is allowed through unchanged (image sink)', () => {
|
|
const html = exportWith({ title: 'Page', ogImage: 'data:image/png;base64,AAAA' });
|
|
expect(html).toContain('<meta property="og:image" content="data:image/png;base64,AAAA">');
|
|
});
|
|
|
|
test('an attribute-breakout metaDescription is escaped, not left raw', () => {
|
|
const payload = '"><script>alert(1)</script>';
|
|
const html = exportWith({ title: 'Page', description: payload });
|
|
expect(html).not.toContain('<script>alert(1)</script>');
|
|
expect(html).toContain('"><script>alert(1)</script>');
|
|
});
|
|
|
|
test('a malicious design-token value cannot break out of the :root{} block', () => {
|
|
const design: SiteDesign = { ...DEFAULT_SITE_DESIGN, primaryColor: '</style><script>alert(1)</script>' };
|
|
const html = exportWith({ title: 'Page', design });
|
|
expect(html).not.toContain('<script>alert(1)</script>');
|
|
expect(html).not.toContain('</style><script>');
|
|
});
|
|
});
|
|
|
|
describe(':root token block', () => {
|
|
test('emits all 16 contract-named CSS variables with sanitized values, in order', () => {
|
|
const design: SiteDesign = {
|
|
...DEFAULT_SITE_DESIGN,
|
|
primaryColor: '#111111',
|
|
secondaryColor: '#222222',
|
|
accentColor: '#333333',
|
|
linkColor: '#444444',
|
|
successColor: '#555555',
|
|
warningColor: '#666666',
|
|
errorColor: '#777777',
|
|
backgroundColor: '#888888',
|
|
textColor: '#999999',
|
|
mutedTextColor: '#aaaaaa',
|
|
borderColor: '#bbbbbb',
|
|
borderRadius: '12px',
|
|
headingFont: 'Georgia, serif',
|
|
bodyFont: 'Verdana, sans-serif',
|
|
buttonFont: 'Tahoma, sans-serif',
|
|
buttonRadius: '4px',
|
|
};
|
|
const html = exportWith({ title: 'Page', design });
|
|
|
|
const rootMatch = html.match(/:root\{([^}]*)\}/);
|
|
expect(rootMatch).not.toBeNull();
|
|
const rootBlock = rootMatch![1];
|
|
|
|
const expectedOrder = [
|
|
['--wsb-primary', '#111111'],
|
|
['--wsb-secondary', '#222222'],
|
|
['--wsb-accent', '#333333'],
|
|
['--wsb-link', '#444444'],
|
|
['--wsb-success', '#555555'],
|
|
['--wsb-warning', '#666666'],
|
|
['--wsb-error', '#777777'],
|
|
['--wsb-bg', '#888888'],
|
|
['--wsb-text', '#999999'],
|
|
['--wsb-muted', '#aaaaaa'],
|
|
['--wsb-border', '#bbbbbb'],
|
|
['--wsb-radius', '12px'],
|
|
['--wsb-heading-font', 'Georgia, serif'],
|
|
['--wsb-body-font', 'Verdana, sans-serif'],
|
|
['--wsb-button-font', 'Tahoma, sans-serif'],
|
|
['--wsb-button-radius', '4px'],
|
|
];
|
|
|
|
let lastIndex = -1;
|
|
for (const [varName, value] of expectedOrder) {
|
|
const decl = `${varName}:${value}`;
|
|
expect(rootBlock).toContain(decl);
|
|
const idx = rootBlock.indexOf(decl);
|
|
expect(idx).toBeGreaterThan(lastIndex);
|
|
lastIndex = idx;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('a11y/perf CSS', () => {
|
|
test('focus-visible outline rule is present', () => {
|
|
const html = exportWith({ title: 'Page' });
|
|
expect(html).toContain('a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}');
|
|
});
|
|
|
|
test('prefers-reduced-motion rule is present', () => {
|
|
const html = exportWith({ title: 'Page' });
|
|
expect(html).toContain('@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}');
|
|
});
|
|
});
|
|
|
|
describe('Google Fonts link', () => {
|
|
test('always includes display=swap, with no design passed (fallback full link)', () => {
|
|
const html = exportWith({ title: 'Page', includeFonts: true });
|
|
expect(html).toContain('display=swap');
|
|
expect(html).toContain('fonts.googleapis.com');
|
|
});
|
|
|
|
test('subsets to only the used families when all 3 design fonts are known presets', () => {
|
|
const design: SiteDesign = {
|
|
...DEFAULT_SITE_DESIGN,
|
|
headingFont: 'Playfair Display, serif',
|
|
bodyFont: 'Inter, sans-serif',
|
|
buttonFont: 'Inter, sans-serif',
|
|
};
|
|
const html = exportWith({ title: 'Page', includeFonts: true, design });
|
|
expect(html).toContain('display=swap');
|
|
expect(html).toContain('family=Playfair+Display');
|
|
expect(html).toContain('family=Inter');
|
|
// Not one of the 3 used fonts -- subset link must not pull it in.
|
|
expect(html).not.toContain('family=Merriweather');
|
|
});
|
|
|
|
test('falls back to the full multi-font link when a design font is not a known preset', () => {
|
|
const design: SiteDesign = {
|
|
...DEFAULT_SITE_DESIGN,
|
|
headingFont: 'Comic Sans MS, cursive',
|
|
};
|
|
const html = exportWith({ title: 'Page', includeFonts: true, design });
|
|
expect(html).toContain('display=swap');
|
|
// Full fallback link carries every preset family, including ones the
|
|
// design object doesn't reference -- a component using ANY preset
|
|
// never silently loses its font.
|
|
expect(html).toContain('family=Merriweather');
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* FIX: entrance-animation broken in Preview. Root causes (see
|
|
* .superpowers/sdd/fix-animation-contract.md):
|
|
* 1. injectAttrs inserted data-attrs before the tag's first `>`, so a void
|
|
* tag (`<img ... />`) became malformed (`<img ... / data-animation="...">`,
|
|
* attrs landing AFTER the self-close slash, outside the tag).
|
|
* 2. wrapInDocument's in-body reveal <script> was destroyed by TopBar's
|
|
* handlePreview, which replaces the whole <body> inner with a
|
|
* recomposed header+body+footer string that never carried the script.
|
|
*/
|
|
describe('injectAttrs well-formed void-tag attrs (animation fix)', () => {
|
|
// ImageBlock.toHtml renders `<img src="..." ... />` -- a real void-tag
|
|
// producer that goes through injectAttrs via renderNode.
|
|
const imageState = (props: Record<string, unknown>) =>
|
|
JSON.stringify({
|
|
ROOT: {
|
|
type: { resolvedName: 'ImageBlock' },
|
|
isCanvas: false,
|
|
props: { src: '/uploads/photo.jpg', style: {}, ...props },
|
|
displayName: 'ImageBlock',
|
|
custom: {},
|
|
hidden: false,
|
|
nodes: [],
|
|
linkedNodes: {},
|
|
},
|
|
});
|
|
|
|
test('void <img/> gets attrs INSIDE the tag, no " / " sequence before the final >', () => {
|
|
const { html } = exportBodyHtml(imageState({ animation: 'bounce' }));
|
|
expect(html).toContain('data-animation="bounce"');
|
|
// Well-formed: the attribute sits before the self-close slash.
|
|
expect(html).toMatch(/data-animation="bounce"\s*\/>/);
|
|
// Malformed shape from the bug: attrs landing after the slash.
|
|
expect(html).not.toMatch(/\/\s*data-animation="bounce"/);
|
|
expect(html).not.toContain('/ data-animation');
|
|
});
|
|
|
|
test('non-void tag (Container div) is unaffected -- attrs still inserted before its only >', () => {
|
|
const state = JSON.stringify({
|
|
ROOT: {
|
|
type: { resolvedName: 'Container' },
|
|
isCanvas: true,
|
|
props: { tag: 'div', style: {}, animation: 'fade-in' },
|
|
displayName: 'Container',
|
|
custom: {},
|
|
hidden: false,
|
|
nodes: [],
|
|
linkedNodes: {},
|
|
},
|
|
});
|
|
const { html } = exportBodyHtml(state);
|
|
expect(html).toMatch(/^<div[^>]*data-animation="fade-in"[^>]*>/);
|
|
expect(html).not.toContain('/>');
|
|
});
|
|
});
|
|
|
|
describe('buildAnimationScript (animation fix)', () => {
|
|
test('returns the IntersectionObserver reveal script when body contains data-animation', () => {
|
|
const body = '<div data-animation="fade-in">Hi</div>';
|
|
const script = buildAnimationScript(body);
|
|
expect(script).toContain('<script>');
|
|
expect(script).toContain('IntersectionObserver');
|
|
expect(script).toContain("querySelectorAll('[data-animation]')");
|
|
});
|
|
|
|
test('returns empty string when body has no data-animation', () => {
|
|
expect(buildAnimationScript('<div>Hi</div>')).toBe('');
|
|
});
|
|
|
|
test('reveal script coerces a bare-number delay to a valid CSS time (e.g. "2" -> "2s")', () => {
|
|
// animationDelay is stored as a plain seconds string ("2"); assigning that raw
|
|
// to el.style.animationDelay is invalid CSS and no-ops. The script must suffix a
|
|
// unit onto bare numbers while leaving unit-bearing values ("2s"/"200ms") alone.
|
|
const script = buildAnimationScript('<div data-animation="fade-in" data-animation-delay="2">Hi</div>');
|
|
expect(script).toContain("/^-?[0-9.]+$/.test(delay) ? delay + 's' : delay");
|
|
// guard against regressing to the raw (invalid) assignment
|
|
expect(script).not.toContain('animationDelay = delay;');
|
|
// sanity-check the coercion logic itself against representative inputs
|
|
const coerce = (delay: string) => (/^-?[0-9.]+$/.test(delay) ? delay + 's' : delay);
|
|
expect(coerce('2')).toBe('2s');
|
|
expect(coerce('0.5')).toBe('0.5s');
|
|
expect(coerce('2s')).toBe('2s');
|
|
expect(coerce('200ms')).toBe('200ms');
|
|
});
|
|
});
|
|
|
|
describe('Preview body-replacement keeps exactly one reveal script (animation fix)', () => {
|
|
const animatedState = JSON.stringify({
|
|
ROOT: {
|
|
type: { resolvedName: 'Container' },
|
|
isCanvas: true,
|
|
props: { tag: 'div', style: {}, animation: 'fade-in' },
|
|
displayName: 'Container',
|
|
custom: {},
|
|
hidden: false,
|
|
nodes: [],
|
|
linkedNodes: {},
|
|
},
|
|
});
|
|
|
|
test('wrapped doc alone already contains exactly one script + the CSS + the noscript fallback', () => {
|
|
const { html } = exportToHtml(animatedState, { title: 'Page' });
|
|
const scriptCount = (html.match(/IntersectionObserver/g) || []).length;
|
|
expect(scriptCount).toBe(1);
|
|
expect(html).toContain('[data-animation]{opacity:0}');
|
|
expect(html).toContain('<noscript><style>[data-animation]{opacity:1}</style></noscript>');
|
|
});
|
|
|
|
test('simulated handlePreview body-replacement: composed body built WITH buildAnimationScript still yields exactly one reveal script and the head CSS survives', () => {
|
|
// Mirror TopBar.tsx handlePreview: exportToHtml gives the wrapped doc
|
|
// (head CSS/noscript + its own in-body script); a "composedBody" of
|
|
// header+body+footer (no script of its own) is what actually replaces
|
|
// the <body> inner. Without appending buildAnimationScript to
|
|
// composedBody, the wrapped doc's script would be clobbered and the
|
|
// element would never reveal.
|
|
const { html: wrapped } = exportToHtml(animatedState, { title: 'Page' });
|
|
const headerHtml = '';
|
|
const { html: bodyHtml } = exportBodyHtml(animatedState);
|
|
const footerHtml = '';
|
|
const composedBody =
|
|
headerHtml + bodyHtml + footerHtml +
|
|
buildAnimationScript(headerHtml + bodyHtml + footerHtml);
|
|
|
|
const bodyMatch = wrapped.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
expect(bodyMatch).toBeTruthy();
|
|
const finalHtml = wrapped.replace(bodyMatch![1], () => composedBody);
|
|
|
|
const scriptCount = (finalHtml.match(/IntersectionObserver/g) || []).length;
|
|
expect(scriptCount).toBe(1);
|
|
expect(finalHtml).toContain('[data-animation]{opacity:0}');
|
|
expect(finalHtml).toContain('data-animation="fade-in"');
|
|
// No malformed void-tag artifact should leak into the final assembly.
|
|
expect(finalHtml).not.toContain('/ data-animation');
|
|
});
|
|
|
|
test('non-animated body: no animation CSS, no noscript, no reveal script anywhere', () => {
|
|
const plainState = JSON.stringify({
|
|
ROOT: {
|
|
type: { resolvedName: 'Container' },
|
|
isCanvas: true,
|
|
props: { tag: 'div', style: {} },
|
|
displayName: 'Container',
|
|
custom: {},
|
|
hidden: false,
|
|
nodes: [],
|
|
linkedNodes: {},
|
|
},
|
|
});
|
|
const { html } = exportToHtml(plainState, { title: 'Page' });
|
|
expect(html).not.toContain('[data-animation]');
|
|
expect(html).not.toContain('<noscript>');
|
|
expect(html).not.toContain('IntersectionObserver');
|
|
expect(buildAnimationScript(exportBodyHtml(plainState).html)).toBe('');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* FIX: bounce entrance-animation disappears after finishing + reads like a
|
|
* fade (see .superpowers/sdd/fix-anim-image-contract.md, section A). Root
|
|
* cause: the old `@keyframes bounce` set opacity at 0% and 60% but NOT at
|
|
* 100% -- with `animation-fill-mode: both`, on finish the element reverted
|
|
* to the base `[data-animation]{opacity:0}` rule and vanished. The fix is a
|
|
* springier keyframe that ends at `opacity:1`.
|
|
*/
|
|
describe('bounce keyframe ends at opacity:1 (fix-anim-image A)', () => {
|
|
const animatedState = (animation: string) => JSON.stringify({
|
|
ROOT: {
|
|
type: { resolvedName: 'Container' },
|
|
isCanvas: true,
|
|
props: { tag: 'div', style: {}, animation },
|
|
displayName: 'Container',
|
|
custom: {},
|
|
hidden: false,
|
|
nodes: [],
|
|
linkedNodes: {},
|
|
},
|
|
});
|
|
|
|
const NEW_BOUNCE_MINIFIED = '@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}';
|
|
const OLD_BOUNCE_TAIL = '100%{transform:translateY(0)}}';
|
|
|
|
test('minified export contains the new springier bounce substring, byte-identical to the shared contract', () => {
|
|
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
|
|
expect(html).toContain(NEW_BOUNCE_MINIFIED);
|
|
});
|
|
|
|
test('minified export does NOT contain the old bounce tail (100% with no opacity)', () => {
|
|
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
|
|
expect(html).not.toContain(OLD_BOUNCE_TAIL);
|
|
// Every keyframe's 100% frame in this doc must carry opacity:1 now.
|
|
expect(html).toContain('100%{opacity:1;transform:translateY(0)}}');
|
|
});
|
|
|
|
test('pretty (non-minified) export ends the bounce keyframe at 100% { opacity: 1; transform: translateY(0); }', () => {
|
|
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page', minifyCss: false });
|
|
const NEW_BOUNCE_PRETTY = '@keyframes bounce { 0% { opacity: 0; transform: translateY(40px); } 40% { opacity: 1; transform: translateY(-12px); } 60% { transform: translateY(6px); } 80% { transform: translateY(-3px); } 100% { opacity: 1; transform: translateY(0); } }';
|
|
expect(html).toContain(NEW_BOUNCE_PRETTY);
|
|
expect(html).not.toContain('100% { transform: translateY(0); } }');
|
|
});
|
|
|
|
test('other keyframes (fadeIn/slideUp/zoomIn) are unchanged', () => {
|
|
const { html } = exportToHtml(animatedState('bounce'), { title: 'Page' });
|
|
expect(html).toContain('@keyframes fadeIn{from{opacity:0}to{opacity:1}}');
|
|
expect(html).toContain('@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}');
|
|
expect(html).toContain('@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* W0-tokens-core: TOKEN_VARS moved from this module into utils/tokens.ts.
|
|
* The export head must be byte-identical before/after that move -- the
|
|
* hashes below were captured from the pre-move code (commit 97ccbee) for the
|
|
* same inputs, so any drift in the :root token block, its order, or the
|
|
* cssValue() sanitising fails here.
|
|
*/
|
|
describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () => {
|
|
const sha = async (s: string) => {
|
|
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
|
|
return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
};
|
|
const emptyRoot = JSON.stringify({
|
|
ROOT: {
|
|
type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {} },
|
|
displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {},
|
|
},
|
|
});
|
|
// W0-breakpoints deliberately moved the flex-stacking rule from 768 to the
|
|
// 767 mobile boundary. Undo exactly that one change before hashing so these
|
|
// baselines still prove nothing ELSE in the head moved.
|
|
const undo767 = (html: string) => {
|
|
const from = '@media(max-width:767px){[style*=';
|
|
expect(html.split(from).length).toBe(2);
|
|
return html.replace(from, '@media(max-width:768px){[style*=');
|
|
};
|
|
|
|
test('default design document hash matches the pre-move baseline', async () => {
|
|
const html = undo767(exportToHtml(emptyRoot, { title: 'T' }).html);
|
|
expect(html.length).toBe(2164);
|
|
expect(await sha(html)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6');
|
|
});
|
|
|
|
test('custom + hostile design document hash matches the pre-move baseline', async () => {
|
|
const design: SiteDesign = {
|
|
...DEFAULT_SITE_DESIGN,
|
|
primaryColor: '#ff0000',
|
|
headingFont: '"Playfair Display", serif',
|
|
accentColor: 'red}</style><script>',
|
|
};
|
|
const html = undo767(exportToHtml(emptyRoot, { title: 'T', design }).html);
|
|
expect(html.length).toBe(2452);
|
|
expect(await sha(html)).toBe('c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338');
|
|
});
|
|
|
|
test('default :root token block is the exact pre-move string', () => {
|
|
const html = exportToHtml(emptyRoot, { title: 'T' }).html;
|
|
expect(html).toContain(
|
|
':root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('per-breakpoint overrides + hidden nodes (W0-breakpoints)', () => {
|
|
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
|
|
type: { resolvedName: type },
|
|
isCanvas: type === 'Container',
|
|
props,
|
|
displayName: type,
|
|
custom: {},
|
|
hidden: false,
|
|
nodes,
|
|
linkedNodes: {},
|
|
...extra,
|
|
});
|
|
|
|
const makeState = (headingProps: Record<string, unknown>, headingExtra: Record<string, unknown> = {}) =>
|
|
JSON.stringify({
|
|
ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']),
|
|
sec1: node('Container', { tag: 'section', style: { padding: '20px' } }, ['head1'], { parent: 'ROOT' }),
|
|
head1: node('Heading', { text: 'Hello', level: 'h2', style: { fontSize: '40px' }, ...headingProps }, [], {
|
|
parent: 'sec1',
|
|
...headingExtra,
|
|
}),
|
|
});
|
|
|
|
test('a mobile override exports one scoped body <style> block + data-wsb-r on the <h2>', () => {
|
|
const { html, css } = exportBodyHtml(makeState({ styleMobile: { fontSize: '24px' } }));
|
|
expect(html.startsWith(
|
|
'<style data-wsb-responsive>@media (max-width:767px){[data-wsb-r="b-head1"]{font-size:24px!important}}</style>',
|
|
)).toBe(true);
|
|
expect(html).toContain('<h2 style="font-size:40px" data-wsb-r="b-head1">Hello</h2>');
|
|
expect(css).toBe('');
|
|
});
|
|
|
|
test('ROOT overrides in header/body/footer get distinct scopes (no cross-zone collision)', () => {
|
|
const rootOnly = JSON.stringify({
|
|
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, displayName: 'Container', custom: {},
|
|
hidden: false, nodes: [], linkedNodes: {}, props: { tag: 'div', style: {}, styleMobile: { padding: '4px' } } },
|
|
});
|
|
const h = exportBodyHtml(rootOnly, 'header').html;
|
|
const b = exportBodyHtml(rootOnly).html;
|
|
const f = exportBodyHtml(rootOnly, 'footer').html;
|
|
expect(h).toContain('[data-wsb-r="h-ROOT"]');
|
|
expect(h).toContain('<div data-wsb-r="h-ROOT"');
|
|
expect(b).toContain('[data-wsb-r="b-ROOT"]');
|
|
expect(f).toContain('[data-wsb-r="f-ROOT"]');
|
|
const doc = h + b + f;
|
|
expect(doc.match(/data-wsb-r="b-ROOT"/g)!.length).toBe(2); // rule + attribute
|
|
expect(doc).not.toMatch(/data-wsb-r="ROOT"/);
|
|
});
|
|
|
|
test('the full document carries the block in the BODY, not the head', () => {
|
|
const { html } = exportToHtml(makeState({ styleTablet: { textAlign: 'center' } }));
|
|
const head = html.slice(0, html.indexOf('</head>'));
|
|
const body = html.slice(html.indexOf('<body>'));
|
|
expect(head).not.toContain('data-wsb');
|
|
expect(body).toContain(
|
|
'<style data-wsb-responsive>@media (max-width:991px){[data-wsb-r="b-head1"]{text-align:center!important}}</style>',
|
|
);
|
|
});
|
|
|
|
test('a state without overrides exports byte-identically to the pre-change exporter', () => {
|
|
// Captured from the exporter BEFORE this task.
|
|
const { html } = exportBodyHtml(makeState({}));
|
|
expect(html).toBe('<div><section style="padding:20px"><h2 style="font-size:40px">Hello</h2></section></div>');
|
|
expect(exportBodyHtml(makeState({ styleTablet: {}, styleMobile: { fontSize: '' } })).html).toBe(html);
|
|
});
|
|
|
|
test('a node with hidden:true is omitted together with its subtree', () => {
|
|
const state = JSON.parse(makeState({ styleMobile: { fontSize: '24px' } }));
|
|
state.sec1.hidden = true;
|
|
const { html } = exportBodyHtml(JSON.stringify(state));
|
|
expect(html).toBe('<div></div>');
|
|
// ...and its overrides do not leak a style block either.
|
|
expect(html).not.toContain('data-wsb');
|
|
});
|
|
|
|
test('a hidden leaf is omitted, siblings survive', () => {
|
|
const { html } = exportBodyHtml(makeState({}, { hidden: true }));
|
|
expect(html).toBe('<div><section style="padding:20px"></section></div>');
|
|
});
|
|
|
|
test('RESPONSIVE_CSS_MINIFIED stacks flex rows at the 767 mobile boundary', () => {
|
|
expect(RESPONSIVE_CSS_MINIFIED).toContain('@media(max-width:767px){');
|
|
expect(RESPONSIVE_CSS_MINIFIED).not.toContain('768');
|
|
expect(VISIBILITY_CSS_MINIFIED).toBe(
|
|
'@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}',
|
|
);
|
|
const pretty = exportToHtml(makeState({}), { minifyCss: false }).html;
|
|
expect(pretty).toContain('@media (max-width: 767px) {\n [style*="display: flex"]');
|
|
expect(pretty).not.toContain('max-width: 768px');
|
|
});
|
|
});
|
|
|
|
describe('injectAttrs skips leading <style> blocks (W0-breakpoints)', () => {
|
|
test('attrs land on the element after a leading <style>', () => {
|
|
expect(injectAttrs('<style>.b:hover{color:red}</style><a href="#">x</a>', ' data-x')).toBe(
|
|
'<style>.b:hover{color:red}</style><a href="#" data-x>x</a>',
|
|
);
|
|
});
|
|
|
|
test('multiple leading style blocks are skipped', () => {
|
|
expect(injectAttrs('<style>a{}</style><style media="x">b{}</style><div>y</div>', ' data-x')).toBe(
|
|
'<style>a{}</style><style media="x">b{}</style><div data-x>y</div>',
|
|
);
|
|
});
|
|
|
|
test('void tag after a style block stays well-formed', () => {
|
|
expect(injectAttrs('<style>i{}</style><img src="a" />', ' data-x')).toBe('<style>i{}</style><img src="a" data-x/>');
|
|
});
|
|
|
|
test('plain html unchanged in behaviour', () => {
|
|
expect(injectAttrs('<div>x</div>', ' data-x')).toBe('<div data-x>x</div>');
|
|
expect(injectAttrs('<img src="a" />', ' data-x')).toBe('<img src="a" data-x/>');
|
|
expect(injectAttrs('<div>x</div>', '')).toBe('<div>x</div>');
|
|
});
|
|
|
|
test('ButtonLink with hover + override: data-wsb-r lands on the <a>, not the <style>', () => {
|
|
const state = JSON.stringify({
|
|
ROOT: {
|
|
type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {} },
|
|
displayName: 'Container', custom: {}, hidden: false, nodes: ['btn1'], linkedNodes: {},
|
|
},
|
|
btn1: {
|
|
type: { resolvedName: 'ButtonLink' }, isCanvas: false, parent: 'ROOT',
|
|
props: { text: 'Go', href: '#', hoverBg: '#000', style: {}, styleMobile: { width: '100%' } },
|
|
displayName: 'ButtonLink', custom: {}, hidden: false, nodes: [], linkedNodes: {},
|
|
},
|
|
});
|
|
const { html } = exportBodyHtml(state);
|
|
expect(html).toMatch(/<a href="#"[^>]* data-wsb-r="b-btn1">Go<\/a>/);
|
|
expect(html).not.toContain('<style data-wsb-r=');
|
|
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');
|
|
});
|
|
});
|