Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
422697acec | ||
|
|
d1c57db967 | ||
|
|
071f3447fd | ||
|
|
98f2ebf118 | ||
|
|
916a568e9f | ||
|
|
32f4092156 | ||
|
|
6a9b227dda | ||
|
|
156c5bae35 | ||
|
|
69e61ab4b2 | ||
|
|
3dd6b54a35 | ||
|
|
d7eeff3a68 | ||
|
|
f43a1ef872 | ||
|
|
fd7f883d6a | ||
|
|
4cfcccd272 | ||
|
|
024f9fdd46 | ||
|
|
fc8918c1f9 | ||
|
|
9be71e8fd1 | ||
|
|
aba0d187d7 | ||
|
|
1a01834068 | ||
|
|
bfcf6278e9 | ||
|
|
e5fd74d63d | ||
|
|
cf38fdb245 | ||
|
|
2438777462 | ||
|
|
f9561c8c54 | ||
|
|
f30fc6efec | ||
|
|
eb4290a45e | ||
|
|
38c8d22e5d | ||
|
|
9885b37af5 | ||
|
|
86aacbe1a8 | ||
|
|
f0a1508acd | ||
|
|
66db1db507 | ||
|
|
b670b436c3 | ||
|
|
d89930e218 | ||
|
|
45ee004672 | ||
|
|
536e4e9f86 | ||
|
|
51f3fe81b6 | ||
|
|
321a193b83 | ||
|
|
4ac57e1c4c | ||
|
|
0d0d722dd7 | ||
|
|
a30e82accf | ||
|
|
6791345f77 | ||
|
|
fb68fa6485 | ||
|
|
c6840db0bb | ||
|
|
f5d2a23a5f | ||
|
|
c712a69c4a | ||
|
|
d460e8ac33 | ||
|
|
2b1569202a | ||
|
|
5c44dd545c | ||
|
|
2ac62c4e9e | ||
|
|
25dfcbb725 | ||
|
|
9bf78fd72d | ||
|
|
2dcc2b4d21 | ||
|
|
4e0fc78a30 | ||
|
|
85dfe181aa | ||
|
|
a698f014b0 | ||
|
|
204ea5e078 |
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This site builder integrates with WHP (Web Hosting Panel) to provide users with a visual site building interface. Users can create HTML pages using a drag-and-drop editor and save them directly to their web hosting account.
|
This site builder integrates with WHP (Web Hosting Platform) to provide users with a visual site building interface. Users can create HTML pages using a drag-and-drop editor and save them directly to their web hosting account.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, test, expect, vi } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useNode: (collect?: (node: any) => any) => {
|
||||||
|
const node = { events: { selected: false } };
|
||||||
|
return {
|
||||||
|
connectors: { connect: (el: any) => el, drag: (el: any) => el },
|
||||||
|
actions: { setProp: vi.fn() },
|
||||||
|
...(collect ? collect(node) : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { HtmlBlock } from './HtmlBlock';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HtmlBlock render ignores the style prop (matches toHtml)', () => {
|
||||||
|
test('a stored backgroundColor/color/padding is NOT applied to the wrapper', () => {
|
||||||
|
render(
|
||||||
|
React.createElement(HtmlBlock as any, {
|
||||||
|
code: '<p>hello</p>',
|
||||||
|
style: { backgroundColor: 'rgb(255, 0, 0)', color: 'rgb(0, 0, 255)', padding: '40px' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.style.backgroundColor).toBe('');
|
||||||
|
expect(wrapper.style.color).toBe('');
|
||||||
|
expect(wrapper.style.padding).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the editor affordances survive: minHeight is kept, content still renders', () => {
|
||||||
|
render(React.createElement(HtmlBlock as any, { code: '<p>hello</p>', style: {} }));
|
||||||
|
const wrapper = container.firstElementChild as HTMLElement;
|
||||||
|
expect(wrapper.style.minHeight).toBe('40px');
|
||||||
|
expect(wrapper.innerHTML).toContain('hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { purifyHtml } from './HtmlBlock';
|
||||||
|
// Vite/Vitest `?raw` import -- ships the exact bytes of the file as a
|
||||||
|
// string, declared by node_modules/vite/client.d.ts. This is a checked-in
|
||||||
|
// copy of the reference acceptance fixture used for Task 24 (widening the
|
||||||
|
// Custom HTML block's sanitiser allow-list); keep it byte-identical to the
|
||||||
|
// external fixture used to drive this task so these tests cannot silently
|
||||||
|
// drift from the thing they are supposed to be testing against.
|
||||||
|
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 24: the site owner tested a broad HTML fixture against the shipped
|
||||||
|
* sanitiser config and found 38% of it silently deleted -- merged table
|
||||||
|
* cells collapsing (colspan/rowspan/scope stripped), <dl>/<sub>/<details>/
|
||||||
|
* inline <svg>/<video>/<audio> dropped wholesale, lang/dir/role stripped
|
||||||
|
* (breaking RTL rendering), <ol start/reversed> flattened. The fix widens
|
||||||
|
* ALLOWED_TAGS/ALLOWED_ATTR in HtmlBlock.tsx. These tests run the *actual*
|
||||||
|
* reference fixture through the *actual* purifyHtml() and assert the
|
||||||
|
* previously-broken constructs now survive with their meaningful
|
||||||
|
* attributes intact, while re-confirming (with attack payloads spliced
|
||||||
|
* into the newly-widened surface -- forms, media, inline svg) that the
|
||||||
|
* four non-negotiable security properties still hold.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('purifyHtml -- Task 24 fixture regression (formerly-dropped constructs survive)', () => {
|
||||||
|
const out = purifyHtml(fixtureHtml);
|
||||||
|
|
||||||
|
test('table merged cells keep colspan/rowspan/scope', () => {
|
||||||
|
expect(out).toContain('<td colspan="2">');
|
||||||
|
expect(out).toContain('<td rowspan="2">');
|
||||||
|
expect(out).toContain('<th scope="col">');
|
||||||
|
expect(out).toContain('<th scope="row">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('definition list keeps its dl/dt/dd structure (was flattened to "TermDef")', () => {
|
||||||
|
expect(out).toMatch(/<dl>[\s\S]*<dt>Term one<\/dt>[\s\S]*<dd>Definition of the first term\.<\/dd>[\s\S]*<\/dl>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('menu list survives with nested buttons', () => {
|
||||||
|
expect(out).toMatch(/<menu>[\s\S]*<button type="button">Copy<\/button>[\s\S]*<\/menu>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sub/sup survive (was flattened to "H2O")', () => {
|
||||||
|
expect(out).toContain('H<sub>2</sub>O');
|
||||||
|
expect(out).toContain('x<sup>2</sup>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('details/summary survive with the open attribute (was flattened)', () => {
|
||||||
|
expect(out).toContain('<summary>Collapsed disclosure</summary>');
|
||||||
|
expect(out).toContain('<details open="">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hgroup survives', () => {
|
||||||
|
expect(out).toMatch(/<hgroup>[\s\S]*<h2>Grouped heading<\/h2>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inline svg survives with its shape children and role/aria-label (was deleted entirely)', () => {
|
||||||
|
expect(out).toMatch(/<svg[^>]*role="img"[^>]*aria-label="Two shapes"[^>]*>/);
|
||||||
|
expect(out).toMatch(/<rect[^>]*fill="none"[^>]*stroke="currentColor"[^>]*>/);
|
||||||
|
expect(out).toMatch(/<circle[^>]*cx="135"[^>]*cy="45"[^>]*r="40"[^>]*>/);
|
||||||
|
expect(out).toMatch(/<text[^>]*text-anchor="middle"[^>]*>svg<\/text>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picture/source with media+srcset survive', () => {
|
||||||
|
expect(out).toContain('<source media="(min-width: 800px)" srcset="wide.png">');
|
||||||
|
expect(out).toContain('<source media="(min-width: 400px)" srcset="medium.png">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('video/audio survive with source/track children (was deleted entirely)', () => {
|
||||||
|
expect(out).toMatch(/<video[^>]*controls=""[^>]*poster="poster\.jpg"[^>]*>/);
|
||||||
|
expect(out).toContain('<source src="clip.webm" type="video/webm">');
|
||||||
|
expect(out).toContain('<track kind="captions" src="captions.vtt" srclang="en" label="English">');
|
||||||
|
expect(out).toMatch(/<audio[^>]*controls=""[^>]*>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canvas survives with its fallback text', () => {
|
||||||
|
expect(out).toContain('<canvas width="200" height="60">Canvas fallback text</canvas>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mark/small/del/ins survive as distinct elements (was flattened to "msdi")', () => {
|
||||||
|
expect(out).toContain('<mark>mark</mark>');
|
||||||
|
expect(out).toContain('<small>small</small>');
|
||||||
|
expect(out).toContain('<del>del</del>');
|
||||||
|
expect(out).toContain('<ins>ins</ins>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lang/dir preserved for RTL text (was stripped, breaking Arabic/Hebrew rendering)', () => {
|
||||||
|
expect(out).toContain('lang="ar" dir="rtl"');
|
||||||
|
expect(out).toContain('lang="he" dir="rtl"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('role attribute preserved alongside aria-* (role was stripped)', () => {
|
||||||
|
expect(out).toMatch(/<nav aria-label="Primary">/);
|
||||||
|
expect(out).toMatch(/role="img"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ol start/reversed preserved (was flattened to plain <ol>)', () => {
|
||||||
|
expect(out).toContain('<ol start="5" reversed="">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('text semantics survive: abbr/cite/q/time/data/kbd/samp/var/dfn/address/bdi/bdo/ruby', () => {
|
||||||
|
expect(out).toContain('<abbr title="HyperText Markup Language">HTML</abbr>');
|
||||||
|
expect(out).toContain('<kbd>Ctrl</kbd>');
|
||||||
|
expect(out).toContain('<samp>output text</samp>');
|
||||||
|
expect(out).toContain('<var>variable</var>');
|
||||||
|
expect(out).toContain('<dfn>definition term</dfn>');
|
||||||
|
expect(out).toContain('<address>');
|
||||||
|
expect(out).toContain('<bdi>');
|
||||||
|
expect(out).toContain('<bdo dir="rtl">');
|
||||||
|
expect(out).toContain('<ruby>');
|
||||||
|
expect(out).toContain('<rt>kan</rt>');
|
||||||
|
expect(out).toContain('<time datetime="2026-08-09">');
|
||||||
|
expect(out).toContain('<data value="42">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wbr survives (word-break opportunity)', () => {
|
||||||
|
expect(out).toContain('super<wbr>cali<wbr>fragilistic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hidden attribute survives', () => {
|
||||||
|
expect(out).toContain('<p hidden="">');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forms survive end-to-end: fieldset/legend/label/select/optgroup/option/textarea/datalist/output/progress/meter', () => {
|
||||||
|
expect(out).toContain('<form action="#" method="get">');
|
||||||
|
expect(out).toContain('<fieldset>');
|
||||||
|
expect(out).toContain('<legend>Text inputs</legend>');
|
||||||
|
expect(out).toContain('<label for="f-text">Text</label>');
|
||||||
|
expect(out).toContain('<input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled">');
|
||||||
|
expect(out).toContain('<input id="f-email" type="email" required="">');
|
||||||
|
expect(out).toContain('<input id="f-num" type="number" min="0" max="100" step="5" value="25">');
|
||||||
|
expect(out).toContain('<input id="f-ro" type="text" value="read only" readonly="">');
|
||||||
|
expect(out).toContain('<input id="f-dis" type="text" value="disabled" disabled="">');
|
||||||
|
expect(out).toContain('<input type="checkbox" name="c" value="1" checked="">');
|
||||||
|
expect(out).toContain('<select id="f-select" name="select">');
|
||||||
|
expect(out).toContain('<optgroup label="Group one">');
|
||||||
|
expect(out).toContain('<option value="1" selected="">One</option>');
|
||||||
|
expect(out).toContain('<select id="f-multi" multiple="" size="4">');
|
||||||
|
expect(out).toContain('<datalist id="suggestions">');
|
||||||
|
expect(out).toContain('<textarea id="f-area" rows="4" cols="40">');
|
||||||
|
expect(out).toContain('<output name="result" for="f-num f-range">');
|
||||||
|
expect(out).toContain('<progress id="f-prog" value="0.6">');
|
||||||
|
expect(out).toContain('<meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">');
|
||||||
|
expect(out).toContain('<button type="submit">Submit</button>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bug fix: <select size> and <meter low/high/optimum> survive (both tags were already allowed, only these four attrs were missing)', () => {
|
||||||
|
expect(out).toContain('<select id="f-multi" multiple="" size="4">');
|
||||||
|
expect(out).toContain('low="30" high="80" optimum="90"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fixture byte survival crosses 90% (was 61.6% -- 9739/15815 -- before Task 24)', () => {
|
||||||
|
expect(out.length).toBeGreaterThan(fixtureHtml.length * 0.9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- Task 24: things in the fixture that must still be dropped', () => {
|
||||||
|
const out = purifyHtml(fixtureHtml);
|
||||||
|
|
||||||
|
test('style tag never survives', () => {
|
||||||
|
expect(out).not.toMatch(/<style[\s>]/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('script tag never survives', () => {
|
||||||
|
expect(out).not.toMatch(/<script[\s>]/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dialog/template stay excluded (not in the widened allow-list)', () => {
|
||||||
|
expect(out).not.toContain('<dialog');
|
||||||
|
expect(out).not.toContain('<template');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no on* handler survives anywhere in the widened output, including inside the dialog fallback content', () => {
|
||||||
|
expect(out).not.toMatch(/\son[a-z]+\s*=/i);
|
||||||
|
// The fixture's dialog/close buttons carry onclick specifically to
|
||||||
|
// prove this; their text content should still come through once the
|
||||||
|
// handler is stripped and (for dialog) the wrapping tag is dropped.
|
||||||
|
expect(out).toContain('Open dialog');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- Task 24: security properties on newly-allowed elements', () => {
|
||||||
|
test('script inside a newly-allowed <form> still never survives', () => {
|
||||||
|
const out = purifyHtml('<form><script>alert(1)</script></form>');
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on* handlers never survive on newly-allowed form controls', () => {
|
||||||
|
const out = purifyHtml('<input onfocus="alert(1)" value="x">');
|
||||||
|
expect(out).not.toMatch(/onfocus/i);
|
||||||
|
const out2 = purifyHtml('<select onchange="alert(1)"><option>x</option></select>');
|
||||||
|
expect(out2).not.toMatch(/onchange/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: blocked in <form action>', () => {
|
||||||
|
const out = purifyHtml('<form action="javascript:alert(1)"><button type="submit">go</button></form>');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formaction is not in the allow-list at all -- dropped regardless of value', () => {
|
||||||
|
const out = purifyHtml('<button formaction="javascript:alert(1)">go</button>');
|
||||||
|
expect(out).not.toContain('formaction');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: blocked on svg <a xlink:href> (xlink:href is not allow-listed at all)', () => {
|
||||||
|
const out = purifyHtml('<svg><a xlink:href="javascript:alert(1)">click</a></svg>');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
expect(out).not.toContain('xlink:href');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: blocked in newly-allowed media URL attributes (poster, source src)', () => {
|
||||||
|
const out = purifyHtml('<video poster="javascript:alert(1)"><source src="javascript:alert(2)"></video>');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: still blocked in plain href alongside the widened surface', () => {
|
||||||
|
const out = purifyHtml('<a href="javascript:alert(1)"><svg><text>x</text></svg></a>');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iframe still gets the forced restrictive sandbox + referrerpolicy alongside the widened surface', () => {
|
||||||
|
const out = purifyHtml('<form><input></form><iframe src="https://example.com/"></iframe>');
|
||||||
|
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
|
||||||
|
const sandbox = out.match(/sandbox="([^"]*)"/)![1];
|
||||||
|
expect(sandbox).not.toMatch(/allow-top-navigation/);
|
||||||
|
expect(out).toContain('referrerpolicy="no-referrer"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on* on an iframe is still stripped even though iframe now sits among many more allowed siblings', () => {
|
||||||
|
const out = purifyHtml('<iframe src="https://example.com/" onload="alert(1)"></iframe>');
|
||||||
|
expect(out).not.toMatch(/onload/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Task 25: style tag nested inside the newly-allowed inline svg now survives, scoped', () => {
|
||||||
|
// Was "style tag stays blocked" pre-Task-25 -- <style> is now a
|
||||||
|
// deliberate escape hatch (see HtmlBlock.tsx's ALLOWED_TAGS/Task 25
|
||||||
|
// comment), including copies nested inside inline SVG:
|
||||||
|
// querySelectorAll('style') in scopeStyleBlocks() doesn't care about
|
||||||
|
// namespace/nesting depth, because CSS itself doesn't respect SVG
|
||||||
|
// subtree boundaries -- an unscoped <style> inside <svg> would still
|
||||||
|
// apply page-wide, so it needs the same scoping as a top-level one.
|
||||||
|
const out = purifyHtml('<svg><style>svg{color:red}</style><rect width="1" height="1"></rect></svg>');
|
||||||
|
expect(out).toMatch(/<style/i);
|
||||||
|
expect(out).not.toContain('<style>svg{color:red}</style>'); // rewritten, not verbatim
|
||||||
|
expect(out).toMatch(/\.whp-html-\w+ svg\{color:red\}/);
|
||||||
|
expect(out).toContain('<rect');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('contenteditable does not smuggle an event handler in alongside it', () => {
|
||||||
|
const out = purifyHtml('<div contenteditable="true" onblur="alert(1)">x</div>');
|
||||||
|
expect(out).not.toMatch(/onblur/i);
|
||||||
|
expect(out).toContain('contenteditable="true"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dialog stays excluded even with an attack payload; its inert children still render', () => {
|
||||||
|
const out = purifyHtml('<dialog onclick="alert(1)"><p>hi</p></dialog>');
|
||||||
|
expect(out).not.toContain('<dialog');
|
||||||
|
expect(out).not.toMatch(/onclick/i);
|
||||||
|
expect(out).toContain('<p>hi</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: blocked via data: smuggling on newly-allowed poster/cite/action attributes', () => {
|
||||||
|
// data: is only allow-listed for data:image/*;base64, -- confirm the
|
||||||
|
// regex is not accidentally satisfied by a text/html or bare data:
|
||||||
|
// payload on any of the newly URI-checked attributes.
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<video poster="data:text/html,<script>alert(1)</script>"></video>' +
|
||||||
|
'<blockquote cite="data:text/html,x">q</blockquote>' +
|
||||||
|
'<form action="data:text/html,x"></form>',
|
||||||
|
);
|
||||||
|
expect(out).not.toContain('data:text/html');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('review fix: data:image/*;base64, URIs now actually survive on poster/cite/href (dead-code regex bug)', () => {
|
||||||
|
// ALLOWED_URI_REGEXP used to put the data:image arm inside the group
|
||||||
|
// that gets a trailing `:` appended to every alternative, requiring a
|
||||||
|
// second colon after the one already in "base64," -- which no real
|
||||||
|
// data URI has, so the clause could never match anything. Confirm the
|
||||||
|
// fixed regex actually allows a real base64 image data URI through on
|
||||||
|
// ordinary URI-checked attributes (not just the DATA_URI_TAGS-covered
|
||||||
|
// src ones tested below).
|
||||||
|
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||||
|
const out = purifyHtml(
|
||||||
|
`<video poster="data:image/png;base64,${b64}"></video>` +
|
||||||
|
`<blockquote cite="data:image/png;base64,${b64}">q</blockquote>` +
|
||||||
|
`<a href="data:image/png;base64,${b64}">img</a>`,
|
||||||
|
);
|
||||||
|
expect(out).toContain(`poster="data:image/png;base64,${b64}"`);
|
||||||
|
expect(out).toContain(`cite="data:image/png;base64,${b64}"`);
|
||||||
|
expect(out).toContain(`href="data:image/png;base64,${b64}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('documented reality: data: on img/video/audio/source src is mimetype-blind (DOMPurify DATA_URI_TAGS bypasses ALLOWED_URI_REGEXP)', () => {
|
||||||
|
// This is NOT gated by ALLOWED_URI_REGEXP at all -- DOMPurify has its
|
||||||
|
// own internal DATA_URI_TAGS allow-list (img, video, audio, source,
|
||||||
|
// image, track) that accepts ANY data: URI on the `src` attribute of
|
||||||
|
// those tags regardless of declared mimetype, before our regex is ever
|
||||||
|
// consulted. Acceptable because none of those tags execute their src
|
||||||
|
// as a document/script context in mainstream browsers -- the sink
|
||||||
|
// doesn't execute. Pinned here so a future DOMPurify version change to
|
||||||
|
// DATA_URI_TAGS shows up as a failing test, not a surprise in
|
||||||
|
// production. <iframe> -- the one tag where this WOULD be dangerous --
|
||||||
|
// is correctly not in DOMPurify's DATA_URI_TAGS list, so its src still
|
||||||
|
// goes through the normal ALLOWED_URI_REGEXP check and gets stripped.
|
||||||
|
const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||||
|
const imgOut = purifyHtml(`<img src="data:text/html;base64,${b64}">`);
|
||||||
|
expect(imgOut).toContain(`src="data:text/html;base64,${b64}"`);
|
||||||
|
|
||||||
|
const iframeOut = purifyHtml(`<iframe src="data:text/html;base64,${b64}"></iframe>`);
|
||||||
|
expect(iframeOut).not.toContain('data:');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- bug fix: size/low/high/optimum were stripped despite select/meter being allowed tags', () => {
|
||||||
|
test('<select size> survives with its value intact (multi-select row count)', () => {
|
||||||
|
const out = purifyHtml('<select size="4"><option>a</option></select>');
|
||||||
|
expect(out).toContain('size="4"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('<input size> survives with its value intact', () => {
|
||||||
|
const out = purifyHtml('<input type="text" size="10">');
|
||||||
|
expect(out).toContain('size="10"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('<meter low/high/optimum> survive with their values intact (threshold-based gauge colouring)', () => {
|
||||||
|
const out = purifyHtml('<meter low="1" high="9" optimum="5" value="4" min="0" max="10"></meter>');
|
||||||
|
expect(out).toContain('low="1"');
|
||||||
|
expect(out).toContain('high="9"');
|
||||||
|
expect(out).toContain('optimum="5"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
import { describe, test, expect } from 'vitest';
|
import { describe, test, expect } from 'vitest';
|
||||||
import { purifyHtml } from './HtmlBlock';
|
import { purifyHtml } from './HtmlBlock';
|
||||||
|
import { stableHash } from '../../utils/escape';
|
||||||
|
import fixtureHtml from './__fixtures__/html-block-test-body.html?raw';
|
||||||
|
// Ground truth "before" output: purifyHtml(fixtureHtml) computed with the
|
||||||
|
// EXACT HtmlBlock.tsx code as it stood at commit 6a9b227 (the commit
|
||||||
|
// immediately before Task 25 -- `git show
|
||||||
|
// 6a9b227:craft/src/components/basic/HtmlBlock.tsx`), run against the real
|
||||||
|
// dompurify+jsdom, not guessed at or re-derived from reading the code. See
|
||||||
|
// the "byte-diff against 6a9b227" describe block below -- this is the
|
||||||
|
// literal regression check the Task 25 review asked for, after the first
|
||||||
|
// round of `.not.toContain(...)`-style tests passed while FORCE_BODY was
|
||||||
|
// silently changing output for a comment-led, style-free fixture.
|
||||||
|
import preTask25FixtureOutput from './__fixtures__/html-block-test-body.pre-task25-output.html?raw';
|
||||||
|
|
||||||
describe('purifyHtml', () => {
|
describe('purifyHtml', () => {
|
||||||
test('strips script tags', () => {
|
test('strips script tags', () => {
|
||||||
@@ -17,8 +29,47 @@ describe('purifyHtml', () => {
|
|||||||
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
||||||
expect(out).toContain('youtube.com/embed/abc');
|
expect(out).toContain('youtube.com/embed/abc');
|
||||||
});
|
});
|
||||||
test('strips form/input', () => {
|
test('allows form/input (Task 24: forms are a deliberate escape-hatch addition) but still strips on*/script inside them', () => {
|
||||||
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
|
const out = purifyHtml('<form><input name="x" onfocus="bad()"><script>alert(1)</script></form>');
|
||||||
|
expect(out).toContain('<form');
|
||||||
|
expect(out).toContain('<input name="x">');
|
||||||
|
expect(out).not.toContain('onfocus');
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml markup path (C1 review finding)', () => {
|
||||||
|
test('a style attribute survives sanitization (colour picker output must not be silently dropped)', () => {
|
||||||
|
const out = purifyHtml('<p style="color: #ff0000">red text</p>');
|
||||||
|
expect(out).toBe('<p style="color: #ff0000">red text</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an id attribute survives sanitization (anchor targets)', () => {
|
||||||
|
const out = purifyHtml('<a href="#section" id="section">link</a>');
|
||||||
|
expect(out).toContain('id="section"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pasted table survives sanitization', () => {
|
||||||
|
const input = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||||
|
expect(purifyHtml(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('script tags still do not survive alongside a style attribute', () => {
|
||||||
|
const out = purifyHtml('<p style="color:#ff0000">ok</p><script>alert(1)</script>');
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
expect(out).toContain('style="color:#ff0000"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('on* handlers still do not survive on an element that also carries style', () => {
|
||||||
|
const out = purifyHtml('<p style="color:#ff0000" onclick="bad()">x</p>');
|
||||||
|
expect(out).not.toContain('onclick');
|
||||||
|
expect(out).toContain('style="color:#ff0000"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('javascript: URLs still do not survive on an element that also carries style', () => {
|
||||||
|
const out = purifyHtml('<a style="color:#ff0000" href="javascript:void(0)">x</a>');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
expect(out).toContain('style="color:#ff0000"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,3 +115,286 @@ describe('purifyHtml iframe sandboxing (M-6)', () => {
|
|||||||
expect(out).toContain('<p>hi</p>');
|
expect(out).toContain('<p>hi</p>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- Task 25: block-scoped <style> support', () => {
|
||||||
|
test('a block with no <style> at all is untouched: no wrapper div added', () => {
|
||||||
|
const out = purifyHtml('<p>hello</p>');
|
||||||
|
expect(out).toBe('<p>hello</p>');
|
||||||
|
expect(out).not.toContain('<div');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks WITHOUT <style> are byte-identical to pre-Task-25 output (no wrapper regression)', () => {
|
||||||
|
// Same representative inputs the Task 24 suite already pins to an exact
|
||||||
|
// string -- re-asserted here under the Task 25 name so a future change
|
||||||
|
// that starts wrapping every block (not just style-bearing ones) fails
|
||||||
|
// loudly and obviously, not just as a Task 24 side-effect.
|
||||||
|
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
|
||||||
|
const table = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||||
|
expect(purifyHtml(table)).toBe(table);
|
||||||
|
expect(purifyHtml('<a href="/x">x</a>')).toBe('<a href="/x">x</a>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the full Task 24 fixture (no <style> in it) produces no wrapper and is unaffected', () => {
|
||||||
|
// The fixture is the broadest real-world stand-in this repo has for
|
||||||
|
// "a customer's actual pasted block". It contains no <style>, so this
|
||||||
|
// is the closest thing to a real before/after diff over a large,
|
||||||
|
// realistic input: the only lever Task 25 pulled (allowing <style> +
|
||||||
|
// FORCE_BODY) must produce PRECISELY the same output as before for
|
||||||
|
// content that never touches that lever.
|
||||||
|
const out = purifyHtml(fixtureHtml);
|
||||||
|
expect(out).not.toContain('<div class="whp-html-');
|
||||||
|
expect(out).not.toMatch(/<style[\s>]/i); // still no bare <style> in this fixture
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty <style></style> (no CSS content) does not trigger a wrapper', () => {
|
||||||
|
const out = purifyHtml('<p>hi</p><style></style>');
|
||||||
|
expect(out).not.toContain('<div class="whp-html-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a whitespace-only <style> does not trigger a wrapper', () => {
|
||||||
|
const out = purifyHtml('<p>hi</p><style> \n </style>');
|
||||||
|
expect(out).not.toContain('<div class="whp-html-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a block WITH real <style> content gets wrapped in a scope-class div', () => {
|
||||||
|
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
|
||||||
|
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
|
||||||
|
expect(out).toContain('<h1>Hi</h1>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the style content is rewritten to only match inside the wrapper (the actual leak-prevention property)', () => {
|
||||||
|
const out = purifyHtml('<style>h1 { color: red; }</style><h1>Hi</h1>');
|
||||||
|
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||||
|
expect(out).toContain(`.${scopeClass} h1 { color: red; }`);
|
||||||
|
// The bare, unscoped rule must not appear anywhere in the output --
|
||||||
|
// that's exactly the leak this feature exists to close.
|
||||||
|
expect(out).not.toContain('<style>h1 { color: red; }</style>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scope class is deterministic: the SAME code produces the SAME class across repeated calls', () => {
|
||||||
|
const code = '<style>p { color: blue; }</style><p>x</p>';
|
||||||
|
const out1 = purifyHtml(code);
|
||||||
|
const out2 = purifyHtml(code);
|
||||||
|
expect(out1).toBe(out2);
|
||||||
|
const class1 = out1.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||||
|
const class2 = out2.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||||
|
expect(class1).toBe(class2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pinned scope class for a known input -- guards against silent hash-function drift', () => {
|
||||||
|
// If this ever needs to change, it means the hash function itself
|
||||||
|
// changed -- which would silently churn every stored site's HTML on
|
||||||
|
// next save and desync already-published pages from a fresh Preview.
|
||||||
|
// That should be a loud, deliberate decision, not a side-effect of an
|
||||||
|
// unrelated refactor -- hence pinning the literal output here.
|
||||||
|
const code = '<style>h1{color:red}</style>';
|
||||||
|
expect(stableHash(code)).toBe('5fwbyn');
|
||||||
|
const out = purifyHtml(code);
|
||||||
|
expect(out).toContain('class="whp-html-5fwbyn"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scope class is a pure function of `code` -- does not depend on Craft node id or call order', () => {
|
||||||
|
// purifyHtml's signature only ever takes the code string -- there is no
|
||||||
|
// node id parameter it could even reach for. This test documents that
|
||||||
|
// invariant so a future refactor threading a node id through here (as
|
||||||
|
// html-export.ts's renderNode already does for OTHER components, see
|
||||||
|
// its `scopeId` comment) doesn't silently get wired into this path too.
|
||||||
|
const codeA = '<style>h1 { color: red; }</style><h1>same content</h1>';
|
||||||
|
const codeB = '<style>h1 { color: red; }</style><h1>same content</h1>';
|
||||||
|
expect(codeA).toBe(codeB); // sanity: truly identical strings
|
||||||
|
const outA = purifyHtml(codeA);
|
||||||
|
const outB = purifyHtml(codeB);
|
||||||
|
expect(outA).toBe(outB);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FORCE_BODY regression: a block whose ENTIRE code is a leading <style> (nothing before it) still survives', () => {
|
||||||
|
// Without FORCE_BODY, DOMPurify parses `code` via DOMParser as a mini
|
||||||
|
// HTML document and serializes only <body>. Per the HTML5 parsing
|
||||||
|
// algorithm, a <style> tag with nothing before it is implicitly placed
|
||||||
|
// in the parser's <head>, which DOMPurify's body-only serialization
|
||||||
|
// never looks at -- the whole block would silently vanish. Confirmed
|
||||||
|
// empirically against dompurify+jsdom directly before this fix existed.
|
||||||
|
const out = purifyHtml('<style>h1{color:red}</style>');
|
||||||
|
expect(out).toContain('<style>');
|
||||||
|
expect(out).toContain('color:red');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('FORCE_BODY regression: leading <style> immediately followed by markup, both survive', () => {
|
||||||
|
const out = purifyHtml('<style>h1{color:red}</style><h1>Hi</h1>');
|
||||||
|
expect(out).toContain('<h1>Hi</h1>');
|
||||||
|
expect(out).toMatch(/<style>[\s\S]*color:\s*red/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(':root / html / body inside a block map to the block wrapper itself (end-to-end through purifyHtml)', () => {
|
||||||
|
const out = purifyHtml('<style>:root { --brand: red; } body { margin: 0; }</style><p>x</p>');
|
||||||
|
const scopeClass = out.match(/class="(whp-html-[0-9a-z]+)"/)![1];
|
||||||
|
expect(out).toContain(`.${scopeClass} { --brand: red; }`);
|
||||||
|
expect(out).toContain(`.${scopeClass} { margin: 0; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@import is stripped end-to-end (network-fetch/exfiltration channel)', () => {
|
||||||
|
const out = purifyHtml('<style>@import url("https://evil.example/x.css"); h1{color:red}</style><h1>x</h1>');
|
||||||
|
expect(out).not.toContain('@import');
|
||||||
|
expect(out).not.toContain('evil.example');
|
||||||
|
expect(out).toContain('color:red');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@keyframes body is not scoped (animation would otherwise break) -- end-to-end through purifyHtml', () => {
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<style>@keyframes spin { from { opacity: 0; } to { opacity: 1; } }</style><h1>x</h1>',
|
||||||
|
);
|
||||||
|
expect(out).toContain('@keyframes spin');
|
||||||
|
expect(out).toMatch(/@keyframes spin\s*\{\s*from\s*\{\s*opacity:\s*0;?\s*\}\s*to\s*\{\s*opacity:\s*1;?\s*\}\s*\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple <style> blocks in one Custom HTML block are each scoped under the SAME class', () => {
|
||||||
|
const out = purifyHtml('<style>h1{color:red}</style><h1>A</h1><style>p{color:blue}</style><p>B</p>');
|
||||||
|
const classes = [...out.matchAll(/class="(whp-html-[0-9a-z]+)"/g)].map((m) => m[1]);
|
||||||
|
expect(classes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(new Set(classes).size).toBe(1); // same block -> same scope class everywhere
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- Task 25: security properties of the newly-allowed <style>', () => {
|
||||||
|
test('</style> inside a CSS comment cannot break out into executable markup', () => {
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<style>/* </style><script>alert(1)</script> */ h1{color:red}</style><p>hi</p>',
|
||||||
|
);
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
expect(out).not.toMatch(/on[a-z]+\s*=/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('</style> inside a CSS string cannot break out into executable markup', () => {
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<style>h1::before{content:"</style><script>alert(1)</script>"}</style><p>hi</p>',
|
||||||
|
);
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('script/on*/javascript: are still stripped from markup sitting alongside a styled block', () => {
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<style>h1{color:red}</style><p onclick="alert(1)">x</p><script>alert(2)</script><a href="javascript:alert(3)">y</a>',
|
||||||
|
);
|
||||||
|
expect(out).not.toMatch(/onclick/i);
|
||||||
|
expect(out).not.toContain('<script');
|
||||||
|
expect(out).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iframe sandboxing still applies alongside a styled block', () => {
|
||||||
|
const out = purifyHtml('<style>h1{color:red}</style><iframe src="https://example.com/"></iframe>');
|
||||||
|
expect(out).toMatch(/<iframe[^>]*\bsandbox="[^"]+"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'documented reality: DOMPurify does not sanitize CSS declaration values -- ' +
|
||||||
|
'expression()/behavior/-moz-binding pass through untouched (dead in modern browsers, ' +
|
||||||
|
'not exploitable there, but not filtered by this pipeline either)',
|
||||||
|
() => {
|
||||||
|
const out = purifyHtml(
|
||||||
|
'<style>div{width:expression(alert(1));behavior:url(evil.htc);-moz-binding:url(evil.xml#x)}</style><div>x</div>',
|
||||||
|
);
|
||||||
|
expect(out).toContain('expression(alert(1))');
|
||||||
|
expect(out).toContain('behavior:url(evil.htc)');
|
||||||
|
expect(out).toContain('-moz-binding:url(evil.xml#x)');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('documented reality: url() to a remote host survives (legitimate for background-image, but a known CSS-exfiltration channel already accepted elsewhere in this config)', () => {
|
||||||
|
const out = purifyHtml('<style>div{background:url(https://tracker.example/pixel.png)}</style><div>x</div>');
|
||||||
|
expect(out).toContain('tracker.example');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- review finding: raw byte-diff against HtmlBlock.tsx@6a9b227 (the commit before Task 25)', () => {
|
||||||
|
// Round 1 of this task's tests used `.not.toContain(...)`/`.toContain(...)`
|
||||||
|
// assertions for the "no <style> => unchanged" guarantee. Those all
|
||||||
|
// passed while FORCE_BODY: true (applied unconditionally at the time)
|
||||||
|
// was silently changing the ACTUAL bytes for any style-free block that
|
||||||
|
// starts with a multi-line HTML comment -- including this repo's own
|
||||||
|
// fixture, which is exactly that shape. `.not.toContain` can't catch an
|
||||||
|
// extra leading newline; only a raw diff against the real old output
|
||||||
|
// can. These tests do that: `preTask25FixtureOutput` is
|
||||||
|
// `purifyHtml(fixtureHtml)` computed with the UNMODIFIED HtmlBlock.tsx
|
||||||
|
// source at 6a9b227 (via `git show 6a9b227:...`), run against the real
|
||||||
|
// dompurify+jsdom, not re-derived from reading the code -- see that
|
||||||
|
// fixture file's own header comment.
|
||||||
|
test('the fixture (comment-led, no <style>) is byte-identical to the pre-Task-25 output', () => {
|
||||||
|
expect(fixtureHtml.startsWith('<!--')).toBe(true); // sanity: this IS the comment-led shape
|
||||||
|
expect(purifyHtml(fixtureHtml)).toBe(preTask25FixtureOutput);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a short comment-led, style-free block matches pre-Task-25 output exactly (including the dropped leading whitespace quirk)', () => {
|
||||||
|
// Confirmed independently against 6a9b227's exact code: a multi-line
|
||||||
|
// leading comment followed by blank-line whitespace, with no <style>
|
||||||
|
// anywhere, produces "<p>hi</p>" -- both the comment AND the
|
||||||
|
// whitespace between it and <p> are dropped by the parser's
|
||||||
|
// "before head" insertion-mode rules (unrelated to this task; that's
|
||||||
|
// the pre-existing, unconditional behavior with FORCE_BODY off). The
|
||||||
|
// point of this test is that the NEW code must reproduce that exact
|
||||||
|
// old quirk byte-for-byte for style-free input, not "improve" on it.
|
||||||
|
const commentLed =
|
||||||
|
'<!-- ============================================================\n' +
|
||||||
|
' HTML test fixture header\n' +
|
||||||
|
' ============================================================ -->\n' +
|
||||||
|
'\n<p>hi</p>';
|
||||||
|
expect(purifyHtml(commentLed)).toBe('<p>hi</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plain style-free inputs (no comment involved) still match pre-Task-25 output', () => {
|
||||||
|
expect(purifyHtml('<p>hello</p>')).toBe('<p>hello</p>');
|
||||||
|
expect(purifyHtml('<p style="color: #ff0000">red text</p>')).toBe('<p style="color: #ff0000">red text</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- review finding: never throws, even on pathological deeply-nested @media input', () => {
|
||||||
|
function buildDeeplyNestedMedia(count: number): string {
|
||||||
|
// ~7000 nested @media blocks (the review's exact repro shape) reproduced
|
||||||
|
// through the REAL purifyHtml() call, not just scopeCss() in isolation
|
||||||
|
// -- proving the fix holds end-to-end through DOMPurify + scopeStyleBlocks,
|
||||||
|
// not merely in the unit-tested function.
|
||||||
|
let css = 'h1{color:red}';
|
||||||
|
for (let i = 0; i < count; i++) css = `@media (min-width: 1px) {${css}}`;
|
||||||
|
return `<style>${css}</style><h1>x</h1>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('~7000 levels of nested @media does not crash purifyHtml (was: RangeError: Maximum call stack size exceeded)', () => {
|
||||||
|
const code = buildDeeplyNestedMedia(7000);
|
||||||
|
expect(() => purifyHtml(code)).not.toThrow();
|
||||||
|
const out = purifyHtml(code);
|
||||||
|
expect(out).toContain('<h1>x</h1>');
|
||||||
|
expect(out).toContain('@media');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a scope class + wrapper is still produced for the pathological input (best-effort, not a silent no-op)', () => {
|
||||||
|
const code = buildDeeplyNestedMedia(7000);
|
||||||
|
const out = purifyHtml(code);
|
||||||
|
expect(out).toMatch(/^<div class="whp-html-[0-9a-z]+">/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('purifyHtml -- review finding: idempotent over its own prior output', () => {
|
||||||
|
test('running purifyHtml() twice (customer pastes previously-published output into a fresh block) does not nest a second wrapper', () => {
|
||||||
|
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
|
||||||
|
const once = purifyHtml(code);
|
||||||
|
const twice = purifyHtml(once);
|
||||||
|
expect(twice).toBe(once);
|
||||||
|
// Specifically: no second wrapper div, no double-prefixed selector.
|
||||||
|
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idempotent for a block using :root/media too', () => {
|
||||||
|
const code = '<style>:root{--x:1} @media (min-width: 600px) { h1, p { color: red; } }</style><h1>Hi</h1><p>x</p>';
|
||||||
|
const once = purifyHtml(code);
|
||||||
|
const twice = purifyHtml(once);
|
||||||
|
expect(twice).toBe(once);
|
||||||
|
expect((twice.match(/<div class="whp-html-/g) || []).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('three generations (paste published output into a block, publish again, paste THAT) stay stable', () => {
|
||||||
|
const code = '<style>h1{color:red}</style><h1>Hi</h1>';
|
||||||
|
const gen1 = purifyHtml(code);
|
||||||
|
const gen2 = purifyHtml(gen1);
|
||||||
|
const gen3 = purifyHtml(gen2);
|
||||||
|
expect(gen3).toBe(gen1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from 'vitest';
|
import { describe, test, expect } from 'vitest';
|
||||||
import { HtmlBlock } from './HtmlBlock';
|
import { HtmlBlock, purifyHtml } from './HtmlBlock';
|
||||||
|
|
||||||
const toHtml = (HtmlBlock as any).toHtml;
|
const toHtml = (HtmlBlock as any).toHtml;
|
||||||
|
|
||||||
@@ -23,3 +23,47 @@ describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
|
|||||||
expect(html).toBe('<p>hi</p>');
|
expect(html).toBe('<p>hi</p>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('toHtml never emits the style prop (the other half of the render/export contract)', () => {
|
||||||
|
const out = (HtmlBlock as any).toHtml(
|
||||||
|
{ code: '<p>hi</p>', style: { backgroundColor: '#ff0000', padding: '40px' } },
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
expect(out.html).toBe('<p>hi</p>');
|
||||||
|
expect(out.html).not.toContain('background');
|
||||||
|
expect(out.html).not.toContain('40px');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HtmlBlock.toHtml markup path (C1 review finding)', () => {
|
||||||
|
test('a style attribute inside `code` (e.g. from the toolbar colour picker) reaches exported output', () => {
|
||||||
|
const { html } = toHtml({ code: '<p style="color: #ff0000">red text</p>' }, '');
|
||||||
|
expect(html).toBe('<p style="color: #ff0000">red text</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a table inside `code` reaches exported output', () => {
|
||||||
|
const code = '<table><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||||
|
const { html } = toHtml({ code }, '');
|
||||||
|
expect(html).toBe(code);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HtmlBlock.toHtml -- Task 25: block-scoped <style>, and editor/export byte-parity', () => {
|
||||||
|
test('a <style>-bearing block exports the same scoped wrapper purifyHtml() would produce in the editor canvas', () => {
|
||||||
|
// The editor canvas (HtmlBlock component) and toHtml() (Preview +
|
||||||
|
// Published export) both call the exact same purifyHtml(code) -- this
|
||||||
|
// is the byte-parity invariant this project treats as a hard
|
||||||
|
// requirement. Proven here by calling purifyHtml directly (as the
|
||||||
|
// canvas's useMemo does) and toHtml (as export does) on the identical
|
||||||
|
// code string and asserting the two never diverge.
|
||||||
|
const code = '<style>h1 { color: red; }</style><h1>Hi</h1>';
|
||||||
|
const { html } = toHtml({ code }, '');
|
||||||
|
expect(html).toBe(purifyHtml(code));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a <style>-free block still exports byte-identical to pre-Task-25 output (no wrapper regression) via toHtml', () => {
|
||||||
|
const code = '<p>hello</p>';
|
||||||
|
const { html } = toHtml({ code }, '');
|
||||||
|
expect(html).toBe('<p>hello</p>');
|
||||||
|
expect(html).not.toContain('<div');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { CSSProperties, useMemo } from 'react';
|
import React, { CSSProperties, useMemo } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
import { stableHash } from '../../utils/escape';
|
||||||
|
import { scopeCss } from '../../utils/scope-css';
|
||||||
|
|
||||||
interface HtmlBlockProps {
|
interface HtmlBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -9,28 +11,207 @@ interface HtmlBlockProps {
|
|||||||
node_id?: string;
|
node_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task 24: widening the allow-list after a customer's broad HTML fixture
|
||||||
|
// showed 38% of it silently deleted (tables losing colspan/rowspan/scope,
|
||||||
|
// <dl>/<sub>/<details>/inline <svg>/<video>/<audio> dropped wholesale,
|
||||||
|
// lang/dir/role stripped, <ol start/reversed> flattened). The owner's call:
|
||||||
|
// be generous -- this block is an explicit escape hatch and customers
|
||||||
|
// reasonably expect it to render ordinary HTML, including forms. The four
|
||||||
|
// non-negotiables (no <script>, no on*, no javascript: URLs, iframes stay
|
||||||
|
// sandboxed) are unaffected by the widening and are covered by dedicated
|
||||||
|
// tests in HtmlBlock.test.ts / HtmlBlock.security.test.ts.
|
||||||
const PURIFY_CONFIG = {
|
const PURIFY_CONFIG = {
|
||||||
ALLOWED_TAGS: [
|
ALLOWED_TAGS: [
|
||||||
'a','p','br','hr','div','span','section','article',
|
'a','p','br','hr','div','span','section','article',
|
||||||
'header','footer','main','aside','nav',
|
'header','footer','main','aside','nav',
|
||||||
'ul','ol','li',
|
'ul','ol','li',
|
||||||
'h1','h2','h3','h4','h5','h6',
|
'h1','h2','h3','h4','h5','h6','hgroup',
|
||||||
'em','strong','b','i','u','s',
|
'em','strong','b','i','u','s',
|
||||||
'blockquote','code','pre',
|
'blockquote','code','pre',
|
||||||
'img','figure','figcaption',
|
'img','figure','figcaption',
|
||||||
'iframe',
|
'iframe',
|
||||||
|
// Tables: pasted content commonly includes these; dropping them
|
||||||
|
// silently ate customer-pasted tables (see C1 review finding).
|
||||||
|
'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col',
|
||||||
|
// Text semantics (Task 24).
|
||||||
|
'sub','sup','small','mark','del','ins','abbr','cite','q','time','data',
|
||||||
|
'kbd','samp','var','dfn','address','bdi','bdo','ruby','rt','rp','wbr',
|
||||||
|
// Lists (Task 24).
|
||||||
|
'dl','dt','dd','menu',
|
||||||
|
// Disclosure widget (Task 24). Note: <dialog> and <template> are
|
||||||
|
// deliberately NOT added -- the fixture exercises them wrapped in
|
||||||
|
// on*= handlers specifically to prove they still get neutralized/
|
||||||
|
// dropped by staying outside the allow-list.
|
||||||
|
'details','summary',
|
||||||
|
// Media (Task 24). URL-bearing attributes on these (poster, srcset,
|
||||||
|
// action, cite...) go through the ALLOWED_URI_REGEXP gate like
|
||||||
|
// everything else -- see _isValidAttribute in dompurify, which
|
||||||
|
// URI-checks every allowed attribute value except a small fixed
|
||||||
|
// "inert" list (alt, class, id, style, title, ...) that never includes
|
||||||
|
// src/poster/srcset. The one exception: `src` itself on img/video/
|
||||||
|
// audio/source/image/track is additionally covered by DOMPurify's own
|
||||||
|
// `DATA_URI_TAGS` allow-list, which accepts any data: URI on those
|
||||||
|
// tag/attribute pairs regardless of mimetype, bypassing this regex --
|
||||||
|
// see the ALLOWED_URI_REGEXP comment below and
|
||||||
|
// HtmlBlock.security.test.ts. Not a gap in the four non-negotiables:
|
||||||
|
// none of those tags execute their src as a document.
|
||||||
|
'picture','source','video','audio','track','canvas',
|
||||||
|
// Forms (Task 24). Site owner's explicit decision: allow the full
|
||||||
|
// ordinary form surface. No on*= survives (FORBID_ATTR below), and
|
||||||
|
// action/formaction-style URLs are gated by ALLOWED_URI_REGEXP the
|
||||||
|
// same as href/src, so `javascript:` still cannot survive here either.
|
||||||
|
'form','input','button','select','option','optgroup','textarea',
|
||||||
|
'label','fieldset','legend','datalist','output','progress','meter',
|
||||||
|
// Inline SVG (Task 24) -- see the block comment on IFRAME_SANDBOX_HOOK's
|
||||||
|
// neighbor below for why this is an explicit tag list rather than
|
||||||
|
// DOMPurify's USE_PROFILES svg profile. Deliberately excludes <use> and
|
||||||
|
// <image> (both need xlink:href, an external-reference vector DOMPurify
|
||||||
|
// itself excludes from its own SVG defaults) and <a>/<foreignObject>
|
||||||
|
// (not needed by the fixture; foreignObject can embed arbitrary HTML).
|
||||||
|
'svg','g','defs','symbol','title','desc','rect','circle','ellipse',
|
||||||
|
'line','polyline','polygon','path','text','tspan',
|
||||||
|
'lineargradient','radialgradient','stop','clippath','mask','marker',
|
||||||
|
'pattern','switch','view',
|
||||||
|
// Task 25: block-scoped <style> support. Formerly in FORBID_TAGS
|
||||||
|
// (stripped entirely). Now allowed through sanitisation -- its CSS is
|
||||||
|
// rewritten by scopeStyleBlocks()/scopeCss() below, immediately after
|
||||||
|
// DOMPurify runs, so it can only match inside this block's own wrapper
|
||||||
|
// element. See the FORCE_BODY comment below and scopeStyleBlocks() for
|
||||||
|
// why allowing the tag alone is not sufficient.
|
||||||
|
//
|
||||||
|
// Review note (Task 25 follow-up, documented not fixed): DOMPurify's
|
||||||
|
// SAFE_FOR_XML default (on unless a caller explicitly disables it,
|
||||||
|
// which PURIFY_CONFIG does not) silently drops an ENTIRE <style>
|
||||||
|
// element -- not just the offending part -- if its text content
|
||||||
|
// contains anything that merely LOOKS tag-like (a `<` followed by a
|
||||||
|
// word character, `/`, or `!`), as an mXSS-namespace-confusion defense
|
||||||
|
// that isn't specific to <style>. So `.x::after{content:"<Read
|
||||||
|
// More>"}` -- a plausible, entirely benign real-world CSS content
|
||||||
|
// string -- makes the whole style block vanish with no error, the same
|
||||||
|
// way a `<script>` would. This is a GOOD security property (better
|
||||||
|
// paranoid than exploitable), but it's an undocumented interaction
|
||||||
|
// with this newly-widened surface that will otherwise confuse whoever
|
||||||
|
// debugs the inevitable "my CSS just disappeared" report -- confirmed
|
||||||
|
// empirically against dompurify+jsdom directly, not guessed at.
|
||||||
|
'style',
|
||||||
],
|
],
|
||||||
|
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
|
||||||
|
// allowlist rather than extending it, so anything the product needs
|
||||||
|
// (style, id, ...) must be listed explicitly here even though DOMPurify
|
||||||
|
// would allow it by default.
|
||||||
ALLOWED_ATTR: [
|
ALLOWED_ATTR: [
|
||||||
'href','src','alt','title','target','rel',
|
'href','src','alt','title','target','rel',
|
||||||
'width','height','class',
|
'width','height','class','id','style',
|
||||||
'allowfullscreen','allow','frameborder',
|
'allowfullscreen','allow','frameborder',
|
||||||
'sandbox','referrerpolicy',
|
'sandbox','referrerpolicy',
|
||||||
|
// Task 24 additions.
|
||||||
|
'colspan','rowspan','scope','headers','span','start','reversed',
|
||||||
|
'type','value','name','placeholder','required','disabled','readonly',
|
||||||
|
'checked','selected','multiple','size','min','max','step','minlength',
|
||||||
|
'maxlength','pattern','rows','cols','accept','action','method','for',
|
||||||
|
'list','label','datetime','cite','lang','dir','role','srcset','media',
|
||||||
|
'sizes','loading','controls','poster','loop','muted','autoplay',
|
||||||
|
'preload','playsinline','kind','srclang','default','open','download',
|
||||||
|
'hidden','contenteditable',
|
||||||
|
// Bug fix: <select size="4">/<input size> and <meter low/high/optimum>
|
||||||
|
// were still being stripped even though <select>/<meter> are already in
|
||||||
|
// ALLOWED_TAGS -- only these four attribute names were missing here.
|
||||||
|
// Effect: a multi-select rendered at default height instead of the
|
||||||
|
// requested row count, and <meter> lost its threshold-based gauge
|
||||||
|
// colouring. Pure presentation/semantic attributes -- no URL, no
|
||||||
|
// script, no event-handler surface -- so no security weight added.
|
||||||
|
'low','high','optimum',
|
||||||
|
// SVG presentation attributes (explicit route -- see ALLOWED_TAGS
|
||||||
|
// comment on the SVG tag list). Covers the fixture's <svg viewBox
|
||||||
|
// role>/<rect>/<circle>/<text> block plus the common presentation
|
||||||
|
// attributes for the shapes/gradients allowed above. Deliberately
|
||||||
|
// excludes xlink:href (no <use>/<image> allowed, so it has nothing
|
||||||
|
// legitimate to attach to) and the SMIL/animation attributes (begin,
|
||||||
|
// dur, repeatCount, ...) which DOMPurify's own SVG defaults exclude
|
||||||
|
// for the same reason on* handlers are excluded.
|
||||||
|
'viewbox','cx','cy','r','rx','ry','x','y','x1','y1','x2','y2',
|
||||||
|
'points','d','fill','stroke','stroke-width','stroke-linecap',
|
||||||
|
'stroke-linejoin','stroke-dasharray','fill-rule','clip-rule','opacity',
|
||||||
|
'fill-opacity','stroke-opacity','text-anchor','dominant-baseline',
|
||||||
|
'font-family','font-size','font-weight','transform','offset',
|
||||||
|
'stop-color','stop-opacity','gradientunits','gradienttransform',
|
||||||
|
'preserveaspectratio',
|
||||||
],
|
],
|
||||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
// Review fix (Task 24 follow-up): the data:image arm used to sit inside
|
||||||
FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
|
// the group that gets a trailing `:` appended for every alternative
|
||||||
|
// (`(?:https?|mailto|tel|data:image\/[a-z]+;base64,):`), so it required
|
||||||
|
// a SECOND colon after the one already in "base64,figure" -- no real
|
||||||
|
// data URI has that, so the clause could never match. It is now its own
|
||||||
|
// top-level alternative. NOTE: this regex is not the only thing gating
|
||||||
|
// data: URIs -- DOMPurify has its own internal `DATA_URI_TAGS` allow-list
|
||||||
|
// (img/video/audio/source/image/track) that accepts ANY data: URI on
|
||||||
|
// those tag/attribute pairs regardless of declared mimetype, bypassing
|
||||||
|
// this regex entirely. See HtmlBlock.security.test.ts for a regression
|
||||||
|
// test documenting that (acceptable: none of those tags execute their
|
||||||
|
// src as a document in mainstream browsers, and <iframe> -- which would
|
||||||
|
// be dangerous -- is correctly not in that DOMPurify list).
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|data:image\/[a-z]+;base64,|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
|
// form/input/button/select/textarea removed from FORBID_TAGS (Task 24) --
|
||||||
|
// they are now deliberately allowed above. script/object/embed/link/meta
|
||||||
|
// stay forbidden. <style> (Task 25) is now allowed too -- see ALLOWED_TAGS
|
||||||
|
// comment above and scopeStyleBlocks() below; it survives sanitisation
|
||||||
|
// here but its CSS gets scoped afterwards, including copies nested inside
|
||||||
|
// the newly-allowed inline <svg> (querySelectorAll('style') in
|
||||||
|
// scopeStyleBlocks() doesn't care about namespace/nesting depth).
|
||||||
|
FORBID_TAGS: ['script','object','embed','link','meta'],
|
||||||
FORBID_ATTR: [/^on/i],
|
FORBID_ATTR: [/^on/i],
|
||||||
|
// NOTE: FORCE_BODY is deliberately NOT set here -- see
|
||||||
|
// needsForceBody()/purifyHtml() below. It's applied conditionally, per
|
||||||
|
// call, only when the input actually has a real <style> tag to rescue.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Task 25: without FORCE_BODY, DOMPurify parses `input` as a full (mini)
|
||||||
|
// HTML document via DOMParser and only serializes <body>'s contents. Per
|
||||||
|
// the HTML5 parsing algorithm, a tag that can only legally appear in
|
||||||
|
// <head> -- and now that <style> is allowed, that includes <style> --
|
||||||
|
// gets implicitly placed in <head> when it appears before any other real
|
||||||
|
// content, and is silently lost (DOMPurify never looks at <head>). A block
|
||||||
|
// whose entire `code` is `<style>h1{color:red}</style>` -- a very
|
||||||
|
// plausible paste, style-before-markup is a common snippet shape -- would
|
||||||
|
// vanish with no error anywhere, despite <style> sitting right there in
|
||||||
|
// ALLOWED_TAGS. FORCE_BODY prepends an internal element before parsing so
|
||||||
|
// the parser is already in body-insertion-mode by the time it reaches the
|
||||||
|
// customer's first tag, keeping a leading <style> (or anything else) in
|
||||||
|
// <body> where DOMPurify's body-only serialization actually looks.
|
||||||
|
// Confirmed empirically against dompurify+jsdom directly (not just this
|
||||||
|
// app's behavior) -- see the "leading <style> with nothing before it" test
|
||||||
|
// in HtmlBlock.test.ts.
|
||||||
|
//
|
||||||
|
// Review finding (Task 25 follow-up): FORCE_BODY is NOT a no-op for input
|
||||||
|
// that has no <style> tag at all. It also changes how the HTML parser
|
||||||
|
// treats character content sitting between a LEADING comment and the next
|
||||||
|
// real tag -- normal parsing (before <body> is established) silently drops
|
||||||
|
// pure-whitespace text runs there per the HTML5 "before head" insertion
|
||||||
|
// mode rules, while FORCE_BODY (already in body-insertion-mode from the
|
||||||
|
// first token) preserves that whitespace as a real text node. Concretely:
|
||||||
|
// a block starting with a multi-line HTML comment -- this repo's own
|
||||||
|
// ~16KB fixture does exactly that -- gained 2 extra leading bytes (a
|
||||||
|
// preserved newline) once FORCE_BODY was unconditionally on, which
|
||||||
|
// silently broke the "blocks without <style> are byte-identical to
|
||||||
|
// pre-Task-25 output" guarantee (confirmed with a raw diff against
|
||||||
|
// HtmlBlock.tsx@6a9b227 -- the commit immediately before this task -- over
|
||||||
|
// the fixture and a comment-led block; see HtmlBlock.test.ts). Fix: only
|
||||||
|
// ever set FORCE_BODY when the input has a real <style> tag to rescue --
|
||||||
|
// the one and only case that needs it -- so every other input takes
|
||||||
|
// exactly the pre-Task-25 code path, unchanged.
|
||||||
|
//
|
||||||
|
// "Real" deliberately excludes a `<style` substring that only appears
|
||||||
|
// inside an HTML comment (e.g. a customer's own code-sample text
|
||||||
|
// mentioning `<style>`) -- that text can never become an actual <style>
|
||||||
|
// element, but naively substring-matching it would still flip FORCE_BODY
|
||||||
|
// on and reintroduce the exact same whitespace-preservation side effect
|
||||||
|
// for a block that never had, and never needed, real style scoping.
|
||||||
|
const STYLE_TAG_RE = /<style[\s>/]/i;
|
||||||
|
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
||||||
|
function needsForceBody(input: string): boolean {
|
||||||
|
return STYLE_TAG_RE.test(input.replace(HTML_COMMENT_RE, ''));
|
||||||
|
}
|
||||||
|
|
||||||
// M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case)
|
// M-6: `<iframe>` is allowed (maps/video embeds are a legitimate use case)
|
||||||
// but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/
|
// but an iframe with a `src` and NO `sandbox` attribute is a clickjacking/
|
||||||
// phishing vector (DOMPurify already strips <script>/on*=, but an
|
// phishing vector (DOMPurify already strips <script>/on*=, but an
|
||||||
@@ -48,6 +229,120 @@ const IFRAME_SANDBOX_HOOK = (node: Element): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 25: rewrite any surviving `<style>` element(s) in `sanitized` (the
|
||||||
|
* DOMPurify output) so their CSS only matches inside this block's own
|
||||||
|
* wrapper element, then wrap the whole thing in that wrapper.
|
||||||
|
*
|
||||||
|
* Deliberately does the LEAST work possible when there's nothing to scope:
|
||||||
|
* a cheap substring check bails out before touching the DOM at all, so a
|
||||||
|
* block that doesn't use <style> -- i.e. every block saved before this task
|
||||||
|
* -- gets `sanitized` back completely unchanged (same string, no wrapper,
|
||||||
|
* no re-serialization round-trip that could subtly reformat attributes).
|
||||||
|
* That byte-for-byte identity is a hard requirement: published pages
|
||||||
|
* already contain `toHtml()` output with NO wrapper element, and adding one
|
||||||
|
* unconditionally would silently change the DOM/box-model of every
|
||||||
|
* existing customer block. See HtmlBlock.test.ts's
|
||||||
|
* "blocks without <style> are byte-identical" tests, which run this
|
||||||
|
* against real fixture content and diff the exact string.
|
||||||
|
*
|
||||||
|
* Scope identifier: `whp-html-${stableHash(rawCode)}` -- `stableHash` is
|
||||||
|
* the existing djb2 hash from utils/escape.ts (already used for this exact
|
||||||
|
* class of problem, see `scopeId` in that file), applied to `rawCode` --
|
||||||
|
* the block's own `code` prop, nothing else. Pure function of the block's
|
||||||
|
* own content: no Math.random, no Date.now, no counter, and deliberately
|
||||||
|
* NOT the Craft node id (unlike `scopeId`), because a scope identifier that
|
||||||
|
* depends on anything outside `code` would make the editor canvas preview
|
||||||
|
* (which calls purifyHtml(code) on render) and the published output (which
|
||||||
|
* calls the same purifyHtml(code) at publish time) diverge whenever that
|
||||||
|
* outside thing differs between the two call sites, and would make the
|
||||||
|
* stored HTML churn on every save even when the block's own content didn't
|
||||||
|
* change. Hashing `code` guarantees purifyHtml(code) is fully deterministic
|
||||||
|
* on its own -- same code in, byte-identical output out, every time, in
|
||||||
|
* both places it's called.
|
||||||
|
*/
|
||||||
|
const SCOPE_CLASS_RE = /^whp-html-[0-9a-z]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotency (review finding, Task 25 follow-up): `purifyHtml()` is not
|
||||||
|
* reachable-with-its-own-output through any CURRENT code path, but nothing
|
||||||
|
* stops a customer from pasting previously-published or exported HTML from
|
||||||
|
* this exact feature into a fresh Custom HTML block -- at which point
|
||||||
|
* `code` already contains our own `<div class="whp-html-OLD">...<style>
|
||||||
|
* .whp-html-OLD h1{...}</style>...</div>` wrapper. Without this check,
|
||||||
|
* `scopeStyleBlocks` would hash the NEW `code` to a NEW scope class, fail
|
||||||
|
* to recognise the embedded selectors as already scoped (they're prefixed
|
||||||
|
* for the OLD class, not the new one `scopeCss`'s own idempotency guard
|
||||||
|
* checks against), and nest a second wrapper div around the first while
|
||||||
|
* re-prefixing every selector under the new class on top of the old one.
|
||||||
|
*
|
||||||
|
* Detects "the sanitized content IS ALREADY exactly one of our own scoped
|
||||||
|
* wrappers": a single root element, a <div>, whose class matches our own
|
||||||
|
* naming convention, and whose `<style>` descendant(s) are each already a
|
||||||
|
* no-op under `scopeCss` for that div's own class -- i.e. re-scoping would
|
||||||
|
* change nothing. That last check reuses `scopeCss`'s own idempotency
|
||||||
|
* guarantee (`scopeCss(scopeCss(x, S), S) === scopeCss(x, S)`, proved in
|
||||||
|
* scope-css.test.ts) rather than re-implementing "is this CSS already
|
||||||
|
* scoped" as a second parser: if scoping again under the div's own class
|
||||||
|
* is a no-op, the CSS is already confined to that div, regardless of
|
||||||
|
* whether this app was the one that put it there -- which is the actual
|
||||||
|
* safety property this function exists to guarantee, not merely a proxy
|
||||||
|
* for it.
|
||||||
|
*/
|
||||||
|
function isAlreadyScoped(container: HTMLElement): boolean {
|
||||||
|
if (container.children.length !== 1) return false;
|
||||||
|
const root = container.children[0];
|
||||||
|
if (root.tagName !== 'DIV') return false;
|
||||||
|
const cls = root.getAttribute('class') || '';
|
||||||
|
if (!SCOPE_CLASS_RE.test(cls)) return false;
|
||||||
|
|
||||||
|
const scopeSelector = `.${cls}`;
|
||||||
|
const styleEls = Array.from(root.querySelectorAll('style'));
|
||||||
|
if (styleEls.length === 0) return false; // matches our naming by coincidence but scopes nothing -- not ours to protect
|
||||||
|
|
||||||
|
return styleEls.every((el) => {
|
||||||
|
const text = el.textContent || '';
|
||||||
|
if (text.trim() === '') return true;
|
||||||
|
return scopeCss(text, scopeSelector) === text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeStyleBlocks(sanitized: string, rawCode: string): string {
|
||||||
|
if (!sanitized.includes('<style')) return sanitized;
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = sanitized;
|
||||||
|
|
||||||
|
if (isAlreadyScoped(container)) return sanitized;
|
||||||
|
|
||||||
|
const styleEls = Array.from(container.querySelectorAll('style'));
|
||||||
|
const nonEmpty = styleEls.filter((el) => (el.textContent || '').trim() !== '');
|
||||||
|
if (nonEmpty.length === 0) return sanitized;
|
||||||
|
|
||||||
|
// Review note (Task 25 follow-up, documented not fixed): `stableHash` is
|
||||||
|
// a 32-bit djb2 hash, so it's brute-forceable in principle -- a customer
|
||||||
|
// could deliberately craft a second block's `code` to collide onto the
|
||||||
|
// same `whp-html-<hash>` class as an existing block on the same page, at
|
||||||
|
// which point the two blocks' <style> rules apply to (and override) each
|
||||||
|
// other, since they'd share one wrapper class. Impact is CSS-only --
|
||||||
|
// visual breakage, never script execution or data exposure -- the same
|
||||||
|
// trust tier as other accepted risks in this file (e.g. remote url() in
|
||||||
|
// style content, or the pre-existing DATA_URI_TAGS mimetype-blindness
|
||||||
|
// documented in HtmlBlock.security.test.ts). Not fixed here: closing it
|
||||||
|
// would mean either a wider hash (cheap, but every existing scope class
|
||||||
|
// set with THIS Task 25 code would silently reshuffle -- a similar
|
||||||
|
// "changing the hash function reshuffles stored HTML" cost the pinned
|
||||||
|
// hash test above already guards against happening BY ACCIDENT) or a
|
||||||
|
// collision-checked/salted scheme, either of which is a bigger design
|
||||||
|
// decision than a follow-up-review fix.
|
||||||
|
const scopeClass = `whp-html-${stableHash(rawCode)}`;
|
||||||
|
for (const el of nonEmpty) {
|
||||||
|
el.textContent = scopeCss(el.textContent || '', `.${scopeClass}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<div class="${scopeClass}">${container.innerHTML}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function purifyHtml(input: string): string {
|
export function purifyHtml(input: string): string {
|
||||||
// Hook is added immediately before sanitize() and removed immediately
|
// Hook is added immediately before sanitize() and removed immediately
|
||||||
// after, scoped tightly to this single call -- so it can never leak onto
|
// after, scoped tightly to this single call -- so it can never leak onto
|
||||||
@@ -56,22 +351,33 @@ export function purifyHtml(input: string): string {
|
|||||||
// multiple copies of the same hook.
|
// multiple copies of the same hook.
|
||||||
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
|
DOMPurify.addHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK);
|
||||||
try {
|
try {
|
||||||
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
|
const raw = input || '';
|
||||||
|
// See needsForceBody()/the FORCE_BODY comment above PURIFY_CONFIG:
|
||||||
|
// applied only when there's a real <style> tag to rescue, so every
|
||||||
|
// other input takes the exact pre-Task-25 sanitize() call, unchanged.
|
||||||
|
const config = needsForceBody(raw) ? { ...PURIFY_CONFIG, FORCE_BODY: true } : PURIFY_CONFIG;
|
||||||
|
const sanitized = DOMPurify.sanitize(raw, config as any) as unknown as string;
|
||||||
|
return scopeStyleBlocks(sanitized, raw);
|
||||||
} finally {
|
} finally {
|
||||||
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
|
DOMPurify.removeHook('afterSanitizeAttributes', IFRAME_SANDBOX_HOOK as any);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
|
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '' }) => {
|
||||||
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
|
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
|
||||||
const clean = useMemo(() => purifyHtml(code), [code]);
|
const clean = useMemo(() => purifyHtml(code), [code]);
|
||||||
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
|
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
|
||||||
|
// The `style` prop is deliberately NOT applied. `toHtml()` emits only the
|
||||||
|
// purified `code`, so anything styled here would show in the editor and
|
||||||
|
// vanish on the live page -- the exact bug this block was reported for.
|
||||||
|
// Wrapper styling belongs in the user's own markup (see the Edit HTML
|
||||||
|
// toolbar's colour control). `style` stays on the props interface so
|
||||||
|
// already-saved sites keep deserializing cleanly.
|
||||||
return React.createElement('div', {
|
return React.createElement('div', {
|
||||||
ref: setRef,
|
ref: setRef,
|
||||||
style: {
|
style: {
|
||||||
minHeight: '40px',
|
minHeight: '40px',
|
||||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||||
...style,
|
|
||||||
},
|
},
|
||||||
dangerouslySetInnerHTML: { __html: clean },
|
dangerouslySetInnerHTML: { __html: clean },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
<!-- ============================================================
|
||||||
|
HTML test fixture — everything below goes inside <body>
|
||||||
|
Unstyled on purpose. No external assets (SVG/data URIs only)
|
||||||
|
except the media/iframe block, which is intentionally broken
|
||||||
|
so you can see fallback behavior.
|
||||||
|
============================================================ -->
|
||||||
|
|
||||||
|
<a href="#main">Skip to content</a>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>HTML Test Fixture</h1>
|
||||||
|
<p><small>A wide sample of elements for rendering, sanitizing, and parsing tests.</small></p>
|
||||||
|
<nav aria-label="Primary">
|
||||||
|
<ul>
|
||||||
|
<li><a href="#text">Text</a></li>
|
||||||
|
<li><a href="#lists">Lists</a></li>
|
||||||
|
<li><a href="#tables">Tables</a></li>
|
||||||
|
<li><a href="#forms">Forms</a></li>
|
||||||
|
<li><a href="#media">Media</a></li>
|
||||||
|
<li><a href="#edge">Edge cases</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
<!-- ========== HEADINGS ========== -->
|
||||||
|
<section id="headings">
|
||||||
|
<h2>Headings</h2>
|
||||||
|
<h1>Heading level 1</h1>
|
||||||
|
<h2>Heading level 2</h2>
|
||||||
|
<h3>Heading level 3</h3>
|
||||||
|
<h4>Heading level 4</h4>
|
||||||
|
<h5>Heading level 5</h5>
|
||||||
|
<h6>Heading level 6</h6>
|
||||||
|
<hgroup>
|
||||||
|
<h2>Grouped heading</h2>
|
||||||
|
<p>Subtitle paragraph inside hgroup</p>
|
||||||
|
</hgroup>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== TEXT & INLINE ========== -->
|
||||||
|
<section id="text">
|
||||||
|
<h2>Text and inline elements</h2>
|
||||||
|
|
||||||
|
<p>A normal paragraph with a fair amount of text so you can check line height, wrapping, and measure. It runs long enough to break across several lines in most containers, which is the whole point of including it here at all.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>strong</strong>, <b>b</b>, <em>em</em>, <i>i</i>, <u>u</u>,
|
||||||
|
<s>s</s>, <del>del</del>, <ins>ins</ins>, <mark>mark</mark>,
|
||||||
|
<small>small</small>, H<sub>2</sub>O, x<sup>2</sup>,
|
||||||
|
<code>inline code</code>, <kbd>Ctrl</kbd>+<kbd>C</kbd>,
|
||||||
|
<samp>output text</samp>, <var>variable</var>,
|
||||||
|
<abbr title="HyperText Markup Language">HTML</abbr>,
|
||||||
|
<dfn>definition term</dfn>,
|
||||||
|
<time datetime="2026-08-09">August 9, 2026</time>,
|
||||||
|
<data value="42">forty-two</data>,
|
||||||
|
<q>short inline quote</q>,
|
||||||
|
<cite>Cited Work</cite>,
|
||||||
|
<bdi>إسم</bdi>,
|
||||||
|
<bdo dir="rtl">reversed direction</bdo>,
|
||||||
|
<ruby>漢<rt>kan</rt>字<rt>ji</rt></ruby>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Links:
|
||||||
|
<a href="#top">internal anchor</a> ·
|
||||||
|
<a href="https://example.com">absolute</a> ·
|
||||||
|
<a href="/relative/path">relative</a> ·
|
||||||
|
<a href="mailto:test@example.com">mailto</a> ·
|
||||||
|
<a href="tel:+15555550123">tel</a> ·
|
||||||
|
<a href="https://example.com" target="_blank" rel="noopener noreferrer">new tab</a> ·
|
||||||
|
<a href="#" download>download attr</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<blockquote cite="https://example.com/source">
|
||||||
|
<p>A block quotation. It contains its own paragraph and a nested quote so you can check indentation stacking.</p>
|
||||||
|
<blockquote><p>Nested block quotation.</p></blockquote>
|
||||||
|
<footer>— <cite>Someone, Somewhere</cite></footer>
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<pre><code>#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
for i in {1..3}; do
|
||||||
|
printf 'iteration %d\n' "$i"
|
||||||
|
done
|
||||||
|
|
||||||
|
# a deliberately long line to force horizontal overflow: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>Line break here,<br>after the break.</p>
|
||||||
|
<p>Word break opportunity: super<wbr>cali<wbr>fragilistic<wbr>expiali<wbr>docious</p>
|
||||||
|
|
||||||
|
<address>
|
||||||
|
Contact: <a href="mailto:admin@example.com">admin@example.com</a><br>
|
||||||
|
123 Nowhere St, Somewhere
|
||||||
|
</address>
|
||||||
|
|
||||||
|
<p>Entities: & < > " ' © ® ™ — … € 😀</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== LISTS ========== -->
|
||||||
|
<section id="lists">
|
||||||
|
<h2>Lists</h2>
|
||||||
|
|
||||||
|
<h3>Unordered, nested</h3>
|
||||||
|
<ul>
|
||||||
|
<li>First item</li>
|
||||||
|
<li>Second item
|
||||||
|
<ul>
|
||||||
|
<li>Nested item
|
||||||
|
<ul><li>Deeply nested item</li></ul>
|
||||||
|
</li>
|
||||||
|
<li>Another nested item</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Third item with a longer body of text so that it wraps onto more than one line and you can confirm the hanging indent behaves.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Ordered variants</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Default numbering</li>
|
||||||
|
<li>Second
|
||||||
|
<ol type="a"><li>Lower alpha</li><li>Second alpha</li></ol>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<ol start="5" reversed>
|
||||||
|
<li>Reversed, starting at 5</li>
|
||||||
|
<li>Next</li>
|
||||||
|
<li>Next</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Description list</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Term one</dt>
|
||||||
|
<dd>Definition of the first term.</dd>
|
||||||
|
<dt>Term two</dt>
|
||||||
|
<dt>Term two, alias</dt>
|
||||||
|
<dd>Definition covering both terms above.</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3>Menu</h3>
|
||||||
|
<menu>
|
||||||
|
<li><button type="button">Copy</button></li>
|
||||||
|
<li><button type="button">Paste</button></li>
|
||||||
|
</menu>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== TABLES ========== -->
|
||||||
|
<section id="tables">
|
||||||
|
<h2>Tables</h2>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<caption>Quarterly figures with spans and a footer</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col span="1">
|
||||||
|
<col span="2">
|
||||||
|
<col>
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Region</th>
|
||||||
|
<th scope="col">Q1</th>
|
||||||
|
<th scope="col">Q2</th>
|
||||||
|
<th scope="col">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">North</th>
|
||||||
|
<td>1,204</td>
|
||||||
|
<td>1,391</td>
|
||||||
|
<td rowspan="2">Shared note spanning two rows</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">South</th>
|
||||||
|
<td>988</td>
|
||||||
|
<td>1,022</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">East</th>
|
||||||
|
<td colspan="2">Merged across two quarters</td>
|
||||||
|
<td>—</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Total</th>
|
||||||
|
<td>2,192</td>
|
||||||
|
<td>2,413</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Wide table (horizontal overflow)</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>G</th><th>H</th><th>I</th><th>J</th><th>K</th><th>L</th></tr>
|
||||||
|
<tr><td>value-1</td><td>value-2</td><td>value-3</td><td>value-4</td><td>value-5</td><td>value-6</td><td>value-7</td><td>value-8</td><td>value-9</td><td>value-10</td><td>value-11</td><td>value-12</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== FORMS ========== -->
|
||||||
|
<section id="forms">
|
||||||
|
<h2>Forms</h2>
|
||||||
|
|
||||||
|
<form action="#" method="get">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Text inputs</legend>
|
||||||
|
<p><label for="f-text">Text</label> <input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled"></p>
|
||||||
|
<p><label for="f-search">Search</label> <input id="f-search" type="search" list="suggestions"></p>
|
||||||
|
<datalist id="suggestions">
|
||||||
|
<option value="alpha"></option>
|
||||||
|
<option value="beta"></option>
|
||||||
|
<option value="gamma"></option>
|
||||||
|
</datalist>
|
||||||
|
<p><label for="f-email">Email</label> <input id="f-email" type="email" required></p>
|
||||||
|
<p><label for="f-url">URL</label> <input id="f-url" type="url"></p>
|
||||||
|
<p><label for="f-tel">Tel</label> <input id="f-tel" type="tel" pattern="[0-9-+ ]+"></p>
|
||||||
|
<p><label for="f-pass">Password</label> <input id="f-pass" type="password" minlength="8"></p>
|
||||||
|
<p><label for="f-num">Number</label> <input id="f-num" type="number" min="0" max="100" step="5" value="25"></p>
|
||||||
|
<p><label for="f-area">Textarea</label><br><textarea id="f-area" rows="4" cols="40">Multiline
|
||||||
|
content
|
||||||
|
here</textarea></p>
|
||||||
|
<p><label for="f-ro">Readonly</label> <input id="f-ro" type="text" value="read only" readonly></p>
|
||||||
|
<p><label for="f-dis">Disabled</label> <input id="f-dis" type="text" value="disabled" disabled></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Date, time, color, range, file</legend>
|
||||||
|
<p><label for="f-date">Date</label> <input id="f-date" type="date" value="2026-08-09"></p>
|
||||||
|
<p><label for="f-time">Time</label> <input id="f-time" type="time" value="13:45"></p>
|
||||||
|
<p><label for="f-dtl">Datetime-local</label> <input id="f-dtl" type="datetime-local"></p>
|
||||||
|
<p><label for="f-month">Month</label> <input id="f-month" type="month"></p>
|
||||||
|
<p><label for="f-week">Week</label> <input id="f-week" type="week"></p>
|
||||||
|
<p><label for="f-color">Color</label> <input id="f-color" type="color" value="#336699"></p>
|
||||||
|
<p><label for="f-range">Range</label> <input id="f-range" type="range" min="0" max="10" value="7"></p>
|
||||||
|
<p><label for="f-file">File</label> <input id="f-file" type="file" multiple accept=".txt,.md"></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Choices</legend>
|
||||||
|
<p>
|
||||||
|
<label><input type="checkbox" name="c" value="1" checked> Checked</label>
|
||||||
|
<label><input type="checkbox" name="c" value="2"> Unchecked</label>
|
||||||
|
<label><input type="checkbox" name="c" value="3" disabled> Disabled</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><input type="radio" name="r" value="a" checked> Option A</label>
|
||||||
|
<label><input type="radio" name="r" value="b"> Option B</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="f-select">Select</label>
|
||||||
|
<select id="f-select" name="select">
|
||||||
|
<option value="">— choose —</option>
|
||||||
|
<optgroup label="Group one">
|
||||||
|
<option value="1" selected>One</option>
|
||||||
|
<option value="2">Two</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Group two" disabled>
|
||||||
|
<option value="3">Three</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="f-multi">Multi-select</label><br>
|
||||||
|
<select id="f-multi" multiple size="4">
|
||||||
|
<option>Red</option><option selected>Green</option><option>Blue</option><option>Violet</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Output and buttons</legend>
|
||||||
|
<p><label for="f-prog">Progress</label> <progress id="f-prog" value="0.6">60%</progress></p>
|
||||||
|
<p><label for="f-meter">Meter</label> <meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">72</meter></p>
|
||||||
|
<p><output name="result" for="f-num f-range">Computed output</output></p>
|
||||||
|
<p>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
<button type="reset">Reset</button>
|
||||||
|
<button type="button">Plain button</button>
|
||||||
|
<button type="button" disabled>Disabled button</button>
|
||||||
|
<input type="submit" value="Input submit">
|
||||||
|
<input type="button" value="Input button">
|
||||||
|
</p>
|
||||||
|
<input type="hidden" name="csrf" value="hidden-value">
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== MEDIA & EMBEDS ========== -->
|
||||||
|
<section id="media">
|
||||||
|
<h2>Media and embeds</h2>
|
||||||
|
|
||||||
|
<h3>Inline SVG</h3>
|
||||||
|
<svg width="180" height="90" viewBox="0 0 180 90" role="img" aria-label="Two shapes">
|
||||||
|
<rect x="5" y="5" width="80" height="80" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||||
|
<circle cx="135" cy="45" r="40" fill="none" stroke="currentColor" stroke-width="3"></circle>
|
||||||
|
<text x="45" y="50" text-anchor="middle" font-size="14" fill="currentColor">svg</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h3>Figure with data-URI image</h3>
|
||||||
|
<figure>
|
||||||
|
<img alt="Small red square"
|
||||||
|
width="64" height="64"
|
||||||
|
src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'64'%20height%3D'64'%3E%3Crect%20width%3D'64'%20height%3D'64'%20fill%3D'%23c0392b'%2F%3E%3C%2Fsvg%3E">
|
||||||
|
<figcaption>Figure caption describing the image above.</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h3>Broken image (alt-text fallback test)</h3>
|
||||||
|
<img src="does-not-exist.png" alt="This alt text should render because the source is missing" width="200" height="100">
|
||||||
|
|
||||||
|
<h3>Picture element</h3>
|
||||||
|
<picture>
|
||||||
|
<source media="(min-width: 800px)" srcset="wide.png">
|
||||||
|
<source media="(min-width: 400px)" srcset="medium.png">
|
||||||
|
<img src="narrow.png" alt="Responsive image fallback" width="150" height="80">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<h3>Video and audio (sources intentionally missing)</h3>
|
||||||
|
<video controls width="320" poster="poster.jpg">
|
||||||
|
<source src="clip.webm" type="video/webm">
|
||||||
|
<source src="clip.mp4" type="video/mp4">
|
||||||
|
<track kind="captions" src="captions.vtt" srclang="en" label="English">
|
||||||
|
Your browser does not support the video element.
|
||||||
|
</video>
|
||||||
|
<audio controls>
|
||||||
|
<source src="tone.ogg" type="audio/ogg">
|
||||||
|
<source src="tone.mp3" type="audio/mpeg">
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<h3>Canvas and iframe</h3>
|
||||||
|
<canvas width="200" height="60">Canvas fallback text</canvas>
|
||||||
|
<iframe title="Sandboxed iframe" src="about:blank" width="300" height="120" sandbox loading="lazy"></iframe>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== INTERACTIVE / SEMANTIC ========== -->
|
||||||
|
<section id="interactive">
|
||||||
|
<h2>Interactive and semantic containers</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Collapsed disclosure</summary>
|
||||||
|
<p>Hidden content revealed on toggle.</p>
|
||||||
|
</details>
|
||||||
|
<details open>
|
||||||
|
<summary>Open disclosure</summary>
|
||||||
|
<ul><li>With a list inside</li><li>Second item</li></ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<dialog id="test-dialog">
|
||||||
|
<p>Non-modal dialog content.</p>
|
||||||
|
<button type="button" onclick="this.closest('dialog').close()">Close</button>
|
||||||
|
</dialog>
|
||||||
|
<button type="button" onclick="document.getElementById('test-dialog').show()">Open dialog</button>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header><h3>Article header</h3></header>
|
||||||
|
<p>Article body content.</p>
|
||||||
|
<aside><p>An aside nested inside the article.</p></aside>
|
||||||
|
<footer><p>Article footer.</p></footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<p><span contenteditable="true">Editable inline region</span></p>
|
||||||
|
<p hidden>This paragraph has the hidden attribute and should not render.</p>
|
||||||
|
|
||||||
|
<template id="tpl">
|
||||||
|
<p>Template content — must not render until cloned.</p>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<!-- ========== EDGE CASES ========== -->
|
||||||
|
<section id="edge">
|
||||||
|
<h2>Edge cases</h2>
|
||||||
|
|
||||||
|
<p>Very long unbroken token (overflow test):</p>
|
||||||
|
<p>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
|
||||||
|
<p>Long URL: https://example.com/a/very/long/path/segment/that/keeps/going/and/going?query=1&another=2&third=3#fragment-identifier</p>
|
||||||
|
|
||||||
|
<p lang="ar" dir="rtl">هذا نص عربي لاختبار الاتجاه من اليمين إلى اليسار.</p>
|
||||||
|
<p lang="he" dir="rtl">זהו טקסט עברי לבדיקה.</p>
|
||||||
|
<p lang="ja">日本語のテキストです。改行と折り返しの確認用。</p>
|
||||||
|
<p lang="de">Straßenverkehrsordnung — Grüße aus München</p>
|
||||||
|
<p>Emoji & combining: 👋🏽 👨👩👧👦 🇺🇸 é vs é (precomposed vs combining)</p>
|
||||||
|
<p>Zero-width chars between letters: a​b​c</p>
|
||||||
|
|
||||||
|
<p>Escaped tag text: <script>alert(1)</script></p>
|
||||||
|
<p>Attribute with quotes: <span title='He said "hello"'>hover me</span></p>
|
||||||
|
|
||||||
|
<p>Empty elements follow:</p>
|
||||||
|
<div></div>
|
||||||
|
<p></p>
|
||||||
|
<ul></ul>
|
||||||
|
<table></table>
|
||||||
|
|
||||||
|
<p>Deep nesting:</p>
|
||||||
|
<div><div><div><div><div><div><div><p>Seven levels deep.</p></div></div></div></div></div></div></div>
|
||||||
|
|
||||||
|
<p>Inline element stress:
|
||||||
|
<strong><em><u><s><mark>all five at once</mark></s></u></em></strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="color: teal;">Inline style attribute (teal).</p>
|
||||||
|
<p class="custom-class another-class" data-test-id="edge-1" data-value="42">Element with classes and data attributes.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p><small>End of fixture — <time datetime="2026-08-09">2026-08-09</time></small></p>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
<a href="#main">Skip to content</a>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>HTML Test Fixture</h1>
|
||||||
|
<p><small>A wide sample of elements for rendering, sanitizing, and parsing tests.</small></p>
|
||||||
|
<nav aria-label="Primary">
|
||||||
|
<ul>
|
||||||
|
<li><a href="#text">Text</a></li>
|
||||||
|
<li><a href="#lists">Lists</a></li>
|
||||||
|
<li><a href="#tables">Tables</a></li>
|
||||||
|
<li><a href="#forms">Forms</a></li>
|
||||||
|
<li><a href="#media">Media</a></li>
|
||||||
|
<li><a href="#edge">Edge cases</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
|
||||||
|
|
||||||
|
<section id="headings">
|
||||||
|
<h2>Headings</h2>
|
||||||
|
<h1>Heading level 1</h1>
|
||||||
|
<h2>Heading level 2</h2>
|
||||||
|
<h3>Heading level 3</h3>
|
||||||
|
<h4>Heading level 4</h4>
|
||||||
|
<h5>Heading level 5</h5>
|
||||||
|
<h6>Heading level 6</h6>
|
||||||
|
<hgroup>
|
||||||
|
<h2>Grouped heading</h2>
|
||||||
|
<p>Subtitle paragraph inside hgroup</p>
|
||||||
|
</hgroup>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="text">
|
||||||
|
<h2>Text and inline elements</h2>
|
||||||
|
|
||||||
|
<p>A normal paragraph with a fair amount of text so you can check line height, wrapping, and measure. It runs long enough to break across several lines in most containers, which is the whole point of including it here at all.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<strong>strong</strong>, <b>b</b>, <em>em</em>, <i>i</i>, <u>u</u>,
|
||||||
|
<s>s</s>, <del>del</del>, <ins>ins</ins>, <mark>mark</mark>,
|
||||||
|
<small>small</small>, H<sub>2</sub>O, x<sup>2</sup>,
|
||||||
|
<code>inline code</code>, <kbd>Ctrl</kbd>+<kbd>C</kbd>,
|
||||||
|
<samp>output text</samp>, <var>variable</var>,
|
||||||
|
<abbr title="HyperText Markup Language">HTML</abbr>,
|
||||||
|
<dfn>definition term</dfn>,
|
||||||
|
<time datetime="2026-08-09">August 9, 2026</time>,
|
||||||
|
<data value="42">forty-two</data>,
|
||||||
|
<q>short inline quote</q>,
|
||||||
|
<cite>Cited Work</cite>,
|
||||||
|
<bdi>إسم</bdi>,
|
||||||
|
<bdo dir="rtl">reversed direction</bdo>,
|
||||||
|
<ruby>漢<rt>kan</rt>字<rt>ji</rt></ruby>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Links:
|
||||||
|
<a href="#top">internal anchor</a> ·
|
||||||
|
<a href="https://example.com">absolute</a> ·
|
||||||
|
<a href="/relative/path">relative</a> ·
|
||||||
|
<a href="mailto:test@example.com">mailto</a> ·
|
||||||
|
<a href="tel:+15555550123">tel</a> ·
|
||||||
|
<a href="https://example.com" target="_blank" rel="noopener noreferrer">new tab</a> ·
|
||||||
|
<a href="#" download="">download attr</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<blockquote cite="https://example.com/source">
|
||||||
|
<p>A block quotation. It contains its own paragraph and a nested quote so you can check indentation stacking.</p>
|
||||||
|
<blockquote><p>Nested block quotation.</p></blockquote>
|
||||||
|
<footer>— <cite>Someone, Somewhere</cite></footer>
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<pre><code>#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
for i in {1..3}; do
|
||||||
|
printf 'iteration %d\n' "$i"
|
||||||
|
done
|
||||||
|
|
||||||
|
# a deliberately long line to force horizontal overflow: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||||
|
</code></pre>
|
||||||
|
|
||||||
|
<p>Line break here,<br>after the break.</p>
|
||||||
|
<p>Word break opportunity: super<wbr>cali<wbr>fragilistic<wbr>expiali<wbr>docious</p>
|
||||||
|
|
||||||
|
<address>
|
||||||
|
Contact: <a href="mailto:admin@example.com">admin@example.com</a><br>
|
||||||
|
123 Nowhere St, Somewhere
|
||||||
|
</address>
|
||||||
|
|
||||||
|
<p>Entities: & < > " ' © ® ™ — … € 😀</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="lists">
|
||||||
|
<h2>Lists</h2>
|
||||||
|
|
||||||
|
<h3>Unordered, nested</h3>
|
||||||
|
<ul>
|
||||||
|
<li>First item</li>
|
||||||
|
<li>Second item
|
||||||
|
<ul>
|
||||||
|
<li>Nested item
|
||||||
|
<ul><li>Deeply nested item</li></ul>
|
||||||
|
</li>
|
||||||
|
<li>Another nested item</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Third item with a longer body of text so that it wraps onto more than one line and you can confirm the hanging indent behaves.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Ordered variants</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Default numbering</li>
|
||||||
|
<li>Second
|
||||||
|
<ol type="a"><li>Lower alpha</li><li>Second alpha</li></ol>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<ol start="5" reversed="">
|
||||||
|
<li>Reversed, starting at 5</li>
|
||||||
|
<li>Next</li>
|
||||||
|
<li>Next</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Description list</h3>
|
||||||
|
<dl>
|
||||||
|
<dt>Term one</dt>
|
||||||
|
<dd>Definition of the first term.</dd>
|
||||||
|
<dt>Term two</dt>
|
||||||
|
<dt>Term two, alias</dt>
|
||||||
|
<dd>Definition covering both terms above.</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h3>Menu</h3>
|
||||||
|
<menu>
|
||||||
|
<li><button type="button">Copy</button></li>
|
||||||
|
<li><button type="button">Paste</button></li>
|
||||||
|
</menu>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="tables">
|
||||||
|
<h2>Tables</h2>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<caption>Quarterly figures with spans and a footer</caption>
|
||||||
|
<colgroup>
|
||||||
|
<col span="1">
|
||||||
|
<col span="2">
|
||||||
|
<col>
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Region</th>
|
||||||
|
<th scope="col">Q1</th>
|
||||||
|
<th scope="col">Q2</th>
|
||||||
|
<th scope="col">Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">North</th>
|
||||||
|
<td>1,204</td>
|
||||||
|
<td>1,391</td>
|
||||||
|
<td rowspan="2">Shared note spanning two rows</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">South</th>
|
||||||
|
<td>988</td>
|
||||||
|
<td>1,022</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">East</th>
|
||||||
|
<td colspan="2">Merged across two quarters</td>
|
||||||
|
<td>—</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Total</th>
|
||||||
|
<td>2,192</td>
|
||||||
|
<td>2,413</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Wide table (horizontal overflow)</h3>
|
||||||
|
<table>
|
||||||
|
<tbody><tr><th>A</th><th>B</th><th>C</th><th>D</th><th>E</th><th>F</th><th>G</th><th>H</th><th>I</th><th>J</th><th>K</th><th>L</th></tr>
|
||||||
|
<tr><td>value-1</td><td>value-2</td><td>value-3</td><td>value-4</td><td>value-5</td><td>value-6</td><td>value-7</td><td>value-8</td><td>value-9</td><td>value-10</td><td>value-11</td><td>value-12</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Forms</h2>
|
||||||
|
|
||||||
|
<form action="#" method="get">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Text inputs</legend>
|
||||||
|
<p><label for="f-text">Text</label> <input id="f-text" name="text" type="text" placeholder="Placeholder" value="Prefilled"></p>
|
||||||
|
<p><label for="f-search">Search</label> <input id="f-search" type="search" list="suggestions"></p>
|
||||||
|
<datalist id="suggestions">
|
||||||
|
<option value="alpha"></option>
|
||||||
|
<option value="beta"></option>
|
||||||
|
<option value="gamma"></option>
|
||||||
|
</datalist>
|
||||||
|
<p><label for="f-email">Email</label> <input id="f-email" type="email" required=""></p>
|
||||||
|
<p><label for="f-url">URL</label> <input id="f-url" type="url"></p>
|
||||||
|
<p><label for="f-tel">Tel</label> <input id="f-tel" type="tel" pattern="[0-9-+ ]+"></p>
|
||||||
|
<p><label for="f-pass">Password</label> <input id="f-pass" type="password" minlength="8"></p>
|
||||||
|
<p><label for="f-num">Number</label> <input id="f-num" type="number" min="0" max="100" step="5" value="25"></p>
|
||||||
|
<p><label for="f-area">Textarea</label><br><textarea id="f-area" rows="4" cols="40">Multiline
|
||||||
|
content
|
||||||
|
here</textarea></p>
|
||||||
|
<p><label for="f-ro">Readonly</label> <input id="f-ro" type="text" value="read only" readonly=""></p>
|
||||||
|
<p><label for="f-dis">Disabled</label> <input id="f-dis" type="text" value="disabled" disabled=""></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Date, time, color, range, file</legend>
|
||||||
|
<p><label for="f-date">Date</label> <input id="f-date" type="date" value="2026-08-09"></p>
|
||||||
|
<p><label for="f-time">Time</label> <input id="f-time" type="time" value="13:45"></p>
|
||||||
|
<p><label for="f-dtl">Datetime-local</label> <input id="f-dtl" type="datetime-local"></p>
|
||||||
|
<p><label for="f-month">Month</label> <input id="f-month" type="month"></p>
|
||||||
|
<p><label for="f-week">Week</label> <input id="f-week" type="week"></p>
|
||||||
|
<p><label for="f-color">Color</label> <input id="f-color" type="color" value="#336699"></p>
|
||||||
|
<p><label for="f-range">Range</label> <input id="f-range" type="range" min="0" max="10" value="7"></p>
|
||||||
|
<p><label for="f-file">File</label> <input id="f-file" type="file" multiple="" accept=".txt,.md"></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Choices</legend>
|
||||||
|
<p>
|
||||||
|
<label><input type="checkbox" name="c" value="1" checked=""> Checked</label>
|
||||||
|
<label><input type="checkbox" name="c" value="2"> Unchecked</label>
|
||||||
|
<label><input type="checkbox" name="c" value="3" disabled=""> Disabled</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label><input type="radio" name="r" value="a" checked=""> Option A</label>
|
||||||
|
<label><input type="radio" name="r" value="b"> Option B</label>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="f-select">Select</label>
|
||||||
|
<select id="f-select" name="select">
|
||||||
|
<option value="">— choose —</option>
|
||||||
|
<optgroup label="Group one">
|
||||||
|
<option value="1" selected="">One</option>
|
||||||
|
<option value="2">Two</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Group two" disabled="">
|
||||||
|
<option value="3">Three</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<label for="f-multi">Multi-select</label><br>
|
||||||
|
<select id="f-multi" multiple="" size="4">
|
||||||
|
<option>Red</option><option selected="">Green</option><option>Blue</option><option>Violet</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Output and buttons</legend>
|
||||||
|
<p><label for="f-prog">Progress</label> <progress id="f-prog" value="0.6">60%</progress></p>
|
||||||
|
<p><label for="f-meter">Meter</label> <meter id="f-meter" min="0" max="100" low="30" high="80" optimum="90" value="72">72</meter></p>
|
||||||
|
<p><output name="result" for="f-num f-range">Computed output</output></p>
|
||||||
|
<p>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
<button type="reset">Reset</button>
|
||||||
|
<button type="button">Plain button</button>
|
||||||
|
<button type="button" disabled="">Disabled button</button>
|
||||||
|
<input type="submit" value="Input submit">
|
||||||
|
<input type="button" value="Input button">
|
||||||
|
</p>
|
||||||
|
<input type="hidden" name="csrf" value="hidden-value">
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="media">
|
||||||
|
<h2>Media and embeds</h2>
|
||||||
|
|
||||||
|
<h3>Inline SVG</h3>
|
||||||
|
<svg width="180" height="90" viewBox="0 0 180 90" role="img" aria-label="Two shapes">
|
||||||
|
<rect x="5" y="5" width="80" height="80" fill="none" stroke="currentColor" stroke-width="3"></rect>
|
||||||
|
<circle cx="135" cy="45" r="40" fill="none" stroke="currentColor" stroke-width="3"></circle>
|
||||||
|
<text x="45" y="50" text-anchor="middle" font-size="14" fill="currentColor">svg</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h3>Figure with data-URI image</h3>
|
||||||
|
<figure>
|
||||||
|
<img alt="Small red square" width="64" height="64" src="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20width%3D'64'%20height%3D'64'%3E%3Crect%20width%3D'64'%20height%3D'64'%20fill%3D'%23c0392b'%2F%3E%3C%2Fsvg%3E">
|
||||||
|
<figcaption>Figure caption describing the image above.</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h3>Broken image (alt-text fallback test)</h3>
|
||||||
|
<img src="does-not-exist.png" alt="This alt text should render because the source is missing" width="200" height="100">
|
||||||
|
|
||||||
|
<h3>Picture element</h3>
|
||||||
|
<picture>
|
||||||
|
<source media="(min-width: 800px)" srcset="wide.png">
|
||||||
|
<source media="(min-width: 400px)" srcset="medium.png">
|
||||||
|
<img src="narrow.png" alt="Responsive image fallback" width="150" height="80">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<h3>Video and audio (sources intentionally missing)</h3>
|
||||||
|
<video controls="" width="320" poster="poster.jpg">
|
||||||
|
<source src="clip.webm" type="video/webm">
|
||||||
|
<source src="clip.mp4" type="video/mp4">
|
||||||
|
<track kind="captions" src="captions.vtt" srclang="en" label="English">
|
||||||
|
Your browser does not support the video element.
|
||||||
|
</video>
|
||||||
|
<audio controls="">
|
||||||
|
<source src="tone.ogg" type="audio/ogg">
|
||||||
|
<source src="tone.mp3" type="audio/mpeg">
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<h3>Canvas and iframe</h3>
|
||||||
|
<canvas width="200" height="60">Canvas fallback text</canvas>
|
||||||
|
<iframe title="Sandboxed iframe" width="300" height="120" sandbox="allow-scripts allow-same-origin allow-popups allow-forms" loading="lazy" referrerpolicy="no-referrer"></iframe>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="interactive">
|
||||||
|
<h2>Interactive and semantic containers</h2>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Collapsed disclosure</summary>
|
||||||
|
<p>Hidden content revealed on toggle.</p>
|
||||||
|
</details>
|
||||||
|
<details open="">
|
||||||
|
<summary>Open disclosure</summary>
|
||||||
|
<ul><li>With a list inside</li><li>Second item</li></ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
<p>Non-modal dialog content.</p>
|
||||||
|
<button type="button">Close</button>
|
||||||
|
|
||||||
|
<button type="button">Open dialog</button>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header><h3>Article header</h3></header>
|
||||||
|
<p>Article body content.</p>
|
||||||
|
<aside><p>An aside nested inside the article.</p></aside>
|
||||||
|
<footer><p>Article footer.</p></footer>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<p><span contenteditable="true">Editable inline region</span></p>
|
||||||
|
<p hidden="">This paragraph has the hidden attribute and should not render.</p>
|
||||||
|
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="edge">
|
||||||
|
<h2>Edge cases</h2>
|
||||||
|
|
||||||
|
<p>Very long unbroken token (overflow test):</p>
|
||||||
|
<p>aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</p>
|
||||||
|
<p>Long URL: https://example.com/a/very/long/path/segment/that/keeps/going/and/going?query=1&another=2&third=3#fragment-identifier</p>
|
||||||
|
|
||||||
|
<p lang="ar" dir="rtl">هذا نص عربي لاختبار الاتجاه من اليمين إلى اليسار.</p>
|
||||||
|
<p lang="he" dir="rtl">זהו טקסט עברי לבדיקה.</p>
|
||||||
|
<p lang="ja">日本語のテキストです。改行と折り返しの確認用。</p>
|
||||||
|
<p lang="de">Straßenverkehrsordnung — Grüße aus München</p>
|
||||||
|
<p>Emoji & combining: 👋🏽 👨👩👧👦 🇺🇸 é vs é (precomposed vs combining)</p>
|
||||||
|
<p>Zero-width chars between letters: abc</p>
|
||||||
|
|
||||||
|
<p>Escaped tag text: <script>alert(1)</script></p>
|
||||||
|
<p>Attribute with quotes: <span title="He said "hello"">hover me</span></p>
|
||||||
|
|
||||||
|
<p>Empty elements follow:</p>
|
||||||
|
<div></div>
|
||||||
|
<p></p>
|
||||||
|
<ul></ul>
|
||||||
|
<table></table>
|
||||||
|
|
||||||
|
<p>Deep nesting:</p>
|
||||||
|
<div><div><div><div><div><div><div><p>Seven levels deep.</p></div></div></div></div></div></div></div>
|
||||||
|
|
||||||
|
<p>Inline element stress:
|
||||||
|
<strong><em><u><s><mark>all five at once</mark></s></u></em></strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="color: teal;">Inline style attribute (teal).</p>
|
||||||
|
<p class="custom-class another-class" data-test-id="edge-1" data-value="42">Element with classes and data attributes.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p><small>End of fixture — <time datetime="2026-08-09">2026-08-09</time></small></p>
|
||||||
|
</footer>
|
||||||
@@ -196,3 +196,93 @@ describe('ContactForm.craft.props includes animation/visibility defaults', () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ---------- Webhook destination (Task 10) ----------
|
||||||
|
This file builds props inline rather than spreading a shared object, so
|
||||||
|
`defaultProps` is introduced here for the destination cases only; every
|
||||||
|
pre-existing test above is untouched. */
|
||||||
|
const defaultProps = { fields: [] as any[], formAction: '#' };
|
||||||
|
|
||||||
|
describe('ContactForm.craft.props includes the destination defaults', () => {
|
||||||
|
// The trap this pins: FormStylePanel renders each destination control behind
|
||||||
|
// `nodeProps.X !== undefined`, so a prop omitted from these defaults yields an
|
||||||
|
// invisible control and the whole feature looks like it does nothing.
|
||||||
|
test('destinationType/webhookUrl/webhookSecretId/webhookAuthMode are all present', () => {
|
||||||
|
expect(ContactForm.craft!.props).toMatchObject({
|
||||||
|
destinationType: 'email',
|
||||||
|
webhookUrl: '',
|
||||||
|
webhookSecretId: '',
|
||||||
|
webhookAuthMode: 'signature',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no craft prop holds a raw secret -- only an id', () => {
|
||||||
|
const keys = Object.keys(ContactForm.craft!.props as object);
|
||||||
|
expect(keys).toContain('webhookSecretId');
|
||||||
|
expect(keys.filter((k) => /secret/i.test(k))).toEqual(['webhookSecretId']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ContactForm.toHtml destination marker', () => {
|
||||||
|
test('email destination emits the legacy marker unchanged', () => {
|
||||||
|
const out = toHtml(
|
||||||
|
{ ...defaultProps, destinationType: 'email', recipientEmail: 'a@example.com', thankYouUrl: '' }, '');
|
||||||
|
expect(out.html).toContain('<!--WHP-FORM');
|
||||||
|
expect(out.html).toContain('recipient="a@example.com"');
|
||||||
|
expect(out.html).not.toContain('type="webhook"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('BYTE-IDENTITY: an email destination emits exactly what a pre-feature form emits', () => {
|
||||||
|
// The guarantee every already-published site depends on: a marker with no
|
||||||
|
// `type` still provisions an email endpoint, so its bytes must not drift by
|
||||||
|
// so much as a space.
|
||||||
|
//
|
||||||
|
// FROZEN LITERAL, not a self-comparison. Comparing two head-revision outputs
|
||||||
|
// to each other only catches a drift that affects ONE of them -- a uniform
|
||||||
|
// change passes it. This string was captured from `071f3447` (the revision
|
||||||
|
// deployed to production before this feature) and is the actual reference:
|
||||||
|
// if it has to be edited, every already-published site's forms have changed
|
||||||
|
// shape and that is the thing to stop, not the test.
|
||||||
|
const FROZEN_LEGACY_MARKER =
|
||||||
|
'<!--WHP-FORM id="F_3hodg" recipient="a@example.com" thankyou="/thx"-->';
|
||||||
|
|
||||||
|
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||||
|
expect(legacy.html.startsWith(`${FROZEN_LEGACY_MARKER}<form `)).toBe(true);
|
||||||
|
|
||||||
|
// ...and the new props, set to their defaults, change nothing about it.
|
||||||
|
const explicit = toHtml(
|
||||||
|
{ ...defaultProps, destinationType: 'email', webhookUrl: '', webhookSecretId: '',
|
||||||
|
webhookAuthMode: 'signature', recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||||
|
expect(explicit.html).toBe(legacy.html);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('webhook destination emits type, url, secret id and auth mode', () => {
|
||||||
|
const out = toHtml(
|
||||||
|
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
||||||
|
webhookSecretId: 'sec-1', webhookAuthMode: 'bearer', recipientEmail: 'fb@example.com' }, '');
|
||||||
|
expect(out.html).toContain('type="webhook"');
|
||||||
|
expect(out.html).toContain('url="https://hooks.example.com/x"');
|
||||||
|
expect(out.html).toContain('secret="sec-1"');
|
||||||
|
expect(out.html).toContain('authmode="bearer"');
|
||||||
|
expect(out.html).toContain('recipient="fb@example.com"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a raw secret value is never emitted, only its id', () => {
|
||||||
|
const out = toHtml(
|
||||||
|
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
||||||
|
webhookSecretId: 'sec-1', webhookSecret: 'SUPERSECRET' } as any, '');
|
||||||
|
// Non-vacuous: the marker IS emitted (so there is something that could have
|
||||||
|
// carried the secret) and carries the id, but not the value.
|
||||||
|
expect(out.html).toContain('secret="sec-1"');
|
||||||
|
expect(out.html).not.toContain('SUPERSECRET');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a webhook form with no fallback email still emits a marker (never a bare formAction)', () => {
|
||||||
|
const out = toHtml(
|
||||||
|
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' }, '');
|
||||||
|
expect(out.html).toContain('type="webhook"');
|
||||||
|
expect(out.html).toContain('recipient=""');
|
||||||
|
expect(out.html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
||||||
|
expect(out.html).toContain('name="_gotcha"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ interface ContactFormProps {
|
|||||||
inputBorder?: string;
|
inputBorder?: string;
|
||||||
recipientEmail?: string;
|
recipientEmail?: string;
|
||||||
thankYouUrl?: string;
|
thankYouUrl?: string;
|
||||||
|
/* ---- Submission destination (see utils/form-relay-wiring.ts) ----
|
||||||
|
'email' (default) reproduces the legacy marker exactly. 'webhook' widens it
|
||||||
|
with the url / secret id / auth mode below.
|
||||||
|
There is deliberately NO raw-secret prop: craft props are serialised into
|
||||||
|
the saved project and into published output, so the secret is POSTed to
|
||||||
|
/api/form-webhook-secret.php and only the returned opaque id is kept. */
|
||||||
|
destinationType?: 'email' | 'webhook';
|
||||||
|
webhookUrl?: string;
|
||||||
|
webhookSecretId?: string;
|
||||||
|
webhookAuthMode?: 'signature' | 'bearer';
|
||||||
animation?: string;
|
animation?: string;
|
||||||
animationDelay?: string;
|
animationDelay?: string;
|
||||||
hideOnDesktop?: boolean;
|
hideOnDesktop?: boolean;
|
||||||
@@ -171,6 +181,12 @@ ContactForm.craft = {
|
|||||||
inputBorder: '#d1d5db',
|
inputBorder: '#d1d5db',
|
||||||
recipientEmail: '',
|
recipientEmail: '',
|
||||||
thankYouUrl: '',
|
thankYouUrl: '',
|
||||||
|
// Present (not omitted) so FormStylePanel's `nodeProps.X !== undefined`
|
||||||
|
// gates actually render the destination controls.
|
||||||
|
destinationType: 'email',
|
||||||
|
webhookUrl: '',
|
||||||
|
webhookSecretId: '',
|
||||||
|
webhookAuthMode: 'signature',
|
||||||
animation: '',
|
animation: '',
|
||||||
animationDelay: '',
|
animationDelay: '',
|
||||||
hideOnDesktop: false,
|
hideOnDesktop: false,
|
||||||
@@ -233,7 +249,17 @@ ContactForm.craft = {
|
|||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
});
|
});
|
||||||
|
|
||||||
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction, nodeId);
|
// Only the webhook SECRET ID travels here -- there is no prop holding the raw
|
||||||
|
// secret, by construction (see ContactFormProps).
|
||||||
|
const { marker, actionAttr, honeypot } = relayFormWiring(
|
||||||
|
props.recipientEmail, props.thankYouUrl, props.formAction, nodeId,
|
||||||
|
{
|
||||||
|
type: props.destinationType,
|
||||||
|
url: props.webhookUrl,
|
||||||
|
secretId: props.webhookSecretId,
|
||||||
|
authMode: props.webhookAuthMode,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// The form-sender relay delivers success via a full-page 303 redirect
|
// The form-sender relay delivers success via a full-page 303 redirect
|
||||||
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
|
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useContextMenu } from '../hooks/useContextMenu';
|
|||||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||||
import { useIsMobile } from '../hooks/useIsMobile';
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
||||||
|
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
|
||||||
import { DeviceMode } from '../types';
|
import { DeviceMode } from '../types';
|
||||||
|
|
||||||
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
||||||
@@ -83,6 +84,7 @@ export const EditorShell: React.FC = () => {
|
|||||||
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
|
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
|
||||||
// identically to before (same booleans, just sourced from context).
|
// identically to before (same booleans, just sourced from context).
|
||||||
<MobileChromeProvider>
|
<MobileChromeProvider>
|
||||||
|
<LayerFocusProvider>
|
||||||
<div className="editor-app">
|
<div className="editor-app">
|
||||||
<TopBar
|
<TopBar
|
||||||
device={device}
|
device={device}
|
||||||
@@ -107,6 +109,7 @@ export const EditorShell: React.FC = () => {
|
|||||||
onClose={hideMenu}
|
onClose={hideMenu}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</LayerFocusProvider>
|
||||||
</MobileChromeProvider>
|
</MobileChromeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,29 +1,95 @@
|
|||||||
import { describe, test, expect, afterEach } from 'vitest';
|
import { describe, test, expect, afterEach } from 'vitest';
|
||||||
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
import type { NodeTree } from '@craftjs/core';
|
||||||
|
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||||
|
|
||||||
|
function makeTree(rootId: string, props: Record<string, unknown> = {}): NodeTree {
|
||||||
|
return {
|
||||||
|
rootNodeId: rootId,
|
||||||
|
nodes: {
|
||||||
|
[rootId]: {
|
||||||
|
id: rootId,
|
||||||
|
data: {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
name: 'Container',
|
||||||
|
displayName: 'Container',
|
||||||
|
props,
|
||||||
|
custom: {},
|
||||||
|
isCanvas: false,
|
||||||
|
parent: 'wherever-it-originally-lived',
|
||||||
|
nodes: [],
|
||||||
|
linkedNodes: {},
|
||||||
|
hidden: false,
|
||||||
|
},
|
||||||
|
info: {},
|
||||||
|
events: { selected: false, dragged: false, hovered: false },
|
||||||
|
dom: null,
|
||||||
|
related: {},
|
||||||
|
rules: {},
|
||||||
|
_hydrationTimestamp: 0,
|
||||||
|
} as unknown as NodeTree['nodes'][string],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('clipboard', () => {
|
describe('clipboard', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setClipboardNodeId(null);
|
setClipboardTree(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('starts empty', () => {
|
test('starts empty', () => {
|
||||||
expect(getClipboardNodeId()).toBeNull();
|
expect(getClipboardTree()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('set then get returns the stored node id', () => {
|
test('set then get returns a tree with the same root id and shape', () => {
|
||||||
setClipboardNodeId('node-123');
|
const tree = makeTree('node-123', { text: 'hello' });
|
||||||
expect(getClipboardNodeId()).toBe('node-123');
|
setClipboardTree(tree);
|
||||||
|
const got = getClipboardTree();
|
||||||
|
expect(got).not.toBeNull();
|
||||||
|
expect(got!.rootNodeId).toBe('node-123');
|
||||||
|
expect(got!.nodes['node-123'].data.props).toEqual({ text: 'hello' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('is a shared module-level store -- overwriting replaces the previous value', () => {
|
test('is a shared module-level store -- overwriting replaces the previous value', () => {
|
||||||
setClipboardNodeId('first');
|
setClipboardTree(makeTree('first'));
|
||||||
setClipboardNodeId('second');
|
setClipboardTree(makeTree('second'));
|
||||||
expect(getClipboardNodeId()).toBe('second');
|
expect(getClipboardTree()!.rootNodeId).toBe('second');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can be cleared back to null', () => {
|
test('can be cleared back to null', () => {
|
||||||
setClipboardNodeId('node-123');
|
setClipboardTree(makeTree('node-123'));
|
||||||
setClipboardNodeId(null);
|
setClipboardTree(null);
|
||||||
expect(getClipboardNodeId()).toBeNull();
|
expect(getClipboardTree()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep-clones on set: mutating the original tree after set does not affect the stored snapshot', () => {
|
||||||
|
const original = makeTree('node-123', { text: 'original' });
|
||||||
|
setClipboardTree(original);
|
||||||
|
|
||||||
|
// Mutate the original tree's props object directly (as if the source
|
||||||
|
// node were edited, or the same live node got copied again).
|
||||||
|
(original.nodes['node-123'].data.props as Record<string, unknown>).text = 'mutated';
|
||||||
|
|
||||||
|
expect(getClipboardTree()!.nodes['node-123'].data.props).toEqual({ text: 'original' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deep-clones nested props (arrays/objects), not just the top-level props object', () => {
|
||||||
|
const original = makeTree('node-123', { links: [{ url: 'https://example.com' }] });
|
||||||
|
setClipboardTree(original);
|
||||||
|
|
||||||
|
(original.nodes['node-123'].data.props as any).links[0].url = 'https://mutated.example.com';
|
||||||
|
|
||||||
|
expect((getClipboardTree()!.nodes['node-123'].data.props as any).links[0].url).toBe(
|
||||||
|
'https://example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('survives the original tree object being discarded entirely (detached copy, not a live reference)', () => {
|
||||||
|
let tree: NodeTree | null = makeTree('node-abc', { text: 'snapshot' });
|
||||||
|
setClipboardTree(tree);
|
||||||
|
tree = null; // simulate the original page's node/tree going away entirely
|
||||||
|
|
||||||
|
const got = getClipboardTree();
|
||||||
|
expect(got).not.toBeNull();
|
||||||
|
expect(got!.nodes['node-abc'].data.props).toEqual({ text: 'snapshot' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Node, NodeId, NodeTree } from '@craftjs/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tiny shared clipboard for canvas node copy/paste.
|
* Tiny shared clipboard for canvas node copy/paste.
|
||||||
*
|
*
|
||||||
@@ -9,15 +11,71 @@
|
|||||||
* Deliberately not React state -- nothing in the UI needs to re-render
|
* Deliberately not React state -- nothing in the UI needs to re-render
|
||||||
* reactively when the clipboard changes; consumers just read the current
|
* reactively when the clipboard changes; consumers just read the current
|
||||||
* value at the moment they need it (on paste, or when a menu opens).
|
* value at the moment they need it (on paste, or when a menu opens).
|
||||||
|
*
|
||||||
|
* Historical bug (cross-page copy/paste): this used to store only the copied
|
||||||
|
* node's bare id (`clipboardNodeId`) and re-resolve it via `query.node(id)`
|
||||||
|
* at paste time. That works fine same-page, but the moment the user switches
|
||||||
|
* pages the canvas is re-deserialized to the target page's Craft.js state --
|
||||||
|
* the copied id no longer exists in `query` at all -- so a cross-page paste
|
||||||
|
* silently no-op'd (or threw, caught, and swallowed). Storing a detached
|
||||||
|
* TREE SNAPSHOT at copy time instead means paste never needs to look the
|
||||||
|
* source id up again: it just hands the snapshot to `regenerateTreeIds` +
|
||||||
|
* `actions.addNodeTree`, which works identically regardless of which page's
|
||||||
|
* state is currently loaded on the canvas.
|
||||||
*/
|
*/
|
||||||
let clipboardNodeId: string | null = null;
|
let clipboardTree: NodeTree | null = null;
|
||||||
|
|
||||||
/** Returns the id of the node currently on the clipboard, or null if empty. */
|
/**
|
||||||
export function getClipboardNodeId(): string | null {
|
* Deep, detached clone of a live Craft.js `NodeTree` (as returned by
|
||||||
return clipboardNodeId;
|
* `query.node(id).toNodeTree()`).
|
||||||
|
*
|
||||||
|
* Not a plain `structuredClone(tree)`: for a REAL (live) Craft.js node,
|
||||||
|
* `data.type` is the actual component function/class reference (not a
|
||||||
|
* serializable `{resolvedName}` wrapper) -- `structuredClone` cannot clone a
|
||||||
|
* function and throws `DataCloneError` (see the identical note on
|
||||||
|
* `regenerateTreeIds` in `utils/craft-tree.ts`, which hit this exact bug
|
||||||
|
* historically). `type` is a stable reference shared by every node of that
|
||||||
|
* component across the whole app (it doesn't change per page), so it's safe
|
||||||
|
* to keep by reference -- only the mutable per-node data (`props`, `custom`,
|
||||||
|
* `nodes`, `linkedNodes`) needs an actual deep copy so a later mutation (a
|
||||||
|
* subsequent paste's `setProp`, or a fresh copy of the same live node)
|
||||||
|
* can never reach back into this stored snapshot.
|
||||||
|
*/
|
||||||
|
function cloneNodeTree(tree: NodeTree): NodeTree {
|
||||||
|
const nodes: Record<NodeId, Node> = {};
|
||||||
|
for (const [id, node] of Object.entries(tree.nodes)) {
|
||||||
|
nodes[id] = {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
props: structuredClone(node.data.props),
|
||||||
|
custom: structuredClone(node.data.custom),
|
||||||
|
nodes: [...(node.data.nodes || [])],
|
||||||
|
linkedNodes: { ...(node.data.linkedNodes || {}) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { rootNodeId: tree.rootNodeId, nodes };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sets (or clears, with `null`) the node id on the clipboard. */
|
/**
|
||||||
export function setClipboardNodeId(nodeId: string | null): void {
|
* Returns the tree snapshot currently on the clipboard, or null if empty.
|
||||||
clipboardNodeId = nodeId;
|
* The returned tree is safe to hand straight to `regenerateTreeIds` --
|
||||||
|
* `regenerateTreeIds` never mutates its input, so repeated pastes of the
|
||||||
|
* same clipboard contents (including across a page switch) all work off the
|
||||||
|
* same untouched snapshot.
|
||||||
|
*/
|
||||||
|
export function getClipboardTree(): NodeTree | null {
|
||||||
|
return clipboardTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets (or clears, with `null`) the tree snapshot on the clipboard. The tree
|
||||||
|
* is deep-cloned before being stored (see `cloneNodeTree`) so it is fully
|
||||||
|
* detached from the live Craft.js node it was captured from -- it survives
|
||||||
|
* that node being deleted, mutated, or (the whole point) the canvas being
|
||||||
|
* re-deserialized to a different page entirely.
|
||||||
|
*/
|
||||||
|
export function setClipboardTree(tree: NodeTree | null): void {
|
||||||
|
clipboardTree = tree ? cloneNodeTree(tree) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createRoot, Root } from 'react-dom/client';
|
|||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import type { NodeTree, Node } from '@craftjs/core';
|
import type { NodeTree, Node } from '@craftjs/core';
|
||||||
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
|
||||||
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through
|
* Regression coverage: Ctrl/Cmd+V must run the copied subtree through
|
||||||
@@ -15,6 +15,13 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
|||||||
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing
|
* ROOT-fallback targeting, the empty-clipboard no-op, and the existing
|
||||||
* input-focus guard.
|
* input-focus guard.
|
||||||
*
|
*
|
||||||
|
* Also covers the cross-page clipboard fix: copy stores a detached TREE
|
||||||
|
* SNAPSHOT (`setClipboardTree`), not a bare node id -- so paste never needs
|
||||||
|
* to re-resolve the original node via `query.node(id)`, which is exactly
|
||||||
|
* what breaks once the canvas has been re-deserialized to a different page
|
||||||
|
* (see `hooks/clipboard.ts` and the cross-page integration test in
|
||||||
|
* `test-utils/integration/duplicate-paste.integration.test.tsx`).
|
||||||
|
*
|
||||||
* Mock pattern mirrors PageContext.pure-updaters.test.tsx /
|
* Mock pattern mirrors PageContext.pure-updaters.test.tsx /
|
||||||
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
|
* PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`,
|
||||||
* mounted via a bare consumer component, with REAL `keydown` events
|
* mounted via a bare consumer component, with REAL `keydown` events
|
||||||
@@ -27,6 +34,7 @@ import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const addNodeTreeMock = vi.fn();
|
const addNodeTreeMock = vi.fn();
|
||||||
|
const selectNodeMock = vi.fn();
|
||||||
let selectedIds: string[] = [];
|
let selectedIds: string[] = [];
|
||||||
|
|
||||||
function makeNode(id: string, parent: string | null, children: string[] = []): Node {
|
function makeNode(id: string, parent: string | null, children: string[] = []): Node {
|
||||||
@@ -62,9 +70,10 @@ const COPIED_TREE: NodeTree = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const nodeStore: Record<string, { data: { parent: string | null } }> = {
|
const nodeStore: Record<string, { data: { parent: string | null; nodes?: string[] } }> = {
|
||||||
'selected-1': { data: { parent: 'parent-container-1' } },
|
'selected-1': { data: { parent: 'parent-container-1' } },
|
||||||
ROOT: { data: { parent: null } },
|
'parent-container-1': { data: { parent: null, nodes: ['other-sibling', 'selected-1'] } },
|
||||||
|
ROOT: { data: { parent: null, nodes: [] } },
|
||||||
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
|
'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,6 +97,7 @@ vi.mock('@craftjs/core', () => ({
|
|||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
addNodeTree: addNodeTreeMock,
|
addNodeTree: addNodeTreeMock,
|
||||||
|
selectNode: selectNodeMock,
|
||||||
history: { undo: vi.fn(), redo: vi.fn() },
|
history: { undo: vi.fn(), redo: vi.fn() },
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
clearEvents: vi.fn(),
|
clearEvents: vi.fn(),
|
||||||
@@ -145,44 +155,51 @@ const Consumer: React.FC = () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
selectedIds = [];
|
selectedIds = [];
|
||||||
addNodeTreeMock.mockClear();
|
addNodeTreeMock.mockClear();
|
||||||
|
selectNodeMock.mockClear();
|
||||||
regenerateTreeIdsMock.mockClear();
|
regenerateTreeIdsMock.mockClear();
|
||||||
setClipboardNodeId(null);
|
setClipboardTree(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setClipboardNodeId(null);
|
setClipboardTree(null);
|
||||||
if (root) unmount();
|
if (root) unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||||
test('Ctrl+C copies the selected node id to the clipboard', () => {
|
test('Ctrl+C copies the selected node`s subtree (a tree snapshot, not a bare id) to the clipboard', () => {
|
||||||
render(<Consumer />);
|
render(<Consumer />);
|
||||||
selectedIds = ['selected-1'];
|
selectedIds = ['copied-root-1'];
|
||||||
|
|
||||||
pressKey('c');
|
pressKey('c');
|
||||||
|
|
||||||
expect(getClipboardNodeId()).toBe('selected-1');
|
const clip = getClipboardTree();
|
||||||
|
expect(clip).not.toBeNull();
|
||||||
|
expect(clip!.rootNodeId).toBe('copied-root-1');
|
||||||
|
expect(Object.keys(clip!.nodes).sort()).toEqual(['copied-child-1', 'copied-root-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Ctrl+V pastes as a sibling of the selection (selected node`s data.parent) with FRESH ids', () => {
|
test('Ctrl+V pastes as a sibling of the selection, immediately after it, with FRESH ids', () => {
|
||||||
render(<Consumer />);
|
render(<Consumer />);
|
||||||
|
|
||||||
selectedIds = ['copied-root-1'];
|
selectedIds = ['copied-root-1'];
|
||||||
pressKey('c');
|
pressKey('c');
|
||||||
expect(getClipboardNodeId()).toBe('copied-root-1');
|
expect(getClipboardTree()!.rootNodeId).toBe('copied-root-1');
|
||||||
|
|
||||||
selectedIds = ['selected-1'];
|
selectedIds = ['selected-1'];
|
||||||
pressKey('v');
|
pressKey('v');
|
||||||
|
|
||||||
// regenerateTreeIds actually ran before the tree was handed to Craft.js.
|
// regenerateTreeIds actually ran before the tree was handed to Craft.js.
|
||||||
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
|
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
|
||||||
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(COPIED_TREE);
|
expect(regenerateTreeIdsMock).toHaveBeenCalledWith(getClipboardTree());
|
||||||
|
|
||||||
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||||
const [pastedTree, targetParent] = addNodeTreeMock.mock.calls[0];
|
const [pastedTree, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||||
|
|
||||||
// Sibling of the current selection: selected-1's data.parent.
|
// Sibling of the current selection: selected-1's data.parent.
|
||||||
expect(targetParent).toBe('parent-container-1');
|
expect(targetParent).toBe('parent-container-1');
|
||||||
|
// Immediately after selected-1 (index 1 among parent-container-1's
|
||||||
|
// children), matching duplicate()'s "insert right after" UX.
|
||||||
|
expect(insertIndex).toBe(2);
|
||||||
|
|
||||||
// The regression this guards: pasted ids must be fresh, never reuse the
|
// The regression this guards: pasted ids must be fresh, never reuse the
|
||||||
// ids the copied node already occupies in the live Craft.js tree.
|
// ids the copied node already occupies in the live Craft.js tree.
|
||||||
@@ -193,9 +210,12 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
|||||||
for (const id of pastedIds) {
|
for (const id of pastedIds) {
|
||||||
expect(originalIds.has(id)).toBe(false);
|
expect(originalIds.has(id)).toBe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The new copy is selected, same as duplicate()'s existing UX.
|
||||||
|
expect(selectNodeMock).toHaveBeenCalledWith(pastedTree.rootNodeId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Ctrl+V with selection at ROOT falls back to ROOT as the insertion parent', () => {
|
test('Ctrl+V with selection at ROOT falls back to appending into ROOT', () => {
|
||||||
render(<Consumer />);
|
render(<Consumer />);
|
||||||
|
|
||||||
selectedIds = ['copied-root-1'];
|
selectedIds = ['copied-root-1'];
|
||||||
@@ -205,8 +225,24 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
|||||||
pressKey('v');
|
pressKey('v');
|
||||||
|
|
||||||
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||||
const [, targetParent] = addNodeTreeMock.mock.calls[0];
|
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||||
expect(targetParent).toBe('ROOT');
|
expect(targetParent).toBe('ROOT');
|
||||||
|
expect(insertIndex).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ctrl+V with nothing selected falls back to appending into ROOT', () => {
|
||||||
|
render(<Consumer />);
|
||||||
|
|
||||||
|
selectedIds = ['copied-root-1'];
|
||||||
|
pressKey('c');
|
||||||
|
|
||||||
|
selectedIds = [];
|
||||||
|
pressKey('v');
|
||||||
|
|
||||||
|
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0];
|
||||||
|
expect(targetParent).toBe('ROOT');
|
||||||
|
expect(insertIndex).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Ctrl+V with an empty clipboard is a no-op', () => {
|
test('Ctrl+V with an empty clipboard is a no-op', () => {
|
||||||
@@ -229,9 +265,9 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
|||||||
selectedIds = ['selected-1'];
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
pressKey('c');
|
pressKey('c');
|
||||||
expect(getClipboardNodeId()).toBeNull();
|
expect(getClipboardTree()).toBeNull();
|
||||||
|
|
||||||
setClipboardNodeId('copied-root-1');
|
setClipboardTree(COPIED_TREE);
|
||||||
pressKey('v');
|
pressKey('v');
|
||||||
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
expect(addNodeTreeMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
@@ -252,7 +288,7 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
|||||||
selectedIds = ['selected-1'];
|
selectedIds = ['selected-1'];
|
||||||
|
|
||||||
pressKey('c');
|
pressKey('c');
|
||||||
expect(getClipboardNodeId()).toBeNull();
|
expect(getClipboardTree()).toBeNull();
|
||||||
|
|
||||||
activeElementSpy.mockRestore();
|
activeElementSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { findDeletableTarget } from '../utils/craft-helpers';
|
import { findDeletableTarget } from '../utils/craft-helpers';
|
||||||
import { regenerateTreeIds } from '../utils/craft-tree';
|
import { regenerateTreeIds } from '../utils/craft-tree';
|
||||||
import { getClipboardNodeId, setClipboardNodeId } from './clipboard';
|
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||||
|
|
||||||
function isInputFocused(): boolean {
|
function isInputFocused(): boolean {
|
||||||
const el = document.activeElement;
|
const el = document.activeElement;
|
||||||
@@ -86,13 +86,16 @@ export function useKeyboardShortcuts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+C: copy selected node id to the shared clipboard
|
// Ctrl+C: copy the selected node's subtree (a detached snapshot, not
|
||||||
|
// just its id -- see clipboard.ts for why: an id-based clipboard can't
|
||||||
|
// survive a page switch, since the copied id no longer exists in
|
||||||
|
// `query` once the canvas is re-deserialized to a different page).
|
||||||
if (ctrl && (e.key === 'c' || e.key === 'C')) {
|
if (ctrl && (e.key === 'c' || e.key === 'C')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const selected = query.getEvent('selected').all();
|
const selected = query.getEvent('selected').all();
|
||||||
if (selected.length > 0 && selected[0] !== 'ROOT') {
|
if (selected.length > 0 && selected[0] !== 'ROOT') {
|
||||||
setClipboardNodeId(selected[0]);
|
setClipboardTree(query.node(selected[0]).toNodeTree());
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Copy failed:', err);
|
console.error('Copy failed:', err);
|
||||||
@@ -100,25 +103,44 @@ export function useKeyboardShortcuts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+V: paste the clipboard node as a sibling of the current selection
|
// Ctrl+V: paste the clipboard tree as a sibling of the current
|
||||||
|
// selection (immediately after it, matching duplicate()'s UX), or
|
||||||
|
// append to ROOT when nothing is selected. Works regardless of which
|
||||||
|
// page is currently on the canvas -- the clipboard tree is a detached
|
||||||
|
// snapshot, not a reference to a node that may no longer exist here.
|
||||||
if (ctrl && (e.key === 'v' || e.key === 'V')) {
|
if (ctrl && (e.key === 'v' || e.key === 'V')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const sourceId = getClipboardNodeId();
|
const clip = getClipboardTree();
|
||||||
if (!sourceId || !query.node(sourceId).get()) return;
|
if (!clip) return;
|
||||||
|
|
||||||
const selected = query.getEvent('selected').all();
|
const selected = query.getEvent('selected').all();
|
||||||
if (selected.length === 0) return;
|
const selectedId = selected.length > 0 ? selected[0] : null;
|
||||||
const selectedId = selected[0];
|
|
||||||
|
|
||||||
let targetParent = 'ROOT';
|
let targetParentId = 'ROOT';
|
||||||
if (selectedId !== 'ROOT') {
|
let insertIndex: number | undefined;
|
||||||
|
if (selectedId && selectedId !== 'ROOT') {
|
||||||
const node = query.node(selectedId).get();
|
const node = query.node(selectedId).get();
|
||||||
targetParent = node?.data?.parent || 'ROOT';
|
const parentId: string | null | undefined = node?.data?.parent;
|
||||||
|
if (parentId) {
|
||||||
|
targetParentId = parentId;
|
||||||
|
try {
|
||||||
|
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
|
||||||
|
const idx = siblings.indexOf(selectedId);
|
||||||
|
if (idx !== -1) insertIndex = idx + 1;
|
||||||
|
} catch {
|
||||||
|
// Leave insertIndex undefined -- addNodeTree appends when omitted.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
|
const tree = regenerateTreeIds(clip);
|
||||||
actions.addNodeTree(tree, targetParent);
|
if (insertIndex !== undefined) {
|
||||||
|
actions.addNodeTree(tree, targetParentId, insertIndex);
|
||||||
|
} else {
|
||||||
|
actions.addNodeTree(tree, targetParentId);
|
||||||
|
}
|
||||||
|
actions.selectNode(tree.rootNodeId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Paste failed:', err);
|
console.error('Paste failed:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||||
|
import { EMPTY_CANVAS, PageProvider, usePages } from '../state/PageContext';
|
||||||
|
import { SiteDesignProvider } from '../state/SiteDesignContext';
|
||||||
|
import { useWhpApi } from './useWhpApi';
|
||||||
|
import { WhpConfig } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 13b: `PageContext.loadState` runs every stored craft state through
|
||||||
|
* `repairOrphanNodes` before handing it to `actions.deserialize` -- that
|
||||||
|
* covers every page SWITCH (see `PageContext.orphan-repair-wiring.test.tsx`,
|
||||||
|
* the model for this file). But the INITIAL load -- `useWhpApi`'s `load()`,
|
||||||
|
* fired once on mount by `TopBar.tsx` -- used to call
|
||||||
|
* `actions.deserialize(state)` directly on the first page's stored
|
||||||
|
* `craftState`, bypassing repair entirely. A site whose saved state
|
||||||
|
* contains a node unreachable from ROOT would get it silently repaired on
|
||||||
|
* the NEXT page switch but not on the load that actually renders it first.
|
||||||
|
*
|
||||||
|
* This mocks `@craftjs/core` the same way `useWhpApi.load.test.tsx` and
|
||||||
|
* `PageContext.orphan-repair-wiring.test.tsx` do, and asserts on the exact
|
||||||
|
* string handed to the mocked `actions.deserialize` -- the orphan must be
|
||||||
|
* reattached to ROOT and a `console.error` must fire, exactly like the
|
||||||
|
* page-switch path (I5 review: this was `console.warn`, which
|
||||||
|
* console-buffer.ts -- feeding the in-builder issue reporter -- does not
|
||||||
|
* capture).
|
||||||
|
*/
|
||||||
|
const deserializeMock = vi.fn();
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({
|
||||||
|
query: { serialize: () => '{}' },
|
||||||
|
actions: { deserialize: deserializeMock },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const whpConfig: WhpConfig = {
|
||||||
|
user: 'testuser',
|
||||||
|
apiUrl: '/panel/api/site-builder',
|
||||||
|
csrfToken: 'tok',
|
||||||
|
siteId: 42,
|
||||||
|
siteDomain: 'example.com',
|
||||||
|
siteName: 'Test Site',
|
||||||
|
backUrl: '/panel/sites',
|
||||||
|
isRoot: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
interface Captured {
|
||||||
|
load: ReturnType<typeof useWhpApi>['load'];
|
||||||
|
pages: ReturnType<typeof usePages>['pages'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(): { get: () => Captured } {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
let captured: Captured | null = null;
|
||||||
|
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
const { load } = useWhpApi();
|
||||||
|
const { pages } = usePages();
|
||||||
|
captured = { load, pages };
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<EditorConfigProvider config={whpConfig}>
|
||||||
|
<SiteDesignProvider>
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>
|
||||||
|
</SiteDesignProvider>
|
||||||
|
</EditorConfigProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { get: () => captured! };
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same shape as `PageContext.orphan-repair-wiring.test.tsx`'s ORPHAN_STATE:
|
||||||
|
* a ROOT with no children plus an orphan ('stray') whose `parent` points at
|
||||||
|
* an id that doesn't exist in the tree, and which no node's `nodes`/
|
||||||
|
* `linkedNodes` lists -- unreachable by BFS from ROOT. */
|
||||||
|
const ORPHAN_STATE = JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
isCanvas: true,
|
||||||
|
props: { style: {}, tag: 'div' },
|
||||||
|
displayName: 'Container',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null,
|
||||||
|
},
|
||||||
|
stray: {
|
||||||
|
type: { resolvedName: 'HtmlBlock' },
|
||||||
|
isCanvas: false,
|
||||||
|
props: { code: '<p>stranded</p>', style: {} },
|
||||||
|
displayName: 'HTML',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useWhpApi load() repairs an orphaned node on the FIRST page before deserializing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
deserializeMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initial load with an orphaned first-page craftState reattaches it and warns', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
project: {
|
||||||
|
design: null,
|
||||||
|
header_craft_state: null,
|
||||||
|
footer_craft_state: null,
|
||||||
|
pages_craft_state: [
|
||||||
|
{ id: 'home', name: 'Home', slug: 'index', craftState: ORPHAN_STATE },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const harness = render();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await harness.get().load();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deserializeMock).toHaveBeenCalled();
|
||||||
|
const passedState = deserializeMock.mock.calls[deserializeMock.mock.calls.length - 1][0];
|
||||||
|
const parsed = JSON.parse(passedState);
|
||||||
|
// The orphan is now an ordinary, reachable child of ROOT.
|
||||||
|
expect(parsed.ROOT.nodes).toContain('stray');
|
||||||
|
expect(parsed.stray.parent).toBe('ROOT');
|
||||||
|
|
||||||
|
// The observable signal that repair actually ran, not just that the
|
||||||
|
// orphan happened to be absent for some unrelated reason. (React's own
|
||||||
|
// act()-environment warnings also go through console.error in this
|
||||||
|
// harness, so search all calls rather than assuming index 0.)
|
||||||
|
expect(errorSpy.mock.calls.some((call) => String(call[0]).includes('reattached'))).toBe(true);
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I5 (review): `PageContext.loadState`'s deserialize failure path logs via
|
||||||
|
* `console.error` AND falls back to `EMPTY_CANVAS` so the user always ends
|
||||||
|
* up with a working (if blank) editor. `useWhpApi.load()`'s equivalent path
|
||||||
|
* used to be `console.warn` with NO fallback deserialize -- the initial
|
||||||
|
* load, which decides whether the user sees a working editor at all, both
|
||||||
|
* failed harder (silently leaving the Frame undeserialized) and reported
|
||||||
|
* quieter than every subsequent page switch. This pins the aligned
|
||||||
|
* behaviour.
|
||||||
|
*/
|
||||||
|
describe('useWhpApi load() falls back to EMPTY_CANVAS when the first page state cannot be deserialized', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
deserializeMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deserialize failure on the first page logs console.error and retries with EMPTY_CANVAS', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
project: {
|
||||||
|
design: null,
|
||||||
|
header_craft_state: null,
|
||||||
|
footer_craft_state: null,
|
||||||
|
pages_craft_state: [
|
||||||
|
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{"broken":true}}' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
deserializeMock.mockImplementationOnce(() => {
|
||||||
|
throw new Error('malformed state');
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const harness = render();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await harness.get().load();
|
||||||
|
});
|
||||||
|
|
||||||
|
// First call (the broken state) threw; the second call is the fallback.
|
||||||
|
expect(deserializeMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(deserializeMock.mock.calls[1][0]).toBe(EMPTY_CANVAS);
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith('Failed to load page state:', expect.any(Error));
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||||
import { usePages } from '../state/PageContext';
|
import { usePages, EMPTY_CANVAS } from '../state/PageContext';
|
||||||
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
|
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
|
||||||
import { exportBodyHtml } from '../utils/html-export';
|
import { exportBodyHtml } from '../utils/html-export';
|
||||||
|
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||||
import { PageData } from '../types';
|
import { PageData } from '../types';
|
||||||
|
|
||||||
export interface BuildSavePayloadInput {
|
export interface BuildSavePayloadInput {
|
||||||
@@ -305,15 +306,42 @@ export function useWhpApi() {
|
|||||||
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo,
|
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo,
|
||||||
})));
|
})));
|
||||||
|
|
||||||
// Load the first page (home) into the canvas
|
// Load the first page (home) into the canvas. Routed through
|
||||||
|
// `repairOrphanNodes` first -- same as `PageContext.loadState` does
|
||||||
|
// for every subsequent page switch -- so a node that's unreachable
|
||||||
|
// from ROOT (invisible to Layers/selection) gets reattached here
|
||||||
|
// too, on the load that actually puts it on screen, rather than only
|
||||||
|
// on the next page switch. `repairOrphanNodes` never throws and
|
||||||
|
// returns the original string reference when nothing needed fixing,
|
||||||
|
// so this is cheap to run unconditionally.
|
||||||
const firstPage = proj.pages_craft_state[0];
|
const firstPage = proj.pages_craft_state[0];
|
||||||
if (firstPage.craftState) {
|
if (firstPage.craftState) {
|
||||||
try {
|
try {
|
||||||
const state = typeof firstPage.craftState === 'string'
|
const rawState = typeof firstPage.craftState === 'string'
|
||||||
? firstPage.craftState : JSON.stringify(firstPage.craftState);
|
? firstPage.craftState : JSON.stringify(firstPage.craftState);
|
||||||
|
const { state, repaired } = repairOrphanNodes(rawState);
|
||||||
|
if (repaired.length > 0) {
|
||||||
|
// I5: console-buffer.ts only patches console.error, and this
|
||||||
|
// reattach signal is the single most diagnostic clue for the
|
||||||
|
// still-unreproduced "elements drop off the canvas" report --
|
||||||
|
// it must reach the in-builder issue reporter's console buffer.
|
||||||
|
console.error(
|
||||||
|
`[site-builder] reattached ${repaired.length} unreachable node(s) to the page root:`,
|
||||||
|
repaired.join(', '),
|
||||||
|
);
|
||||||
|
}
|
||||||
actions.deserialize(state);
|
actions.deserialize(state);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to load page state:', e);
|
// I5: this is the initial load that decides whether the user
|
||||||
|
// sees a working editor at all -- align with `loadState`'s
|
||||||
|
// behaviour (console.error + a known-safe fallback) instead of
|
||||||
|
// warning quietly and leaving the Frame on whatever it last had.
|
||||||
|
console.error('Failed to load page state:', e);
|
||||||
|
try {
|
||||||
|
actions.deserialize(EMPTY_CANVAS);
|
||||||
|
} catch (_e2) {
|
||||||
|
// give up
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,24 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { WhpConfig } from './types';
|
import { WhpConfig } from './types';
|
||||||
|
import { installConsoleErrorBuffer } from './utils/console-buffer';
|
||||||
|
import { editorBuild } from './utils/build-stamp';
|
||||||
import './styles/editor.css';
|
import './styles/editor.css';
|
||||||
|
|
||||||
|
// Installed before React mounts so errors thrown during the first render are
|
||||||
|
// captured too.
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
|
||||||
|
// Exposed on window (not logged -- no need to print this on every load for
|
||||||
|
// every customer) so support can ask someone to type __WHP_EDITOR_BUILD__
|
||||||
|
// in the console on request. This call is also load-bearing for bundling:
|
||||||
|
// it is currently the only reference to `editorBuild()`, which keeps the
|
||||||
|
// __EDITOR_BUILD__-reading module from being tree-shaken out of the bundle
|
||||||
|
// before Task 19 wires it into the report payload. Do not remove this line
|
||||||
|
// as a "stray global" cleanup -- doing so silently reverts every future
|
||||||
|
// bug report to showing 'dev' instead of a real build stamp.
|
||||||
|
(window as any).__WHP_EDITOR_BUILD__ = editorBuild();
|
||||||
|
|
||||||
// Read WHP_CONFIG injected by PHP wrapper (or null for standalone dev)
|
// Read WHP_CONFIG injected by PHP wrapper (or null for standalone dev)
|
||||||
const whpConfig: WhpConfig | null = (window as any).WHP_CONFIG || null;
|
const whpConfig: WhpConfig | null = (window as any).WHP_CONFIG || null;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useEditor } from '@craftjs/core';
|
|||||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||||
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
||||||
import { regenerateTreeIds } from '../../utils/craft-tree';
|
import { regenerateTreeIds } from '../../utils/craft-tree';
|
||||||
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
|
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
|
||||||
import { useNodeActions } from '../../hooks/useNodeActions';
|
import { useNodeActions } from '../../hooks/useNodeActions';
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
@@ -79,35 +79,54 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
const copyNode = useCallback(() => {
|
const copyNode = useCallback(() => {
|
||||||
if (!nodeId || nodeId === 'ROOT') return;
|
if (!nodeId || nodeId === 'ROOT') return;
|
||||||
try {
|
try {
|
||||||
setClipboardNodeId(nodeId);
|
// Store a detached subtree snapshot, not just the id -- an id-based
|
||||||
|
// clipboard can't survive a page switch (the copied id no longer
|
||||||
|
// exists in `query` once the canvas is re-deserialized to a different
|
||||||
|
// page's Craft.js state). See clipboard.ts.
|
||||||
|
setClipboardTree(query.node(nodeId).toNodeTree());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Copy failed:', e);
|
console.error('Copy failed:', e);
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
}, [nodeId, onClose]);
|
}, [nodeId, query, onClose]);
|
||||||
|
|
||||||
const pasteNode = useCallback(() => {
|
const pasteNode = useCallback(() => {
|
||||||
const sourceId = getClipboardNodeId();
|
const clip = getClipboardTree();
|
||||||
if (!sourceId) {
|
if (!clip) {
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!query.node(sourceId).get()) {
|
// Paste as a SIBLING of the right-clicked node (immediately after it,
|
||||||
onClose();
|
// matching duplicate()'s UX), not as its child -- using the clicked
|
||||||
return;
|
// node itself as the parent throws when it's a leaf. Falls back to
|
||||||
}
|
// appending into ROOT when nothing valid was right-clicked. This works
|
||||||
|
// regardless of which page is on the canvas -- the clipboard tree is a
|
||||||
// Paste as a SIBLING of the right-clicked node, not as its child --
|
// detached snapshot, not a reference to a node that may not exist here.
|
||||||
// using the clicked node itself as the parent throws when it's a leaf.
|
|
||||||
let targetParent = 'ROOT';
|
let targetParent = 'ROOT';
|
||||||
|
let insertIndex: number | undefined;
|
||||||
if (nodeId && nodeId !== 'ROOT') {
|
if (nodeId && nodeId !== 'ROOT') {
|
||||||
const clickedNode = query.node(nodeId).get();
|
const clickedNode = query.node(nodeId).get();
|
||||||
targetParent = clickedNode?.data?.parent || 'ROOT';
|
const parentId: string | null | undefined = clickedNode?.data?.parent;
|
||||||
|
if (parentId) {
|
||||||
|
targetParent = parentId;
|
||||||
|
try {
|
||||||
|
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
|
||||||
|
const idx = siblings.indexOf(nodeId);
|
||||||
|
if (idx !== -1) insertIndex = idx + 1;
|
||||||
|
} catch {
|
||||||
|
// Leave insertIndex undefined -- addNodeTree appends when omitted.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
|
const tree = regenerateTreeIds(clip);
|
||||||
|
if (insertIndex !== undefined) {
|
||||||
|
actions.addNodeTree(tree, targetParent, insertIndex);
|
||||||
|
} else {
|
||||||
actions.addNodeTree(tree, targetParent);
|
actions.addNodeTree(tree, targetParent);
|
||||||
|
}
|
||||||
|
actions.selectNode(tree.rootNodeId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Paste failed:', e);
|
console.error('Paste failed:', e);
|
||||||
}
|
}
|
||||||
@@ -176,7 +195,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
icon: 'clipboard',
|
icon: 'clipboard',
|
||||||
shortcut: 'Ctrl+V',
|
shortcut: 'Ctrl+V',
|
||||||
action: pasteNode,
|
action: pasteNode,
|
||||||
disabled: !getClipboardNodeId(),
|
disabled: !getClipboardTree(),
|
||||||
dividerAfter: true,
|
dividerAfter: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { LayerFocusProvider, useLayerFocus } from './LayerFocusContext';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
const Probe: React.FC = () => {
|
||||||
|
const { focus, requestFocus } = useLayerFocus();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => requestFocus('n1', 'features', 2)}>request</button>
|
||||||
|
<span data-testid="state">{focus ? `${focus.nodeId}:${focus.prop}:${focus.index}:${focus.nonce}` : 'none'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(<LayerFocusProvider><Probe /></LayerFocusProvider>);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LayerFocusContext', () => {
|
||||||
|
test('starts with no focus request', () => {
|
||||||
|
render();
|
||||||
|
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requestFocus publishes the target', () => {
|
||||||
|
render();
|
||||||
|
act(() => { (container.querySelector('button') as HTMLButtonElement).click(); });
|
||||||
|
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeating the same request bumps the nonce so consumers re-fire', () => {
|
||||||
|
render();
|
||||||
|
const btn = container.querySelector('button') as HTMLButtonElement;
|
||||||
|
act(() => { btn.click(); });
|
||||||
|
act(() => { btn.click(); });
|
||||||
|
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('useLayerFocus outside a provider is inert rather than a crash', () => {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
expect(() => {
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(<Probe />);
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carries "the user clicked a virtual Layers row -- scroll that array item's
|
||||||
|
* card into view" from the Layers panel (left) to the array editors (right).
|
||||||
|
*
|
||||||
|
* Deliberately a request, not a command: consumers that don't implement
|
||||||
|
* scrolling simply ignore it. Selecting the parent node always happens in
|
||||||
|
* LayersPanel itself, so a click is useful even with no consumer at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LayerFocusRequest {
|
||||||
|
nodeId: string;
|
||||||
|
prop: string;
|
||||||
|
index: number;
|
||||||
|
/** Bumped on every request so an identical repeat still re-fires effects. */
|
||||||
|
nonce: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayerFocusValue {
|
||||||
|
focus: LayerFocusRequest | null;
|
||||||
|
requestFocus(nodeId: string, prop: string, index: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayerFocusContext = createContext<LayerFocusValue>({
|
||||||
|
focus: null,
|
||||||
|
requestFocus: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useLayerFocus = (): LayerFocusValue => useContext(LayerFocusContext);
|
||||||
|
|
||||||
|
export const LayerFocusProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [focus, setFocus] = useState<LayerFocusRequest | null>(null);
|
||||||
|
const nonceRef = useRef(0);
|
||||||
|
|
||||||
|
const requestFocus = useCallback((nodeId: string, prop: string, index: number) => {
|
||||||
|
nonceRef.current += 1;
|
||||||
|
setFocus({ nodeId, prop, index, nonce: nonceRef.current });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ focus, requestFocus }), [focus, requestFocus]);
|
||||||
|
|
||||||
|
return <LayerFocusContext.Provider value={value}>{children}</LayerFocusContext.Provider>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderEditorHarness } from '../../test-utils/editorHarness';
|
||||||
|
import { LayersPanel } from './LayersPanel';
|
||||||
|
import { LayerFocusProvider } from './LayerFocusContext';
|
||||||
|
|
||||||
|
function stateWith(extra: Record<string, any>, rootChildren: string[]) {
|
||||||
|
return JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' }, isCanvas: true,
|
||||||
|
props: { style: {}, tag: 'div' }, displayName: 'Container',
|
||||||
|
custom: {}, hidden: false, nodes: rootChildren, linkedNodes: {}, parent: null,
|
||||||
|
},
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const featuresNode = {
|
||||||
|
type: { resolvedName: 'FeaturesGrid' }, isCanvas: false,
|
||||||
|
props: { features: [{ title: 'Fast' }, { title: 'Secure' }] },
|
||||||
|
displayName: 'Features Grid',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LayersPanel', () => {
|
||||||
|
test('shows virtual rows for a Features Grid array prop', () => {
|
||||||
|
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
|
||||||
|
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||||
|
|
||||||
|
// Scoped to `.layer-virtual-row` rather than the whole container:
|
||||||
|
// `FeaturesGrid` itself renders `feat.title` on the live canvas (which
|
||||||
|
// is also mounted inside `harness.container`, alongside the panel), so
|
||||||
|
// asserting on `container.textContent` alone would pass even if
|
||||||
|
// LayersPanel never rendered a single virtual row -- the "Fast"/"Secure"
|
||||||
|
// text would already be there from the canvas. Scoping to the virtual
|
||||||
|
// row elements themselves makes the assertion actually exercise
|
||||||
|
// LayersPanel's own rendering.
|
||||||
|
const virtualRows = harness.container.querySelectorAll('.layer-virtual-row');
|
||||||
|
expect(virtualRows).toHaveLength(2);
|
||||||
|
const virtualText = Array.from(virtualRows).map((el) => el.textContent).join(' | ');
|
||||||
|
expect(virtualText).toContain('Fast');
|
||||||
|
expect(virtualText).toContain('Secure');
|
||||||
|
|
||||||
|
// The real node row for the parent is still shown too.
|
||||||
|
const nodeRows = harness.container.querySelectorAll('.layer-node-row');
|
||||||
|
const nodeText = Array.from(nodeRows).map((el) => el.textContent).join(' | ');
|
||||||
|
expect(nodeText).toContain('Features Grid');
|
||||||
|
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('virtual rows are not rendered for a component with no registry entry', () => {
|
||||||
|
const heading = {
|
||||||
|
type: { resolvedName: 'Heading' }, isCanvas: false,
|
||||||
|
props: { text: 'Title', level: 2 }, displayName: 'Heading',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||||
|
};
|
||||||
|
const harness = renderEditorHarness({ initialState: stateWith({ h1: heading }, ['h1']) });
|
||||||
|
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||||
|
expect(harness.container.querySelectorAll('.layer-virtual-row')).toHaveLength(0);
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Unplaced group does not render for a healthy tree', () => {
|
||||||
|
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
|
||||||
|
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||||
|
expect(harness.container.textContent).not.toContain('Unplaced');
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Unplaced group appears and lists a node unreachable from ROOT', () => {
|
||||||
|
// 'stray' is a real node in the deserialized state, but no node's
|
||||||
|
// `nodes`/`linkedNodes` list references it -- exactly the "dropped
|
||||||
|
// outside the page" scenario findUnreachableNodeIds exists to catch.
|
||||||
|
const heading = {
|
||||||
|
type: { resolvedName: 'Heading' }, isCanvas: false,
|
||||||
|
props: { text: 'Title', level: 2 }, displayName: 'Heading',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||||
|
};
|
||||||
|
const stray = {
|
||||||
|
type: { resolvedName: 'HtmlBlock' }, isCanvas: false,
|
||||||
|
props: { code: '<p>stranded</p>', style: {} }, displayName: 'HTML',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
|
||||||
|
};
|
||||||
|
const harness = renderEditorHarness({
|
||||||
|
initialState: stateWith({ h1: heading, stray }, ['h1']),
|
||||||
|
});
|
||||||
|
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||||
|
|
||||||
|
expect(harness.container.textContent).toContain('Unplaced');
|
||||||
|
expect(harness.container.textContent).toContain('Unplaced (1)');
|
||||||
|
|
||||||
|
// The orphan itself is rendered as a selectable/deletable LayerNode row
|
||||||
|
// (displayName 'HTML') underneath the Unplaced heading, not silently
|
||||||
|
// dropped from the tree.
|
||||||
|
const nodeRows = harness.container.querySelectorAll('.layer-node-row');
|
||||||
|
const nodeText = Array.from(nodeRows).map((el) => el.textContent).join(' | ');
|
||||||
|
expect(nodeText).toContain('HTML');
|
||||||
|
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { clickableProps } from '../../utils/a11y';
|
import { clickableProps } from '../../utils/a11y';
|
||||||
|
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
|
||||||
|
import { useLayerFocus } from './LayerFocusContext';
|
||||||
|
import { findUnreachableNodeIds } from '../../utils/orphan-repair';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-type icon lookup keyed by the component's `craft.displayName` (the
|
* Per-type icon lookup keyed by the component's `craft.displayName` (the
|
||||||
@@ -62,12 +65,51 @@ const TYPE_ICONS: Record<string, string> = {
|
|||||||
const DEFAULT_ICON = 'fa-cube';
|
const DEFAULT_ICON = 'fa-cube';
|
||||||
const ROOT_ICON = 'fa-desktop';
|
const ROOT_ICON = 'fa-desktop';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A display-only row for one item of a composite's array prop (a Features
|
||||||
|
* Grid feature, a Tabs tab, ...). Not a Craft node: it can't be dragged or
|
||||||
|
* deleted. Clicking it selects the PARENT and asks the array editor to
|
||||||
|
* scroll that item's card into view.
|
||||||
|
*/
|
||||||
|
const VirtualRowNode: React.FC<{
|
||||||
|
parentId: string;
|
||||||
|
prop: string;
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
depth: number;
|
||||||
|
onActivate: () => void;
|
||||||
|
}> = ({ index, label, depth, onActivate }) => (
|
||||||
|
<div
|
||||||
|
{...clickableProps(onActivate)}
|
||||||
|
className="layer-virtual-row"
|
||||||
|
title={label}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '4px 8px',
|
||||||
|
paddingLeft: `${8 + depth * 16}px`,
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--color-text-dim)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ marginRight: 6, fontSize: 8, flexShrink: 0 }} aria-hidden="true">▪</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||||
|
<span style={{ marginLeft: 'auto', paddingLeft: 6, opacity: 0.6, flexShrink: 0 }}>{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
interface LayerNodeProps {
|
interface LayerNodeProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
depth: number;
|
depth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||||
|
const { requestFocus } = useLayerFocus();
|
||||||
const { node, selectedId, actions, query } = useEditor((state) => {
|
const { node, selectedId, actions, query } = useEditor((state) => {
|
||||||
const n = state.nodes[nodeId];
|
const n = state.nodes[nodeId];
|
||||||
const selectedIds = state.events.selected;
|
const selectedIds = state.events.selected;
|
||||||
@@ -140,6 +182,10 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
const isRoot = nodeId === 'ROOT';
|
const isRoot = nodeId === 'ROOT';
|
||||||
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
|
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
|
||||||
|
|
||||||
|
const virtualSpec = VIRTUAL_CHILD_PROPS[displayName];
|
||||||
|
const virtualRows = virtualSpec ? deriveVirtualRows(displayName, node.data.props || {}) : [];
|
||||||
|
const hasDisclosure = allChildren.length + virtualRows.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@@ -187,7 +233,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Indentation/disclosure indicator */}
|
{/* Indentation/disclosure indicator */}
|
||||||
{allChildren.length > 0 ? (
|
{hasDisclosure ? (
|
||||||
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
|
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
|
||||||
▼
|
▼
|
||||||
</span>
|
</span>
|
||||||
@@ -217,6 +263,23 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Virtual rows: array-prop items (composite content Craft doesn't see
|
||||||
|
as child nodes), rendered before real children. */}
|
||||||
|
{virtualSpec && virtualRows.map((row) => (
|
||||||
|
<VirtualRowNode
|
||||||
|
key={`${nodeId}:${virtualSpec.prop}:${row.index}`}
|
||||||
|
parentId={nodeId}
|
||||||
|
prop={virtualSpec.prop}
|
||||||
|
index={row.index}
|
||||||
|
label={row.label}
|
||||||
|
depth={depth + 1}
|
||||||
|
onActivate={() => {
|
||||||
|
actions.selectNode(nodeId);
|
||||||
|
requestFocus(nodeId, virtualSpec.prop, row.index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Render children */}
|
{/* Render children */}
|
||||||
{allChildren.map((childId) => (
|
{allChildren.map((childId) => (
|
||||||
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
|
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||||
@@ -226,9 +289,17 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const LayersPanel: React.FC = () => {
|
export const LayersPanel: React.FC = () => {
|
||||||
const { nodeIds } = useEditor((state) => {
|
const { nodeIds, unplacedIds } = useEditor((state) => {
|
||||||
|
const serializable: Record<string, any> = {};
|
||||||
|
for (const [id, n] of Object.entries(state.nodes)) {
|
||||||
|
serializable[id] = {
|
||||||
|
nodes: n.data.nodes || [],
|
||||||
|
linkedNodes: n.data.linkedNodes || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
nodeIds: Object.keys(state.nodes),
|
nodeIds: Object.keys(state.nodes),
|
||||||
|
unplacedIds: findUnreachableNodeIds(serializable),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -243,13 +314,7 @@ export const LayersPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ display: 'flex', flexDirection: 'column', margin: '-12px', minHeight: 0 }}>
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
margin: '-12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
@@ -259,11 +324,44 @@ export const LayersPanel: React.FC = () => {
|
|||||||
letterSpacing: '0.5px',
|
letterSpacing: '0.5px',
|
||||||
color: 'var(--color-text-muted)',
|
color: 'var(--color-text-muted)',
|
||||||
borderBottom: '1px solid var(--color-border)',
|
borderBottom: '1px solid var(--color-border)',
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Component Tree
|
Component Tree
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Own scroll container: a deep or long tree must stay fully reachable
|
||||||
|
regardless of how the parent tab panel is sized. */}
|
||||||
|
<div className="layers-tree-scroll" style={{ overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
||||||
<LayerNode nodeId="ROOT" depth={0} />
|
<LayerNode nodeId="ROOT" depth={0} />
|
||||||
|
|
||||||
|
{/* Unplaced: nodes no parent lists. `repairOrphanNodes` reattaches
|
||||||
|
these on load, so this should stay empty -- it exists so an
|
||||||
|
element stranded mid-session is still selectable and deletable
|
||||||
|
rather than invisible. */}
|
||||||
|
{unplacedIds.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
color: 'var(--color-warning, #f59e0b)',
|
||||||
|
borderTop: '1px solid var(--color-border)',
|
||||||
|
}}
|
||||||
|
title="These elements are not attached to the page. Select one to delete it."
|
||||||
|
>
|
||||||
|
Unplaced ({unplacedIds.length})
|
||||||
|
</div>
|
||||||
|
{unplacedIds.map((id) => (
|
||||||
|
<LayerNode key={id} nodeId={id} depth={1} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderEditorHarness } from '../../test-utils/editorHarness';
|
||||||
|
import { PageProvider, usePages } from '../../state/PageContext';
|
||||||
|
import { PagesPanel } from './PagesPanel';
|
||||||
|
|
||||||
|
function clickByLabel(container: HTMLElement, label: string): HTMLButtonElement {
|
||||||
|
const btn = container.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement | null;
|
||||||
|
if (!btn) throw new Error(`No button found with aria-label "${label}"`);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickByText(container: HTMLElement, text: string): HTMLButtonElement {
|
||||||
|
const btn = Array.from(container.querySelectorAll('button')).find(
|
||||||
|
(b) => b.textContent === text,
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
if (!btn) throw new Error(`No button found with text "${text}"`);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I3 (review): `deleteConfirmId`, `resetConfirmId` and `editingId` are three
|
||||||
|
* independent bits of local state, but the row rendering treats them as
|
||||||
|
* mutually exclusive (edit -> delete -> reset -> normal, first match wins).
|
||||||
|
* Before this fix, none of the three setters cleared the other two, so if
|
||||||
|
* both `resetConfirmId` and `deleteConfirmId` were ever set to the same page
|
||||||
|
* id, the delete-confirm view would win (it's checked first) and cancelling
|
||||||
|
* it would fall through to reveal the reset-confirm view "unbidden" -- a
|
||||||
|
* destructive prompt the user never asked for.
|
||||||
|
*
|
||||||
|
* This is reachable any time two arm-clicks land in the same render pass
|
||||||
|
* (e.g. a fast double click / synthetic dual dispatch before React commits
|
||||||
|
* the first click's re-render) -- reproduced below by issuing both clicks
|
||||||
|
* inside a single `act()` batch, which is exactly what removes the re-render
|
||||||
|
* that would otherwise make the second button disappear before it can be
|
||||||
|
* clicked.
|
||||||
|
*/
|
||||||
|
describe('PagesPanel confirmation-state isolation (I3 review finding)', () => {
|
||||||
|
async function setupTwoPages() {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Probe: React.FC = () => { ctx = usePages(); return null; };
|
||||||
|
const harness = renderEditorHarness();
|
||||||
|
harness.mountChild(
|
||||||
|
<PageProvider>
|
||||||
|
<Probe />
|
||||||
|
<PagesPanel />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
harness.act(() => { ctx!.addPage('About', 'about'); });
|
||||||
|
await harness.act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); });
|
||||||
|
return harness;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('arming Reset then Delete for the same page in one batch shows Delete (render precedence), and cancelling Delete does NOT reveal a leftover Reset prompt', async () => {
|
||||||
|
const harness = await setupTwoPages();
|
||||||
|
|
||||||
|
// Both arm-clicks land before any re-render commits.
|
||||||
|
harness.act(() => {
|
||||||
|
clickByLabel(harness.container, 'Reset About to blank').click();
|
||||||
|
clickByLabel(harness.container, 'Delete About').click();
|
||||||
|
});
|
||||||
|
expect(harness.container.textContent).toContain('Delete "About"?');
|
||||||
|
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
|
||||||
|
|
||||||
|
harness.act(() => {
|
||||||
|
clickByText(harness.container, 'Cancel').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The fix: arming Delete clears any pending Reset confirmation for the
|
||||||
|
// same page, so cancelling Delete returns to the normal row, not a
|
||||||
|
// surprise Reset prompt.
|
||||||
|
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
|
||||||
|
expect(harness.container.textContent).not.toContain('Delete "About"?');
|
||||||
|
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('arming Delete then Reset for the same page in one batch shows Reset, and cancelling Reset does NOT reveal a leftover Delete prompt', async () => {
|
||||||
|
const harness = await setupTwoPages();
|
||||||
|
|
||||||
|
harness.act(() => {
|
||||||
|
clickByLabel(harness.container, 'Delete About').click();
|
||||||
|
clickByLabel(harness.container, 'Reset About to blank').click();
|
||||||
|
});
|
||||||
|
expect(harness.container.textContent).toContain('Clear every element from "About"?');
|
||||||
|
|
||||||
|
harness.act(() => {
|
||||||
|
clickByText(harness.container, 'Cancel').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(harness.container.textContent).not.toContain('Delete "About"?');
|
||||||
|
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
|
||||||
|
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starting a Rename while a Reset confirmation is pending for the same page clears the pending Reset', async () => {
|
||||||
|
const harness = await setupTwoPages();
|
||||||
|
|
||||||
|
// Arm Reset, then (same batch) start editing the same row -- edit wins
|
||||||
|
// in render precedence, but the fix also clears resetConfirmId so
|
||||||
|
// cancelling the rename doesn't fall through to a stale Reset prompt.
|
||||||
|
harness.act(() => {
|
||||||
|
clickByLabel(harness.container, 'Reset About to blank').click();
|
||||||
|
clickByLabel(harness.container, 'Rename About').click();
|
||||||
|
});
|
||||||
|
const nameInput = harness.container.querySelector('input.control-input') as HTMLInputElement | null;
|
||||||
|
expect(nameInput).toBeTruthy();
|
||||||
|
expect(nameInput!.value).toBe('About');
|
||||||
|
|
||||||
|
harness.act(() => {
|
||||||
|
clickByText(harness.container, 'Cancel').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
|
||||||
|
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderEditorHarness } from '../../test-utils/editorHarness';
|
||||||
|
import { EMPTY_CANVAS, PageProvider, usePages } from '../../state/PageContext';
|
||||||
|
import { PagesPanel } from './PagesPanel';
|
||||||
|
|
||||||
|
const withHeading = JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' }, isCanvas: true,
|
||||||
|
props: { style: {}, tag: 'div' }, displayName: 'Container',
|
||||||
|
custom: {}, hidden: false, nodes: ['h1'], linkedNodes: {}, parent: null,
|
||||||
|
},
|
||||||
|
h1: {
|
||||||
|
type: { resolvedName: 'Heading' }, isCanvas: false,
|
||||||
|
props: { text: 'Keep me', level: 2 }, displayName: 'Heading',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Same real-timer-flush pattern as `PageContext.orphan-repair-wiring.test.tsx`
|
||||||
|
* -- `PageContext.loadState`/this feature's own reset handler both schedule
|
||||||
|
* their `actions.deserialize()` calls via `setTimeout(..., 0)` rather than
|
||||||
|
* applying them synchronously, so tests that exercise either path need to
|
||||||
|
* let a real macrotask turn run before asserting on the live Frame. */
|
||||||
|
async function flushTimers(harness: ReturnType<typeof renderEditorHarness>): Promise<void> {
|
||||||
|
await harness.act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickByLabel(container: HTMLElement, label: string): HTMLButtonElement {
|
||||||
|
const btn = container.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement | null;
|
||||||
|
if (!btn) throw new Error(`No button found with aria-label "${label}"`);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickByText(container: HTMLElement, text: string): HTMLButtonElement {
|
||||||
|
const btn = Array.from(container.querySelectorAll('button')).find(
|
||||||
|
(b) => b.textContent === text,
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
if (!btn) throw new Error(`No button found with text "${text}"`);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resetting a page to EMPTY_CANVAS (Craft.js characterisation)', () => {
|
||||||
|
test('clears the canvas', () => {
|
||||||
|
const harness = renderEditorHarness({ initialState: withHeading });
|
||||||
|
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
|
||||||
|
|
||||||
|
harness.act(() => { harness.actions.deserialize(EMPTY_CANVAS); });
|
||||||
|
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the reset is undoable through Craft history', () => {
|
||||||
|
const harness = renderEditorHarness({ initialState: withHeading });
|
||||||
|
harness.act(() => { harness.actions.deserialize(EMPTY_CANVAS); });
|
||||||
|
harness.act(() => { harness.actions.history.undo(); });
|
||||||
|
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PagesPanel Reset Page control', () => {
|
||||||
|
test('a Reset button is rendered for every page row, and the dialog names the page', () => {
|
||||||
|
const harness = renderEditorHarness();
|
||||||
|
harness.mountChild(
|
||||||
|
<PageProvider>
|
||||||
|
<PagesPanel />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fresh PageProvider starts with exactly one page: "Home".
|
||||||
|
const erasers = harness.container.querySelectorAll('.fa-eraser');
|
||||||
|
expect(erasers.length).toBe(1);
|
||||||
|
expect(clickByLabel(harness.container, 'Reset Home to blank')).toBeTruthy();
|
||||||
|
|
||||||
|
harness.act(() => {
|
||||||
|
clickByLabel(harness.container, 'Reset Home to blank').click();
|
||||||
|
});
|
||||||
|
expect(harness.container.textContent).toContain('Clear every element from "Home"?');
|
||||||
|
expect(harness.container.textContent).toContain('Ctrl+Z undoes this.');
|
||||||
|
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Header and Footer zone rows never grow a Reset control', () => {
|
||||||
|
const harness = renderEditorHarness();
|
||||||
|
harness.mountChild(
|
||||||
|
<PageProvider>
|
||||||
|
<PagesPanel />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const zoneRows = harness.container.querySelectorAll('.zone-row');
|
||||||
|
expect(zoneRows.length).toBe(2); // Header, Footer
|
||||||
|
zoneRows.forEach((row) => {
|
||||||
|
expect(row.querySelector('.fa-eraser')).toBeNull();
|
||||||
|
});
|
||||||
|
// No aria-label anywhere in the panel offers to reset the header/footer.
|
||||||
|
expect(harness.container.querySelector('[aria-label="Reset Header to blank"]')).toBeNull();
|
||||||
|
expect(harness.container.querySelector('[aria-label="Reset Footer to blank"]')).toBeNull();
|
||||||
|
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking Reset then Cancel leaves the active page untouched', () => {
|
||||||
|
const harness = renderEditorHarness({ initialState: withHeading });
|
||||||
|
harness.mountChild(
|
||||||
|
<PageProvider>
|
||||||
|
<PagesPanel />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.act(() => {
|
||||||
|
clickByLabel(harness.container, 'Reset Home to blank').click();
|
||||||
|
});
|
||||||
|
harness.act(() => {
|
||||||
|
clickByText(harness.container, 'Cancel').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking Reset then confirming blanks the ACTIVE page', async () => {
|
||||||
|
const harness = renderEditorHarness({ initialState: withHeading });
|
||||||
|
harness.mountChild(
|
||||||
|
<PageProvider>
|
||||||
|
<PagesPanel />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.act(() => {
|
||||||
|
clickByLabel(harness.container, 'Reset Home to blank').click();
|
||||||
|
});
|
||||||
|
harness.act(() => {
|
||||||
|
clickByText(harness.container, 'Reset page').click();
|
||||||
|
});
|
||||||
|
await flushTimers(harness);
|
||||||
|
|
||||||
|
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resetting a page that is NOT on screen switches to it first, then blanks it -- ' +
|
||||||
|
'the switch-in load must resolve BEFORE the blank, or the blank would just get ' +
|
||||||
|
'overwritten by the page\'s real content', async () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Probe: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const harness = renderEditorHarness({ initialState: withHeading });
|
||||||
|
harness.mountChild(
|
||||||
|
<PageProvider>
|
||||||
|
<Probe />
|
||||||
|
<PagesPanel />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// addPage() saves the live Frame (withHeading) into Home's slot, adds
|
||||||
|
// "About", and switches the live Frame to About's (empty) canvas.
|
||||||
|
harness.act(() => { ctx!.addPage('About', 'about'); });
|
||||||
|
await flushTimers(harness);
|
||||||
|
expect(ctx!.activePageId).not.toBe('home');
|
||||||
|
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]); // About is blank
|
||||||
|
|
||||||
|
// Now reset Home while About is the active/on-screen page.
|
||||||
|
harness.act(() => {
|
||||||
|
clickByLabel(harness.container, 'Reset Home to blank').click();
|
||||||
|
});
|
||||||
|
harness.act(() => {
|
||||||
|
clickByText(harness.container, 'Reset page').click();
|
||||||
|
});
|
||||||
|
await flushTimers(harness);
|
||||||
|
|
||||||
|
// The live Frame now shows Home (switchPage ran) and it's blank (reset ran
|
||||||
|
// after the switch-in load, not before it).
|
||||||
|
expect(ctx!.activePageId).toBe('home');
|
||||||
|
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
|
||||||
|
|
||||||
|
// And the blank state actually persisted into Home's stored slot, not
|
||||||
|
// just transiently on the Frame: switch away and back, still blank.
|
||||||
|
const aboutId = ctx!.pages.find((p) => p.id !== 'home')!.id;
|
||||||
|
harness.act(() => { ctx!.switchPage(aboutId); });
|
||||||
|
await flushTimers(harness);
|
||||||
|
harness.act(() => { ctx!.switchPage('home'); });
|
||||||
|
await flushTimers(harness);
|
||||||
|
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
|
||||||
|
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { usePages } from '../../state/PageContext';
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { usePages, EMPTY_CANVAS } from '../../state/PageContext';
|
||||||
import { clickableProps } from '../../utils/a11y';
|
import { clickableProps } from '../../utils/a11y';
|
||||||
import { PageSettingsModal } from './PageSettingsModal';
|
import { PageSettingsModal } from './PageSettingsModal';
|
||||||
|
|
||||||
@@ -15,8 +16,12 @@ export const PagesPanel: React.FC = () => {
|
|||||||
addPage,
|
addPage,
|
||||||
deletePage,
|
deletePage,
|
||||||
renamePage,
|
renamePage,
|
||||||
|
duplicatePage,
|
||||||
|
movePage,
|
||||||
|
setLandingPage,
|
||||||
updatePageSeo,
|
updatePageSeo,
|
||||||
} = usePages();
|
} = usePages();
|
||||||
|
const { actions: editorActions } = useEditor();
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [newSlug, setNewSlug] = useState('');
|
const [newSlug, setNewSlug] = useState('');
|
||||||
@@ -24,6 +29,7 @@ export const PagesPanel: React.FC = () => {
|
|||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [editSlug, setEditSlug] = useState('');
|
const [editSlug, setEditSlug] = useState('');
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [resetConfirmId, setResetConfirmId] = useState<string | null>(null);
|
||||||
const [seoSettingsPageId, setSeoSettingsPageId] = useState<string | null>(null);
|
const [seoSettingsPageId, setSeoSettingsPageId] = useState<string | null>(null);
|
||||||
const seoSettingsPage = pages.find((p) => p.id === seoSettingsPageId) || null;
|
const seoSettingsPage = pages.find((p) => p.id === seoSettingsPageId) || null;
|
||||||
|
|
||||||
@@ -46,11 +52,32 @@ export const PagesPanel: React.FC = () => {
|
|||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blanks `pageId`'s canvas to `EMPTY_CANVAS`. `actions.deserialize()` acts
|
||||||
|
* on the LIVE `<Frame>`, so resetting a page that isn't currently on
|
||||||
|
* screen requires switching to it first. `switchPage` itself defers its
|
||||||
|
* own `actions.deserialize(targetState)` via `setTimeout(..., 0)` (see
|
||||||
|
* `PageContext.loadState`) rather than applying it synchronously -- so the
|
||||||
|
* blanking `setTimeout` scheduled here must run AFTER that one, or it
|
||||||
|
* would blank the frame and then have `switchPage`'s own deferred load
|
||||||
|
* immediately overwrite the blank with the target page's real content.
|
||||||
|
* Since `switchPage(pageId)` runs synchronously above (registering its
|
||||||
|
* internal setTimeout first) before this function schedules its own,
|
||||||
|
* same-delay `setTimeout` callbacks fire in registration order -- the
|
||||||
|
* switch's load always resolves before this reset does.
|
||||||
|
*/
|
||||||
|
const handleResetPage = (pageId: string): void => {
|
||||||
|
if (pageId !== activePageId) switchPage(pageId);
|
||||||
|
setTimeout(() => editorActions.deserialize(EMPTY_CANVAS), 0);
|
||||||
|
setResetConfirmId(null);
|
||||||
|
};
|
||||||
|
|
||||||
const startEditing = (page: { id: string; name: string; slug: string }) => {
|
const startEditing = (page: { id: string; name: string; slug: string }) => {
|
||||||
setEditingId(page.id);
|
setEditingId(page.id);
|
||||||
setEditName(page.name);
|
setEditName(page.name);
|
||||||
setEditSlug(page.slug);
|
setEditSlug(page.slug);
|
||||||
setDeleteConfirmId(null);
|
setDeleteConfirmId(null);
|
||||||
|
setResetConfirmId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const autoSlug = (name: string): string => {
|
const autoSlug = (name: string): string => {
|
||||||
@@ -68,6 +95,33 @@ export const PagesPanel: React.FC = () => {
|
|||||||
* differently-colored category. The active/editing state reuses the same
|
* differently-colored category. The active/editing state reuses the same
|
||||||
* accent-outline treatment the page list already uses for the active page,
|
* accent-outline treatment the page list already uses for the active page,
|
||||||
* so there's one consistent "this is what's currently open" affordance. */
|
* so there's one consistent "this is what's currently open" affordance. */
|
||||||
|
/* ---------- Per-page-row icon button ----------
|
||||||
|
* Shared 24x24 icon-button styling used by every action in the page row
|
||||||
|
* (SEO settings, rename, duplicate, reorder, set-as-home, delete) so a new
|
||||||
|
* action slots in looking identical to the pre-existing gear/pencil/trash
|
||||||
|
* buttons -- same dark-theme neutral treatment, `disabled` dims the icon
|
||||||
|
* (matching how `disabled` already reads elsewhere in this panel, e.g. the
|
||||||
|
* "Add Page" button), and `danger` reuses the existing delete-button
|
||||||
|
* accent color. */
|
||||||
|
const pageActionBtnStyle = (opts: { disabled?: boolean; danger?: boolean } = {}): React.CSSProperties => ({
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 11,
|
||||||
|
color: opts.disabled
|
||||||
|
? 'var(--color-text-dim)'
|
||||||
|
: opts.danger
|
||||||
|
? 'var(--color-danger)'
|
||||||
|
: 'var(--color-text-muted)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: opts.disabled ? 'default' : 'pointer',
|
||||||
|
opacity: opts.disabled ? 0.5 : 1,
|
||||||
|
});
|
||||||
|
|
||||||
const zoneRowStyle = (isActive: boolean): React.CSSProperties => ({
|
const zoneRowStyle = (isActive: boolean): React.CSSProperties => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -271,6 +325,60 @@ export const PagesPanel: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : resetConfirmId === page.id ? (
|
||||||
|
/* Reset-to-blank confirmation -- mirrors the delete confirmation
|
||||||
|
* above, since both are destructive per-page actions. */
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 10,
|
||||||
|
background: 'var(--color-bg-elevated)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
border: '1px solid var(--color-danger)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text)' }}>
|
||||||
|
Clear every element from "{page.name}"? Your header, footer and other
|
||||||
|
pages are untouched, and the published site doesn't change until you
|
||||||
|
publish again. Ctrl+Z undoes this.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleResetPage(page.id)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '5px 10px',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#fff',
|
||||||
|
background: 'var(--color-danger)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset page
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setResetConfirmId(null)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '5px 10px',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
background: 'var(--color-bg-base)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Normal page item */
|
/* Normal page item */
|
||||||
<div
|
<div
|
||||||
@@ -344,26 +452,58 @@ export const PagesPanel: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{ display: 'flex', gap: 4, flexShrink: 0 }}
|
style={{ display: 'flex', gap: 2, flexShrink: 0, flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: 96 }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => movePage(page.id, 'up')}
|
||||||
|
disabled={pageIndex === 0}
|
||||||
|
data-tooltip="Move up"
|
||||||
|
aria-label={`Move ${page.name} up`}
|
||||||
|
style={pageActionBtnStyle({ disabled: pageIndex === 0 })}
|
||||||
|
>
|
||||||
|
<i className="fa fa-arrow-up" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => movePage(page.id, 'down')}
|
||||||
|
disabled={pageIndex === pages.length - 1}
|
||||||
|
data-tooltip="Move down"
|
||||||
|
aria-label={`Move ${page.name} down`}
|
||||||
|
style={pageActionBtnStyle({ disabled: pageIndex === pages.length - 1 })}
|
||||||
|
>
|
||||||
|
<i className="fa fa-arrow-down" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => duplicatePage(page.id)}
|
||||||
|
data-tooltip="Duplicate"
|
||||||
|
aria-label={`Duplicate ${page.name}`}
|
||||||
|
style={pageActionBtnStyle()}
|
||||||
|
>
|
||||||
|
<i className="fa fa-clone" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{isLanding ? (
|
||||||
|
<span
|
||||||
|
data-tooltip="This is the home page"
|
||||||
|
aria-label={`${page.name} is the home page`}
|
||||||
|
style={pageActionBtnStyle({ disabled: true })}
|
||||||
|
>
|
||||||
|
<i className="fa fa-home" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setLandingPage(page.id)}
|
||||||
|
data-tooltip="Set as home page"
|
||||||
|
aria-label={`Set ${page.name} as the home page`}
|
||||||
|
style={pageActionBtnStyle()}
|
||||||
|
>
|
||||||
|
<i className="fa fa-home" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSeoSettingsPageId(page.id)}
|
onClick={() => setSeoSettingsPageId(page.id)}
|
||||||
data-tooltip="Page Settings (SEO)"
|
data-tooltip="Page Settings (SEO)"
|
||||||
aria-label={`Page settings for ${page.name}`}
|
aria-label={`Page settings for ${page.name}`}
|
||||||
style={{
|
style={pageActionBtnStyle()}
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<i className="fa fa-cog" aria-hidden="true" />
|
<i className="fa fa-cog" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -371,40 +511,30 @@ export const PagesPanel: React.FC = () => {
|
|||||||
onClick={() => startEditing(page)}
|
onClick={() => startEditing(page)}
|
||||||
data-tooltip="Rename"
|
data-tooltip="Rename"
|
||||||
aria-label={`Rename ${page.name}`}
|
aria-label={`Rename ${page.name}`}
|
||||||
style={{
|
style={pageActionBtnStyle()}
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<i className="fa fa-pencil" aria-hidden="true" />
|
<i className="fa fa-pencil" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setResetConfirmId(page.id);
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
}}
|
||||||
|
data-tooltip="Reset to blank"
|
||||||
|
aria-label={`Reset ${page.name} to blank`}
|
||||||
|
style={pageActionBtnStyle({ danger: true })}
|
||||||
|
>
|
||||||
|
<i className="fa fa-eraser" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
{pages.length > 1 && !isLanding && (
|
{pages.length > 1 && !isLanding && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteConfirmId(page.id)}
|
onClick={() => {
|
||||||
|
setDeleteConfirmId(page.id);
|
||||||
|
setResetConfirmId(null);
|
||||||
|
}}
|
||||||
data-tooltip="Delete"
|
data-tooltip="Delete"
|
||||||
aria-label={`Delete ${page.name}`}
|
aria-label={`Delete ${page.name}`}
|
||||||
style={{
|
style={pageActionBtnStyle({ danger: true })}
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<i className="fa fa-trash" aria-hidden="true" />
|
<i className="fa fa-trash" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
|
||||||
|
|
||||||
|
describe('deriveVirtualRows', () => {
|
||||||
|
test('returns one row per item, labelled by the registered field', () => {
|
||||||
|
const rows = deriveVirtualRows('Features Grid', {
|
||||||
|
features: [{ title: 'Fast' }, { title: 'Secure' }],
|
||||||
|
});
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ index: 0, label: 'Fast' },
|
||||||
|
{ index: 1, label: 'Secure' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to "<Fallback> N" when the label field is missing or blank', () => {
|
||||||
|
const rows = deriveVirtualRows('Features Grid', {
|
||||||
|
features: [{ title: '' }, { description: 'no title key' }],
|
||||||
|
});
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ index: 0, label: 'Feature 1' },
|
||||||
|
{ index: 1, label: 'Feature 2' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trims and truncates a long label to 40 characters with an ellipsis', () => {
|
||||||
|
const long = 'x'.repeat(60);
|
||||||
|
const rows = deriveVirtualRows('Features Grid', { features: [{ title: ` ${long} ` }] });
|
||||||
|
expect(rows[0].label).toHaveLength(41);
|
||||||
|
expect(rows[0].label.endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unregistered component yields no rows', () => {
|
||||||
|
expect(deriveVirtualRows('Heading', { text: 'hi' })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing or non-array prop yields no rows instead of throwing', () => {
|
||||||
|
expect(deriveVirtualRows('Tabs', {})).toEqual([]);
|
||||||
|
expect(deriveVirtualRows('Tabs', { tabs: 'not an array' })).toEqual([]);
|
||||||
|
expect(deriveVirtualRows('Tabs', { tabs: null })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-object item still gets a fallback label', () => {
|
||||||
|
expect(deriveVirtualRows('Menu', { links: ['raw string'] })).toEqual([
|
||||||
|
{ index: 0, label: 'Link 1' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Hero, Call to Action and CTA Section derive rows from their shared ctas prop', () => {
|
||||||
|
const ctas = [
|
||||||
|
{ text: 'Get Started', href: '#', variant: 'primary' },
|
||||||
|
{ text: 'Learn More', href: '#learn', variant: 'outline' },
|
||||||
|
];
|
||||||
|
expect(deriveVirtualRows('Hero', { ctas })).toEqual([
|
||||||
|
{ index: 0, label: 'Get Started' },
|
||||||
|
{ index: 1, label: 'Learn More' },
|
||||||
|
]);
|
||||||
|
expect(deriveVirtualRows('Call to Action', { ctas })).toEqual([
|
||||||
|
{ index: 0, label: 'Get Started' },
|
||||||
|
{ index: 1, label: 'Learn More' },
|
||||||
|
]);
|
||||||
|
expect(deriveVirtualRows('CTA Section', { ctas })).toEqual([
|
||||||
|
{ index: 0, label: 'Get Started' },
|
||||||
|
{ index: 1, label: 'Learn More' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a CTA with no text falls back to "Button N"', () => {
|
||||||
|
const rows = deriveVirtualRows('Hero', {
|
||||||
|
ctas: [{ href: '#' }, { text: '', href: '#empty' }],
|
||||||
|
});
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ index: 0, label: 'Button 1' },
|
||||||
|
{ index: 1, label: 'Button 2' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every registry entry has a non-empty prop, label and fallback', () => {
|
||||||
|
for (const [name, spec] of Object.entries(VIRTUAL_CHILD_PROPS)) {
|
||||||
|
expect(spec.prop, `${name}.prop`).toBeTruthy();
|
||||||
|
expect(spec.label, `${name}.label`).toBeTruthy();
|
||||||
|
expect(spec.fallback, `${name}.fallback`).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Virtual Layers rows for components whose content lives in ARRAY PROPS
|
||||||
|
* rather than Craft child nodes.
|
||||||
|
*
|
||||||
|
* FeaturesGrid, Tabs, Accordion, PricingTable, Testimonials, Gallery,
|
||||||
|
* ContentSlider, NumberCounter, Menu, SocialLinks, Navbar and ContactForm all
|
||||||
|
* render their items from a prop array, so Craft sees them as leaf nodes and
|
||||||
|
* the Layers tree showed nothing underneath them -- the "Layers doesn't show
|
||||||
|
* everything" report. These rows are display-only: they are not Craft nodes,
|
||||||
|
* cannot be dragged, and selecting one selects the PARENT node (plus asks the
|
||||||
|
* array editor to scroll that item into view -- see LayerFocusContext).
|
||||||
|
*
|
||||||
|
* ColumnLayout is deliberately absent: it uses real `<Element canvas>`
|
||||||
|
* children, which LayersPanel already nests correctly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface VirtualRow {
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VirtualChildSpec {
|
||||||
|
/** Name of the array prop holding the items. */
|
||||||
|
prop: string;
|
||||||
|
/** Per-item field used as the row label. */
|
||||||
|
label: string;
|
||||||
|
/** Used as "<fallback> <n>" when the label field is missing or blank. */
|
||||||
|
fallback: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyed by the component's craft `displayName` -- the same string
|
||||||
|
* `LayerNode` already resolves and shows as the row text.
|
||||||
|
*
|
||||||
|
* Verified against each component's actual item interface and
|
||||||
|
* `craft.props` defaults (see task-9-report.md, Step 1):
|
||||||
|
* - Tabs items are `{ label, content }`, not `{ title }` -- registry
|
||||||
|
* corrected from `title` to `label`.
|
||||||
|
* - ContentSlider items are `{ heading, text, ... }`, not `{ title }` --
|
||||||
|
* registry corrected from `title` to `heading`.
|
||||||
|
* Every other entry (prop name and label field) matched the component as
|
||||||
|
* originally drafted.
|
||||||
|
*
|
||||||
|
* Hero, Call to Action and CTA Section were added after a follow-up sweep
|
||||||
|
* of every component under src/components/ for top-level array props (not
|
||||||
|
* just the 12 files initially listed) turned up three more leaf components
|
||||||
|
* with the identical pattern: all three render a `ctas?: CtaButton[]` prop
|
||||||
|
* (see sections/_cta-helpers.tsx) whose items are `{ text, href, variant?,
|
||||||
|
* target? }`, so `text` is the label field. Verified against each file's
|
||||||
|
* `craft.displayName` and `craft.props.ctas` defaults. That sweep found no
|
||||||
|
* further candidates -- the only other array fields in the tree are nested
|
||||||
|
* one level down inside already-covered items (ContactFormField.options,
|
||||||
|
* PricingPlan.features), not top-level component props. */
|
||||||
|
export const VIRTUAL_CHILD_PROPS: Record<string, VirtualChildSpec> = {
|
||||||
|
'Features Grid': { prop: 'features', label: 'title', fallback: 'Feature' },
|
||||||
|
Tabs: { prop: 'tabs', label: 'label', fallback: 'Tab' },
|
||||||
|
Accordion: { prop: 'items', label: 'title', fallback: 'Item' },
|
||||||
|
'Pricing Table': { prop: 'plans', label: 'name', fallback: 'Plan' },
|
||||||
|
Testimonials: { prop: 'testimonials', label: 'name', fallback: 'Testimonial' },
|
||||||
|
Gallery: { prop: 'images', label: 'alt', fallback: 'Image' },
|
||||||
|
'Content Slider': { prop: 'slides', label: 'heading', fallback: 'Slide' },
|
||||||
|
'Number Counter': { prop: 'counters', label: 'label', fallback: 'Counter' },
|
||||||
|
Menu: { prop: 'links', label: 'text', fallback: 'Link' },
|
||||||
|
'Social Links': { prop: 'links', label: 'platform', fallback: 'Link' },
|
||||||
|
Navbar: { prop: 'links', label: 'text', fallback: 'Link' },
|
||||||
|
'Contact Form': { prop: 'fields', label: 'label', fallback: 'Field' },
|
||||||
|
Hero: { prop: 'ctas', label: 'text', fallback: 'Button' },
|
||||||
|
'Call to Action': { prop: 'ctas', label: 'text', fallback: 'Button' },
|
||||||
|
'CTA Section': { prop: 'ctas', label: 'text', fallback: 'Button' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_LABEL = 40;
|
||||||
|
|
||||||
|
export function deriveVirtualRows(displayName: string, props: Record<string, any>): VirtualRow[] {
|
||||||
|
const spec = VIRTUAL_CHILD_PROPS[displayName];
|
||||||
|
if (!spec) return [];
|
||||||
|
|
||||||
|
const items = props?.[spec.prop];
|
||||||
|
if (!Array.isArray(items)) return [];
|
||||||
|
|
||||||
|
return items.map((item, index) => {
|
||||||
|
const raw = item && typeof item === 'object' ? item[spec.label] : undefined;
|
||||||
|
const text = typeof raw === 'string' ? raw.trim() : '';
|
||||||
|
if (!text) return { index, label: `${spec.fallback} ${index + 1}` };
|
||||||
|
const label = text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL)}…` : text;
|
||||||
|
return { index, label };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
PricingStylePanel,
|
PricingStylePanel,
|
||||||
BackgroundSectionStylePanel,
|
BackgroundSectionStylePanel,
|
||||||
GenericPropsEditor,
|
GenericPropsEditor,
|
||||||
|
HtmlStylePanel,
|
||||||
} from './styles';
|
} from './styles';
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
@@ -75,8 +76,10 @@ export const GuidedStyles: React.FC = () => {
|
|||||||
const isSocial = /^social links$|^icon$|^star rating$/i.test(typeName);
|
const isSocial = /^social links$|^icon$|^star rating$/i.test(typeName);
|
||||||
const isPricing = /^pricing/i.test(typeName);
|
const isPricing = /^pricing/i.test(typeName);
|
||||||
const isSection = /^accordion$|^tabs$|^testimonial|^countdown$|^number counter$|^cta section$|^call to action$|^features grid$/i.test(typeName);
|
const isSection = /^accordion$|^tabs$|^testimonial|^countdown$|^number counter$|^cta section$|^call to action$|^features grid$/i.test(typeName);
|
||||||
// Utility types that need minimal controls
|
const isHtml = /^html$/i.test(typeName);
|
||||||
const isUtility = /^divider$|^spacer$|^html$/i.test(typeName);
|
// Utility types that need minimal controls. HTML is deliberately NOT here
|
||||||
|
// -- it gets HtmlStylePanel, which shows the code editor and nothing else.
|
||||||
|
const isUtility = /^divider$|^spacer$/i.test(typeName);
|
||||||
|
|
||||||
// Icon for the type badge
|
// Icon for the type badge
|
||||||
const typeIcon = isText ? 'fa-font'
|
const typeIcon = isText ? 'fa-font'
|
||||||
@@ -90,6 +93,7 @@ export const GuidedStyles: React.FC = () => {
|
|||||||
: isForm ? 'fa-wpforms'
|
: isForm ? 'fa-wpforms'
|
||||||
: isSocial ? 'fa-share-alt'
|
: isSocial ? 'fa-share-alt'
|
||||||
: isSection ? 'fa-th-large'
|
: isSection ? 'fa-th-large'
|
||||||
|
: isHtml ? 'fa-code'
|
||||||
: isUtility ? 'fa-ellipsis-h'
|
: isUtility ? 'fa-ellipsis-h'
|
||||||
: 'fa-cube';
|
: 'fa-cube';
|
||||||
|
|
||||||
@@ -158,11 +162,14 @@ export const GuidedStyles: React.FC = () => {
|
|||||||
{/* SECTION-TYPE (Accordion, Tabs, Testimonials, Countdown, Counter, CTA, Features) */}
|
{/* SECTION-TYPE (Accordion, Tabs, Testimonials, Countdown, Counter, CTA, Features) */}
|
||||||
{isSection && <SectionTypePanel selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
|
{isSection && <SectionTypePanel selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
|
||||||
|
|
||||||
{/* UTILITY (Divider, Spacer, HTML) -- use generic but it works well for these */}
|
{/* HTML -- code editor only */}
|
||||||
|
{isHtml && <HtmlStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||||
|
|
||||||
|
{/* UTILITY (Divider, Spacer) -- use generic but it works well for these */}
|
||||||
{isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
|
{isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
|
||||||
|
|
||||||
{/* FALLBACK: Anything not matched above */}
|
{/* FALLBACK: Anything not matched above */}
|
||||||
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && (
|
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && (
|
||||||
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
|
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderEditorHarness } from '../../test-utils/editorHarness';
|
||||||
|
import { EditorConfigProvider } from '../../state/EditorConfigContext';
|
||||||
|
import { PageProvider, usePages } from '../../state/PageContext';
|
||||||
|
import { SiteDesignProvider, useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
|
||||||
|
import { SiteDesignPanel } from './SiteDesignPanel';
|
||||||
|
|
||||||
|
/** React tracks a controlled `<input>`'s value via a wrapped native setter --
|
||||||
|
* a plain `input.value = x` assignment doesn't go through it, so React never
|
||||||
|
* sees the change and skips onChange. */
|
||||||
|
function setInputValue(input: HTMLInputElement, value: string) {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||||
|
setter.call(input, value);
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-provider integration test -- NO mocks. Exercises the actual
|
||||||
|
* `replaceAllPages`/`setHeader`/`setFooter`/`resetToDefaults` implementations,
|
||||||
|
* which route the component's blank trees through the real
|
||||||
|
* `treeToCraftState` -> `sanitizeAiTree`/`flattenTreeForCraft` pipeline (see
|
||||||
|
* `state/PageContext.tsx` and `utils/craft-tree.ts`). This is the check the
|
||||||
|
* task brief calls out specifically: a tree shape that fails sanitisation
|
||||||
|
* would silently fall back to an empty canvas (masking a bug) rather than
|
||||||
|
* throwing, so asserting only on mock call arguments (as
|
||||||
|
* `SiteDesignPanel.reset.test.tsx` does) would not catch a bad tree shape --
|
||||||
|
* the mocked assertions there only prove the component PASSED a
|
||||||
|
* `{type:{resolvedName:'Container'}, nodes:[]}`-shaped object; they can't
|
||||||
|
* prove that object is actually valid input to the real pipeline.
|
||||||
|
*/
|
||||||
|
describe('Reset Entire Site -- real PageContext/SiteDesignContext integration', () => {
|
||||||
|
test('confirming produces a real, valid single blank Home page + header + footer + default design', () => {
|
||||||
|
let pageCtx: ReturnType<typeof usePages> | null = null;
|
||||||
|
let designCtx: ReturnType<typeof useSiteDesign> | null = null;
|
||||||
|
const Probe: React.FC = () => {
|
||||||
|
pageCtx = usePages();
|
||||||
|
designCtx = useSiteDesign();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const harness = renderEditorHarness();
|
||||||
|
harness.mountChild(
|
||||||
|
<EditorConfigProvider config={{
|
||||||
|
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
|
||||||
|
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
|
||||||
|
}}>
|
||||||
|
<PageProvider>
|
||||||
|
<SiteDesignProvider>
|
||||||
|
<Probe />
|
||||||
|
<SiteDesignPanel />
|
||||||
|
</SiteDesignProvider>
|
||||||
|
</PageProvider>
|
||||||
|
</EditorConfigProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dirty the design tokens first so resetToDefaults has something to undo.
|
||||||
|
harness.act(() => { designCtx!.updateDesign({ primaryColor: '#000000' }); });
|
||||||
|
expect(designCtx!.design.primaryColor).toBe('#000000');
|
||||||
|
|
||||||
|
harness.act(() => {
|
||||||
|
(harness.container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
const input = harness.container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
|
||||||
|
harness.act(() => { setInputValue(input, 'example.com'); });
|
||||||
|
harness.act(() => {
|
||||||
|
(harness.container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exactly one page, "Home", landing-page slug -- and its stored
|
||||||
|
// craftState is real, parseable Craft.js state with a Container ROOT
|
||||||
|
// and no children (i.e. sanitizeAiTree did NOT reject the tree and fall
|
||||||
|
// back to a silent empty canvas for the wrong reason -- it's genuinely
|
||||||
|
// ROOT with zero nodes because that's what we asked for).
|
||||||
|
expect(pageCtx!.pages).toHaveLength(1);
|
||||||
|
expect(pageCtx!.pages[0].name).toBe('Home');
|
||||||
|
expect(pageCtx!.pages[0].slug).toBe('index');
|
||||||
|
const homeState = JSON.parse(pageCtx!.pages[0].craftState!);
|
||||||
|
expect(homeState.ROOT.type.resolvedName).toBe('Container');
|
||||||
|
expect(homeState.ROOT.nodes).toEqual([]);
|
||||||
|
|
||||||
|
const headerState = JSON.parse(pageCtx!.headerPage.craftState!);
|
||||||
|
expect(headerState.ROOT.type.resolvedName).toBe('Container');
|
||||||
|
expect(headerState.ROOT.nodes).toEqual([]);
|
||||||
|
expect(headerState.ROOT.props.tag).toBe('header');
|
||||||
|
|
||||||
|
const footerState = JSON.parse(pageCtx!.footerPage.craftState!);
|
||||||
|
expect(footerState.ROOT.type.resolvedName).toBe('Container');
|
||||||
|
expect(footerState.ROOT.nodes).toEqual([]);
|
||||||
|
expect(footerState.ROOT.props.tag).toBe('footer');
|
||||||
|
|
||||||
|
// Design tokens are back to the defaults.
|
||||||
|
expect(designCtx!.design).toEqual(DEFAULT_SITE_DESIGN);
|
||||||
|
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, test, expect, vi } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
* Mocked-hook tests -- pure UI-logic coverage: the domain-match guard, that
|
||||||
|
* confirming calls the right context functions with tree-shaped arguments,
|
||||||
|
* and that standalone mode (empty siteDomain) hides the entry point
|
||||||
|
* entirely. The real `replaceAllPages`/`setHeader`/`setFooter`/
|
||||||
|
* `resetToDefaults` implementations (and whether the blank trees this
|
||||||
|
* component builds actually survive `treeToCraftState`/`sanitizeAiTree`) are
|
||||||
|
* covered separately, WITHOUT mocks, in
|
||||||
|
* `SiteDesignPanel.reset.integration.test.tsx` -- `vi.mock` is hoisted and
|
||||||
|
* file-scoped, so it can't be selectively "undone" partway through one file
|
||||||
|
* for a real-provider test.
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const replaceAllPages = vi.fn();
|
||||||
|
const setHeader = vi.fn();
|
||||||
|
const setFooter = vi.fn();
|
||||||
|
const resetToDefaults = vi.fn();
|
||||||
|
let mockSiteDomain = 'example.com';
|
||||||
|
|
||||||
|
vi.mock('../../state/PageContext', () => ({
|
||||||
|
usePages: () => ({ replaceAllPages, setHeader, setFooter, pages: [], siteDesign: {} }),
|
||||||
|
}));
|
||||||
|
vi.mock('../../state/SiteDesignContext', () => ({
|
||||||
|
useSiteDesign: () => ({ design: {}, updateDesign: vi.fn(), resetToDefaults }),
|
||||||
|
DEFAULT_SITE_DESIGN: {},
|
||||||
|
}));
|
||||||
|
vi.mock('../../state/EditorConfigContext', () => ({
|
||||||
|
useEditorConfig: () => ({ whpConfig: mockSiteDomain ? { siteDomain: mockSiteDomain } : null, isWHP: !!mockSiteDomain }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SiteDesignPanel, siteResetConfirmMatches } from './SiteDesignPanel';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(<SiteDesignPanel />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => { root.unmount(); });
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** React tracks a controlled `<input>`'s value via a wrapped native setter --
|
||||||
|
* a plain `input.value = x` assignment doesn't go through it, so React never
|
||||||
|
* sees the change and skips onChange. Using the real native setter (same
|
||||||
|
* pattern as `MediaStylePanel.slides.test.tsx`) makes the subsequent
|
||||||
|
* `input` event register as a genuine value change. */
|
||||||
|
function setInputValue(input: HTMLInputElement, value: string) {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||||
|
setter.call(input, value);
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reach past React's OWN disabled-button protection to grab the `onClick`
|
||||||
|
* function it attached to a DOM node, and call it directly.
|
||||||
|
*
|
||||||
|
* Verified empirically (see task-15 follow-up investigation) that neither
|
||||||
|
* `el.disabled = false; el.click()` nor a raw `dispatchEvent(new
|
||||||
|
* MouseEvent('click', ...))` actually invokes a React `onClick` handler once
|
||||||
|
* React has rendered the element with `disabled` truthy: react-dom's event
|
||||||
|
* system special-cases form controls and refuses to dispatch synthetic
|
||||||
|
* click/similar events against its own last-rendered `disabled` prop,
|
||||||
|
* regardless of what the live DOM property says. That's a second, redundant
|
||||||
|
* safety net on top of the native browser behavior -- but it also means a
|
||||||
|
* pure DOM-level "disabled bypass" can't reach `handleResetSite` at all
|
||||||
|
* through the button, and so can't exercise the handler's OWN guard the way
|
||||||
|
* this test needs to. Pulling `onClick` off the fiber's stashed props
|
||||||
|
* (`__reactProps$...`, the same object React itself calls into) and
|
||||||
|
* invoking it directly is what actually reaches `handleResetSite` -- the
|
||||||
|
* same code path a differently-wired future trigger (a keyboard shortcut, a
|
||||||
|
* second button, a copy-paste bug) would also go through without passing
|
||||||
|
* back through the `disabled` gate at all.
|
||||||
|
*/
|
||||||
|
function invokeReactOnClick(el: HTMLElement): void {
|
||||||
|
const propsKey = Object.keys(el).find((k) => k.startsWith('__reactProps$'));
|
||||||
|
const onClick = propsKey ? (el as unknown as Record<string, { onClick?: () => void }>)[propsKey].onClick : undefined;
|
||||||
|
if (!onClick) throw new Error('no React onClick prop found on element');
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('siteResetConfirmMatches -- the guard predicate itself', () => {
|
||||||
|
test('matches only an exact (trimmed) domain match', () => {
|
||||||
|
expect(siteResetConfirmMatches('example.com', 'example.com')).toBe(true);
|
||||||
|
expect(siteResetConfirmMatches('example.co', 'example.com')).toBe(false);
|
||||||
|
expect(siteResetConfirmMatches('', 'example.com')).toBe(false);
|
||||||
|
expect(siteResetConfirmMatches(' example.com ', 'example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty domain never matches, even against an empty typed value -- ' +
|
||||||
|
'this is the guard that must hold even if the (currently hidden) entry ' +
|
||||||
|
'point were ever reachable in standalone mode', () => {
|
||||||
|
expect(siteResetConfirmMatches('', '')).toBe(false);
|
||||||
|
expect(siteResetConfirmMatches(' ', '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reset Entire Site -- guard + wiring (mocked hooks)', () => {
|
||||||
|
test('the confirm button is disabled until the domain is typed exactly', () => {
|
||||||
|
mockSiteDomain = 'example.com';
|
||||||
|
render();
|
||||||
|
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
|
||||||
|
|
||||||
|
const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement;
|
||||||
|
expect(confirm.disabled).toBe(true);
|
||||||
|
|
||||||
|
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
|
||||||
|
act(() => { setInputValue(input, 'example.co'); });
|
||||||
|
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
|
||||||
|
act(() => { setInputValue(input, 'example.com'); });
|
||||||
|
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirming blanks pages, header, footer and design tokens', () => {
|
||||||
|
mockSiteDomain = 'example.com';
|
||||||
|
replaceAllPages.mockClear();
|
||||||
|
setHeader.mockClear();
|
||||||
|
setFooter.mockClear();
|
||||||
|
resetToDefaults.mockClear();
|
||||||
|
|
||||||
|
render();
|
||||||
|
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
|
||||||
|
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
|
||||||
|
act(() => { setInputValue(input, 'example.com'); });
|
||||||
|
act(() => { (container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click(); });
|
||||||
|
|
||||||
|
expect(replaceAllPages).toHaveBeenCalledTimes(1);
|
||||||
|
const pagesArg = replaceAllPages.mock.calls[0][0];
|
||||||
|
expect(pagesArg).toHaveLength(1);
|
||||||
|
expect(pagesArg[0].name).toBe('Home');
|
||||||
|
// The replacement is a SerializedTreeNode (recursive tree), not a flat
|
||||||
|
// craft-state entry: a real Container root with no children.
|
||||||
|
expect(pagesArg[0].tree.type.resolvedName).toBe('Container');
|
||||||
|
expect(pagesArg[0].tree.nodes).toEqual([]);
|
||||||
|
|
||||||
|
expect(setHeader).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setHeader.mock.calls[0][0].type.resolvedName).toBe('Container');
|
||||||
|
expect(setFooter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setFooter.mock.calls[0][0].type.resolvedName).toBe('Container');
|
||||||
|
expect(resetToDefaults).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The panel closes its own dialog back up after confirming.
|
||||||
|
expect(container.querySelector('[data-action="confirm-site-reset"]')).toBeNull();
|
||||||
|
expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the handler itself refuses a non-matching value, even when its onClick is invoked ' +
|
||||||
|
'directly (bypassing `disabled` and React\'s own disabled-click suppression) -- proves ' +
|
||||||
|
'the guard is load-bearing in handleResetSite, not just a UI affordance a different ' +
|
||||||
|
'trigger path could route around', () => {
|
||||||
|
mockSiteDomain = 'example.com';
|
||||||
|
replaceAllPages.mockClear();
|
||||||
|
setHeader.mockClear();
|
||||||
|
setFooter.mockClear();
|
||||||
|
resetToDefaults.mockClear();
|
||||||
|
|
||||||
|
render();
|
||||||
|
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
|
||||||
|
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
|
||||||
|
act(() => { setInputValue(input, 'not-the-domain'); });
|
||||||
|
|
||||||
|
const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement;
|
||||||
|
expect(confirm.disabled).toBe(true); // sanity: the UI affordance is still doing its job too
|
||||||
|
|
||||||
|
act(() => { invokeReactOnClick(confirm); });
|
||||||
|
|
||||||
|
expect(replaceAllPages).not.toHaveBeenCalled();
|
||||||
|
expect(setHeader).not.toHaveBeenCalled();
|
||||||
|
expect(setFooter).not.toHaveBeenCalled();
|
||||||
|
expect(resetToDefaults).not.toHaveBeenCalled();
|
||||||
|
// The dialog is still open -- handleResetSite returned early before
|
||||||
|
// reaching its own close-the-dialog cleanup.
|
||||||
|
expect(container.querySelector('[data-action="confirm-site-reset"]')).toBeTruthy();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standalone mode (no WHP_CONFIG, siteDomain "") hides the entry point entirely, ' +
|
||||||
|
'while non-standalone mode shows it -- an empty typed input must never trivially ' +
|
||||||
|
'satisfy the guard. Asserted as a contrast within one test (not "absence" alone) ' +
|
||||||
|
'so this fails if the whole feature -- not just the guard -- were ever removed.', () => {
|
||||||
|
mockSiteDomain = '';
|
||||||
|
render();
|
||||||
|
expect(container.querySelector('[data-action="open-site-reset"]')).toBeNull();
|
||||||
|
expect(container.querySelector('[data-testid="site-reset-domain"]')).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain('Danger zone');
|
||||||
|
expect(container.textContent).not.toContain('Reset Entire Site');
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
mockSiteDomain = 'example.com';
|
||||||
|
render();
|
||||||
|
expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy();
|
||||||
|
expect(container.textContent).toContain('Danger zone');
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
|
import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
|
||||||
|
import { usePages } from '../../state/PageContext';
|
||||||
|
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||||
|
import { SerializedTreeNode } from '../../types/sitesmith';
|
||||||
import { FONT_FAMILIES } from '../../constants/presets';
|
import { FONT_FAMILIES } from '../../constants/presets';
|
||||||
import { AssetPicker } from '../../ui/AssetPicker';
|
import { AssetPicker } from '../../ui/AssetPicker';
|
||||||
|
import { inputStyle } from './styles/shared';
|
||||||
|
|
||||||
type DesignTab = 'basic' | 'advanced';
|
type DesignTab = 'basic' | 'advanced';
|
||||||
|
|
||||||
@@ -156,12 +160,94 @@ const NavStyleField: React.FC<NavStyleFieldProps> = ({ value, onChange }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ---------- Reset Entire Site (danger zone) ---------- */
|
||||||
|
|
||||||
|
// Blank replacement trees for the "Reset Entire Site" escape hatch. Shaped
|
||||||
|
// as a `SerializedTreeNode` (type + props + nodes), NOT a flat Craft.js
|
||||||
|
// state entry -- this is what `replaceAllPages`/`setHeader`/`setFooter`
|
||||||
|
// feed into `treeToCraftState` -> `sanitizeAiTree`/`flattenTreeForCraft`
|
||||||
|
// (see `state/PageContext.tsx`/`utils/craft-tree.ts`). A single `Container`
|
||||||
|
// root with no children mirrors the (unexported) EMPTY_CANVAS/EMPTY_HEADER/
|
||||||
|
// EMPTY_FOOTER constants already used elsewhere for "blank".
|
||||||
|
const BLANK_PAGE_TREE: SerializedTreeNode = {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
props: { style: { minHeight: '100vh', backgroundColor: '#ffffff' }, tag: 'div' },
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLANK_HEADER_TREE: SerializedTreeNode = {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
props: {
|
||||||
|
style: { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' },
|
||||||
|
tag: 'header',
|
||||||
|
},
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BLANK_FOOTER_TREE: SerializedTreeNode = {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
props: {
|
||||||
|
style: { minHeight: '60px', backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 24px', textAlign: 'center' },
|
||||||
|
tag: 'footer',
|
||||||
|
},
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for "has the user typed enough to arm the confirm
|
||||||
|
* button" -- used for the `disabled` attribute/cursor/opacity AND, more
|
||||||
|
* importantly, inside `handleResetSite` itself. `disabled` is a UI
|
||||||
|
* affordance, not a safety mechanism (it only stops a plain mouse click);
|
||||||
|
* for the one action in this app that irreversibly wipes a user's whole
|
||||||
|
* site draft, the real guard has to live in the handler, checked against
|
||||||
|
* this exact same predicate rather than a second hand-rolled comparison
|
||||||
|
* that could drift out of sync with it.
|
||||||
|
*
|
||||||
|
* An empty/falsy `domain` always returns `false`, even if `typed` is also
|
||||||
|
* empty -- `''.trim() === ''.trim()` would otherwise "match" trivially.
|
||||||
|
* Standalone mode (no `WHP_CONFIG`) has `siteDomain === ''`, and while the
|
||||||
|
* entry point that would let a user reach this code is hidden in that case
|
||||||
|
* (see the `siteDomain &&` guard around the whole danger zone below), this
|
||||||
|
* function must not depend on that -- it has to fail safe on its own if
|
||||||
|
* ever reached with no configured domain.
|
||||||
|
*
|
||||||
|
* Exported so it's directly unit-testable without needing a way to render
|
||||||
|
* the (deliberately unreachable-when-`siteDomain`-is-empty) confirm UI.
|
||||||
|
*/
|
||||||
|
export function siteResetConfirmMatches(typed: string, domain: string): boolean {
|
||||||
|
return !!domain && typed.trim() === domain.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Main SiteDesignPanel ---------- */
|
/* ---------- Main SiteDesignPanel ---------- */
|
||||||
|
|
||||||
export const SiteDesignPanel: React.FC = () => {
|
export const SiteDesignPanel: React.FC = () => {
|
||||||
const { design, updateDesign, resetToDefaults } = useSiteDesign();
|
const { design, updateDesign, resetToDefaults } = useSiteDesign();
|
||||||
|
const { replaceAllPages, setHeader, setFooter } = usePages();
|
||||||
|
const { whpConfig } = useEditorConfig();
|
||||||
|
// Standalone mode (no WHP_CONFIG) has no site domain to confirm against --
|
||||||
|
// an empty typed input would then trivially "match" an empty siteDomain,
|
||||||
|
// arming the destructive confirm button with no guard at all. The entry
|
||||||
|
// point itself is hidden in that case (see the `siteDomain &&` guard below).
|
||||||
|
const siteDomain = whpConfig?.siteDomain ?? '';
|
||||||
|
const [siteResetOpen, setSiteResetOpen] = useState(false);
|
||||||
|
const [siteResetTyped, setSiteResetTyped] = useState('');
|
||||||
const [tab, setTab] = useState<DesignTab>('basic');
|
const [tab, setTab] = useState<DesignTab>('basic');
|
||||||
|
|
||||||
|
const handleResetSite = (): void => {
|
||||||
|
// Load-bearing guard -- see `siteResetConfirmMatches`'s docstring. Not
|
||||||
|
// just a UI nicety: this must hold even if the confirm button's
|
||||||
|
// `disabled` attribute were ever bypassed (a synthetic click, or a
|
||||||
|
// future refactor that drops it).
|
||||||
|
if (!siteResetConfirmMatches(siteResetTyped, siteDomain)) return;
|
||||||
|
|
||||||
|
replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }]);
|
||||||
|
setHeader(BLANK_HEADER_TREE);
|
||||||
|
setFooter(BLANK_FOOTER_TREE);
|
||||||
|
resetToDefaults();
|
||||||
|
setSiteResetOpen(false);
|
||||||
|
setSiteResetTyped('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -371,6 +457,85 @@ export const SiteDesignPanel: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Entire Site (danger zone) -- hidden entirely in standalone
|
||||||
|
mode, where siteDomain is '' and typing nothing would trivially
|
||||||
|
satisfy an empty-string match. */}
|
||||||
|
{siteDomain && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 18, padding: 12,
|
||||||
|
border: '1px solid rgba(239,68,68,0.35)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
background: 'rgba(239,68,68,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 6 }}>
|
||||||
|
Danger zone
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!siteResetOpen ? (
|
||||||
|
<button
|
||||||
|
data-action="open-site-reset"
|
||||||
|
onClick={() => setSiteResetOpen(true)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '8px 12px', fontSize: 11, fontWeight: 600,
|
||||||
|
color: '#ef4444', background: 'transparent',
|
||||||
|
border: '1px solid rgba(239,68,68,0.5)', borderRadius: 'var(--radius-sm)', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Entire Site
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5, margin: '0 0 8px' }}>
|
||||||
|
This blanks <strong>every page</strong>, the header, the footer, all
|
||||||
|
design tokens (colors, fonts, radii), and your custom head code and
|
||||||
|
favicon, leaving one empty Home page. Uploaded images are kept.
|
||||||
|
<strong> This cannot be undone.</strong> Your published site stays as it
|
||||||
|
is until you publish again — but the editor auto-saves roughly every 30
|
||||||
|
seconds, so the blank version becomes your saved draft shortly after.
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--color-text-muted)', margin: '0 0 4px' }}>
|
||||||
|
Type <code>{siteDomain}</code> to confirm:
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
data-testid="site-reset-domain"
|
||||||
|
value={siteResetTyped}
|
||||||
|
onChange={(e) => setSiteResetTyped(e.target.value)}
|
||||||
|
placeholder={siteDomain}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||||
|
<button
|
||||||
|
data-action="confirm-site-reset"
|
||||||
|
disabled={!siteResetConfirmMatches(siteResetTyped, siteDomain)}
|
||||||
|
onClick={handleResetSite}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '7px 10px', fontSize: 11, fontWeight: 600,
|
||||||
|
color: '#fff', background: '#ef4444', border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
cursor: siteResetConfirmMatches(siteResetTyped, siteDomain) ? 'pointer' : 'not-allowed',
|
||||||
|
opacity: siteResetConfirmMatches(siteResetTyped, siteDomain) ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset everything
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSiteResetOpen(false); setSiteResetTyped(''); }}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '7px 10px', fontSize: 11, fontWeight: 600,
|
||||||
|
color: 'var(--color-text-muted)', background: 'var(--color-bg-elevated)',
|
||||||
|
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { CollapsibleSection, ArrayPropEditor, smallInputStyle } from './shared';
|
import { CollapsibleSection, ArrayPropEditor, smallInputStyle } from './shared';
|
||||||
|
import { useLayerFocus } from '../../left/LayerFocusContext';
|
||||||
|
|
||||||
/* ---------- Shared array-item field editor ----------
|
/* ---------- Shared array-item field editor ----------
|
||||||
Extracted from SectionTypePanel and GenericPropsEditor, which both had a
|
Extracted from SectionTypePanel and GenericPropsEditor, which both had a
|
||||||
@@ -18,7 +19,26 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
|
|||||||
const sampleItem = arrayItems[0] || {};
|
const sampleItem = arrayItems[0] || {};
|
||||||
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
|
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
|
||||||
|
|
||||||
|
// Layers panel -> array editor "scroll to this item" hookup. The outer
|
||||||
|
// <div ref={rootRef}> wraps ArrayPropEditor's rendered cards (the actual
|
||||||
|
// per-item background box lives in shared.tsx's ArrayPropEditor, which is
|
||||||
|
// also used by MediaStylePanel/FormStylePanel -- rather than touch that
|
||||||
|
// shared component for one consumer, the data-array-item tag below goes
|
||||||
|
// on the content renderItem returns, which is enough for scrollIntoView
|
||||||
|
// to bring the right card into the viewport.
|
||||||
|
const { focus } = useLayerFocus();
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focus || focus.prop !== propKey) return;
|
||||||
|
const card = rootRef.current?.querySelector(`[data-array-item="${propKey}:${focus.index}"]`);
|
||||||
|
card?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
// `focus.nonce` is in the dep list so clicking the SAME row twice
|
||||||
|
// re-scrolls (the request object is otherwise identical).
|
||||||
|
}, [focus?.nonce, focus?.prop, focus?.index, propKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div ref={rootRef}>
|
||||||
<CollapsibleSection title={propKey.replace(/([A-Z])/g, ' $1').trim()}>
|
<CollapsibleSection title={propKey.replace(/([A-Z])/g, ' $1').trim()}>
|
||||||
<ArrayPropEditor
|
<ArrayPropEditor
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
@@ -29,6 +49,7 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
data-array-item={`${propKey}:${index}`}
|
||||||
value={String(item)}
|
value={String(item)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
actions.setProp(selectedId, (props: any) => {
|
actions.setProp(selectedId, (props: any) => {
|
||||||
@@ -42,7 +63,7 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<div data-array-item={`${propKey}:${index}`} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
{itemFields.map((field) => {
|
{itemFields.map((field) => {
|
||||||
const fieldVal = item[field];
|
const fieldVal = item[field];
|
||||||
if (typeof fieldVal === 'boolean') {
|
if (typeof fieldVal === 'boolean') {
|
||||||
@@ -123,5 +144,6 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { labelStyle, inputStyle, sectionGap } from './shared';
|
import { labelStyle, inputStyle, sectionGap } from './shared';
|
||||||
import { AssetPicker } from '../../../ui/AssetPicker';
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||||
|
import { useLayerFocus } from '../../left/LayerFocusContext';
|
||||||
|
|
||||||
interface Feature {
|
interface Feature {
|
||||||
title?: string; description?: string; icon?: string;
|
title?: string; description?: string; icon?: string;
|
||||||
@@ -27,10 +28,24 @@ export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }>
|
|||||||
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
|
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
|
||||||
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
|
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
|
||||||
|
|
||||||
|
// Layers panel -> array editor "scroll to this item" hookup. See
|
||||||
|
// ArrayItemFields.tsx for the generic-editor counterpart; this component
|
||||||
|
// keeps its own per-feature cards, so it tags/scrolls them directly.
|
||||||
|
const { focus } = useLayerFocus();
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focus || focus.prop !== 'features') return;
|
||||||
|
const card = rootRef.current?.querySelector(`[data-array-item="features:${focus.index}"]`);
|
||||||
|
card?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
// `focus.nonce` is in the dep list so clicking the SAME row twice
|
||||||
|
// re-scrolls (the request object is otherwise identical).
|
||||||
|
}, [focus?.nonce, focus?.prop, focus?.index]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div ref={rootRef} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{list.map((feat, i) => (
|
{list.map((feat, i) => (
|
||||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div key={i} data-array-item={`features:${i}`} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
<input type="text" value={feat.title || ''} onChange={(e) => update(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
|
<input type="text" value={feat.title || ''} onChange={(e) => update(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
|
||||||
<button onClick={() => remove(i)} title="Remove" style={{ padding: '2px 8px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}>×</button>
|
<button onClick={() => remove(i)} title="Remove" style={{ padding: '2px 8px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}>×</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { storeWebhookSecret } from '../../../utils/form-webhook-secret';
|
||||||
import {
|
import {
|
||||||
BG_COLORS,
|
BG_COLORS,
|
||||||
SPACING_PRESETS,
|
SPACING_PRESETS,
|
||||||
@@ -58,12 +59,125 @@ const SPACING_SIDE_KEYS: { side: 'top' | 'right' | 'bottom' | 'left'; suffix: 'T
|
|||||||
{ side: 'left', suffix: 'Left' },
|
{ side: 'left', suffix: 'Left' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const hintStyle: React.CSSProperties = { fontSize: 10, color: '#71717a', margin: '4px 0 0' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this look like a webhook URL the publish step will accept?
|
||||||
|
*
|
||||||
|
* Mirrors the shape `FormRelayProvisioner::upsertWebhookToken()` enforces
|
||||||
|
* (absolute https, a host, no whitespace/control characters) closely enough to
|
||||||
|
* warn in the panel. It is a HINT, not a gate -- the server-side check is the
|
||||||
|
* real one, and this deliberately never edits or blocks the value.
|
||||||
|
*/
|
||||||
|
export function isHttpsWebhookUrl(value: unknown): boolean {
|
||||||
|
const v = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (v === '' || /[\s\x00-\x1F\x7F]/.test(v)) return false;
|
||||||
|
try {
|
||||||
|
const u = new URL(v);
|
||||||
|
return u.protocol === 'https:' && u.hostname !== '';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Webhook shared secret: WRITE-ONLY field ----------
|
||||||
|
The raw secret lives in this component's local state and nowhere else. On
|
||||||
|
blur it is POSTed to the panel endpoint, which returns an opaque id; only
|
||||||
|
that id is handed to `onStored` (and thence to a craft prop). The field is
|
||||||
|
then cleared, because there is no read route and nothing to show back --
|
||||||
|
the UI offers set / replace / clear, never "view".
|
||||||
|
|
||||||
|
MOUNT THIS WITH `key={selectedId}`. GuidedStyles renders <FormStylePanel>
|
||||||
|
with no key, so a selection change re-renders this component rather than
|
||||||
|
remounting it, and React keeps `draft`/`status`. A failed store deliberately
|
||||||
|
RETAINS the draft (so a 429 or a network blip doesn't make the customer
|
||||||
|
retype a pasted key) -- which means without the key, clicking a second
|
||||||
|
contact form shows node A's raw secret in node B's field, and the next blur
|
||||||
|
assigns the returned id to the wrong form and burns a slot against the
|
||||||
|
per-site cap. The `status` banner leaks the same way. */
|
||||||
|
export const WebhookSecretField: React.FC<{
|
||||||
|
secretId: string;
|
||||||
|
onStored: (secretId: string) => void;
|
||||||
|
onRemoved: () => void;
|
||||||
|
}> = ({ secretId, onStored, onRemoved }) => {
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const raw = draft.trim();
|
||||||
|
if (raw === '' || status === 'saving') return;
|
||||||
|
setStatus('saving');
|
||||||
|
setError('');
|
||||||
|
const result = await storeWebhookSecret(raw);
|
||||||
|
if (result.ok && result.secretId) {
|
||||||
|
// Only the id crosses this line. The raw value is dropped here and is
|
||||||
|
// never written to a prop, to storage, or back into the input.
|
||||||
|
onStored(result.secretId);
|
||||||
|
setDraft('');
|
||||||
|
setStatus('saved');
|
||||||
|
} else {
|
||||||
|
setStatus('error');
|
||||||
|
setError(result.error || 'Could not store the secret.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle} htmlFor="whp-webhook-secret">Shared secret (optional)</label>
|
||||||
|
<input
|
||||||
|
id="whp-webhook-secret"
|
||||||
|
data-testid="webhook-secret-input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => { setDraft(e.target.value); if (status !== 'idle') { setStatus('idle'); setError(''); } }}
|
||||||
|
onBlur={() => { void save(); }}
|
||||||
|
placeholder={secretId ? 'Paste a new secret to replace' : 'Paste the secret from your receiver'}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
{secretId && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
|
||||||
|
<span data-testid="webhook-secret-status" style={{ fontSize: 10, color: '#22c55e' }}>Secret stored</span>
|
||||||
|
{/* "Clear", not "Remove": this only drops the form's reference to the
|
||||||
|
key. The stored key file stays on the server and still counts
|
||||||
|
toward the per-site cap -- nothing deletes one, so a button
|
||||||
|
labelled Remove would be telling the customer they had reclaimed
|
||||||
|
a slot right up until the 429 that says otherwise. */}
|
||||||
|
<button
|
||||||
|
data-testid="webhook-secret-remove"
|
||||||
|
onClick={onRemoved}
|
||||||
|
style={{ ...moveBtnStyle, flex: 'none', padding: '3px 8px' }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={hintStyle}>
|
||||||
|
Clearing stops this form using the secret; the stored key stays on the server.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === 'saving' && <p style={hintStyle}>Storing…</p>}
|
||||||
|
{status === 'saved' && <p data-testid="webhook-secret-saved" style={{ ...hintStyle, color: '#22c55e' }}>Secret stored.</p>}
|
||||||
|
{status === 'error' && <p data-testid="webhook-secret-error" style={{ ...hintStyle, color: '#f87171' }}>{error}</p>}
|
||||||
|
<p style={hintStyle}>
|
||||||
|
Stored on the server and never shown again — paste a new one to replace it. Used to sign
|
||||||
|
(or authorise) each delivery so your receiver can verify it came from this site.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/* ---------- FORM ---------- */
|
/* ---------- FORM ---------- */
|
||||||
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const { actions } = useEditor();
|
const { actions } = useEditor();
|
||||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||||
|
|
||||||
const style = nodeProps.style || {};
|
const style = nodeProps.style || {};
|
||||||
|
// ContactForm only: FormContainer/SubscribeForm have no destinationType prop,
|
||||||
|
// so their relay controls stay exactly as they were.
|
||||||
|
const isWebhook = nodeProps.destinationType === 'webhook';
|
||||||
|
|
||||||
const updateField = (index: number, patch: Record<string, any>) => {
|
const updateField = (index: number, patch: Record<string, any>) => {
|
||||||
actions.setProp(selectedId, (props: any) => {
|
actions.setProp(selectedId, (props: any) => {
|
||||||
@@ -158,14 +272,94 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
|||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Destination: email (default, unchanged behaviour) or webhook. Gated on
|
||||||
|
the `destinationType` default in ContactForm.craft.props -- a prop
|
||||||
|
missing from those defaults is `undefined` here and the control would
|
||||||
|
simply never render. */}
|
||||||
|
{nodeProps.destinationType !== undefined && (
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>Send submissions to</label>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{[{ v: 'email', l: 'Email' }, { v: 'webhook', l: 'Webhook' }].map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.v}
|
||||||
|
data-testid={`destination-${o.v}`}
|
||||||
|
onClick={() => setProp('destinationType', o.v)}
|
||||||
|
style={btnActiveStyle((nodeProps.destinationType || 'email') === o.v)}
|
||||||
|
>
|
||||||
|
{o.l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nodeProps.destinationType !== undefined && isWebhook && (
|
||||||
|
<>
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>Webhook URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
data-testid="webhook-url"
|
||||||
|
value={nodeProps.webhookUrl || ''}
|
||||||
|
onChange={(e) => setProp('webhookUrl', e.target.value)}
|
||||||
|
placeholder="https://hooks.example.com/..."
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
{/* The publish step DOES refuse a blank/non-https URL -- loudly, but
|
||||||
|
into an error_log the customer never reads, leaving them with a
|
||||||
|
form that just doesn't work. Warn here instead. Deliberately a
|
||||||
|
warning only: not blanking the value and not blocking the
|
||||||
|
publish, since either would trade a loud server-side refusal for
|
||||||
|
a silently inert form. */}
|
||||||
|
{!isHttpsWebhookUrl(nodeProps.webhookUrl) && (
|
||||||
|
<p data-testid="webhook-url-warning" style={{ ...hintStyle, color: '#fbbf24' }}>
|
||||||
|
{nodeProps.webhookUrl
|
||||||
|
? 'This must be an absolute https:// URL — submissions to this form won\'t be delivered until it is.'
|
||||||
|
: 'Enter the https:// URL to POST submissions to — this form won\'t deliver anything until you do.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p style={hintStyle}>
|
||||||
|
Must be an absolute <strong>https://</strong> URL. Each submission is POSTed as JSON;
|
||||||
|
failures are retried, then emailed to the fallback address below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>Authentication</label>
|
||||||
|
<select
|
||||||
|
data-testid="webhook-authmode"
|
||||||
|
value={nodeProps.webhookAuthMode || 'signature'}
|
||||||
|
onChange={(e) => setProp('webhookAuthMode', e.target.value)}
|
||||||
|
style={{ ...inputStyle, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<option value="signature">Signature (HMAC-SHA256 header)</option>
|
||||||
|
<option value="bearer">Bearer token (Authorization header)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<WebhookSecretField
|
||||||
|
/* Remounts on every selection change, so a retained draft (and its
|
||||||
|
status banner) can never follow the customer to another form --
|
||||||
|
see the comment on WebhookSecretField. */
|
||||||
|
key={selectedId}
|
||||||
|
secretId={nodeProps.webhookSecretId || ''}
|
||||||
|
onStored={(id) => setProp('webhookSecretId', id)}
|
||||||
|
onRemoved={() => setProp('webhookSecretId', '')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contact-form relay: where submissions are emailed. Present on ContactForm
|
{/* Contact-form relay: where submissions are emailed. Present on ContactForm
|
||||||
and FormContainer (both have recipientEmail/thankYouUrl props). */}
|
and FormContainer (both have recipientEmail/thankYouUrl props). With a
|
||||||
|
webhook destination this same address is the FALLBACK the relay uses
|
||||||
|
when delivery is exhausted. */}
|
||||||
{nodeProps.recipientEmail !== undefined && (
|
{nodeProps.recipientEmail !== undefined && (
|
||||||
<div style={sectionGap}>
|
<div style={sectionGap}>
|
||||||
<label style={labelStyle}>Send submissions to (email)</label>
|
<label style={labelStyle}>{isWebhook ? 'Fallback email (if the webhook fails)' : 'Send submissions to (email)'}</label>
|
||||||
<input type="email" value={nodeProps.recipientEmail || ''} onChange={(e) => setProp('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} />
|
<input type="email" value={nodeProps.recipientEmail || ''} onChange={(e) => setProp('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} />
|
||||||
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
|
<p style={hintStyle}>
|
||||||
Emailed via the site's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead.
|
{isWebhook
|
||||||
|
? 'Emailed here if the webhook keeps failing after retries. Leave blank to skip the fallback.'
|
||||||
|
: "Emailed via the site's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
/* Same DOM harness + craftjs mock as NavStylePanel.test.tsx (no
|
||||||
|
@testing-library/react in this repo). `lastProps` IS the node's prop bag: the
|
||||||
|
mocked setProp mutates it exactly as Craft.js would, which is what lets the
|
||||||
|
"no raw secret ever reaches a prop" assertion below be a real check on
|
||||||
|
everything the panel writes rather than on a hand-picked key. */
|
||||||
|
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
|
||||||
|
updater(lastProps);
|
||||||
|
});
|
||||||
|
let lastProps: any;
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({ actions: { setProp: setPropSpy } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { FormStylePanel, isHttpsWebhookUrl } from './FormStylePanel';
|
||||||
|
|
||||||
|
const RAW_SECRET = 'hunter2-SUPER-SECRET-VALUE';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rerender(ui: React.ReactElement) {
|
||||||
|
act(() => { root.render(ui); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => { root.unmount(); });
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValue(el: HTMLInputElement | HTMLSelectElement, value: string) {
|
||||||
|
const proto = el instanceof HTMLSelectElement ? window.HTMLSelectElement.prototype : window.HTMLInputElement.prototype;
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(proto, 'value')!.set!;
|
||||||
|
act(() => {
|
||||||
|
setter.call(el, value);
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(el: Element | null) {
|
||||||
|
if (!el) throw new Error('element not found');
|
||||||
|
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* React 17+ implements onBlur with the native `focusout` event (which bubbles),
|
||||||
|
not `blur`. */
|
||||||
|
async function blur(el: Element) {
|
||||||
|
await act(async () => {
|
||||||
|
el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const testid = (id: string) => container.querySelector(`[data-testid="${id}"]`);
|
||||||
|
|
||||||
|
function contactFormProps(over: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
fields: [],
|
||||||
|
style: {},
|
||||||
|
recipientEmail: '',
|
||||||
|
thankYouUrl: '',
|
||||||
|
destinationType: 'email',
|
||||||
|
webhookUrl: '',
|
||||||
|
webhookSecretId: '',
|
||||||
|
webhookAuthMode: 'signature',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setPropSpy.mockClear();
|
||||||
|
(window as any).WHP_CONFIG = { apiUrl: '/api/site-builder.php', csrfToken: 'tok', siteId: 42 };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container) unmount();
|
||||||
|
delete (window as any).WHP_CONFIG;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FormStylePanel destination selector', () => {
|
||||||
|
test('choosing Webhook writes destinationType and reveals the webhook fields', () => {
|
||||||
|
lastProps = contactFormProps();
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
expect(testid('webhook-url')).toBeNull();
|
||||||
|
|
||||||
|
click(testid('destination-webhook'));
|
||||||
|
expect(lastProps.destinationType).toBe('webhook');
|
||||||
|
|
||||||
|
rerender(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
expect(testid('webhook-url')).toBeTruthy();
|
||||||
|
expect(testid('webhook-authmode')).toBeTruthy();
|
||||||
|
expect(testid('webhook-secret-input')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the URL and auth-mode controls write their props', () => {
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
setValue(testid('webhook-url') as HTMLInputElement, 'https://hooks.example.com/x');
|
||||||
|
expect(lastProps.webhookUrl).toBe('https://hooks.example.com/x');
|
||||||
|
setValue(testid('webhook-authmode') as HTMLSelectElement, 'bearer');
|
||||||
|
expect(lastProps.webhookAuthMode).toBe('bearer');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the recipient field is relabelled as the fallback address for a webhook', () => {
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
const labels = Array.from(container.querySelectorAll('label')).map((l) => l.textContent);
|
||||||
|
expect(labels.some((t) => t?.includes('Fallback email'))).toBe(true);
|
||||||
|
expect(labels.some((t) => t === 'Send submissions to (email)')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a component without destinationType (FormContainer) shows neither the selector nor the webhook fields', () => {
|
||||||
|
lastProps = { recipientEmail: '', thankYouUrl: '', style: {} };
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
expect(testid('destination-webhook')).toBeNull();
|
||||||
|
expect(testid('webhook-url')).toBeNull();
|
||||||
|
const labels = Array.from(container.querySelectorAll('label')).map((l) => l.textContent);
|
||||||
|
expect(labels).toContain('Send submissions to (email)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FormStylePanel webhook secret field is WRITE-ONLY', () => {
|
||||||
|
test('blurring the secret POSTs it and persists ONLY the returned id', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: async () => ({ success: true, secret_id: 'whs_42_deadbeefdeadbeef' }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||||
|
setValue(input, RAW_SECRET);
|
||||||
|
await blur(input);
|
||||||
|
|
||||||
|
// The flow really ran (otherwise the assertions below would pass vacuously).
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(JSON.parse(fetchMock.mock.calls[0][1].body).secret).toBe(RAW_SECRET);
|
||||||
|
expect(lastProps.webhookSecretId).toBe('whs_42_deadbeefdeadbeef');
|
||||||
|
|
||||||
|
// THE PROPERTY: nothing the panel wrote to the node's props contains the raw
|
||||||
|
// secret, under any key -- craft props are serialised into the saved project
|
||||||
|
// and into published output.
|
||||||
|
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
|
||||||
|
for (const [, updater] of setPropSpy.mock.calls) {
|
||||||
|
const probe: any = {};
|
||||||
|
(updater as (p: any) => void)(probe);
|
||||||
|
expect(JSON.stringify(probe)).not.toContain(RAW_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...and the input is cleared, so it isn't sitting in the DOM either.
|
||||||
|
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe('');
|
||||||
|
expect(testid('webhook-secret-saved')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('typing a secret without blurring writes nothing at all', () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
setValue(testid('webhook-secret-input') as HTMLInputElement, RAW_SECRET);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed store surfaces the endpoint message and persists no id', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: false, status: 429,
|
||||||
|
json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||||
|
setValue(input, RAW_SECRET);
|
||||||
|
await blur(input);
|
||||||
|
|
||||||
|
expect(testid('webhook-secret-error')!.textContent).toContain('Too many webhook secrets stored for this site');
|
||||||
|
expect(lastProps.webhookSecretId).toBe('');
|
||||||
|
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the stored secret is never shown -- only its state, with a Clear action', () => {
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
expect(testid('webhook-secret-status')!.textContent).toContain('Secret stored');
|
||||||
|
// The field is a password input that starts empty: there is no read route to
|
||||||
|
// populate it from, and nothing anywhere renders the value.
|
||||||
|
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||||
|
expect(input.type).toBe('password');
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
|
||||||
|
click(testid('webhook-secret-remove'));
|
||||||
|
expect(lastProps.webhookSecretId).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blurring an empty secret field posts nothing', async () => {
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
await blur(testid('webhook-secret-input')!);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Clear action says what it does: the stored key stays on the server', () => {
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
// "Remove" would be a lie: nothing deletes the key file, and it keeps
|
||||||
|
// counting toward the per-site cap the customer eventually 429s against.
|
||||||
|
expect(testid('webhook-secret-remove')!.textContent).toBe('Clear');
|
||||||
|
expect(container.textContent).toContain('the stored key stays on the server');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FormStylePanel: a retained secret draft never follows the selection to another form', () => {
|
||||||
|
/* GuidedStyles renders <FormStylePanel> with no key, so a selection change
|
||||||
|
re-renders rather than remounts. A failed store deliberately KEEPS the
|
||||||
|
draft, so without a remount the next form's field would open holding the
|
||||||
|
previous form's raw secret -- and the next blur would POST it and assign
|
||||||
|
the returned id to the wrong node. */
|
||||||
|
test('after a failed store on node A, selecting node B shows an empty field and no status', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: false, status: 429,
|
||||||
|
json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nodeA = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://a.example/x' });
|
||||||
|
lastProps = nodeA;
|
||||||
|
render(<FormStylePanel selectedId="nodeA" nodeProps={nodeA} />);
|
||||||
|
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||||
|
setValue(input, RAW_SECRET);
|
||||||
|
await blur(input);
|
||||||
|
|
||||||
|
// Precondition: the draft really was retained on node A (otherwise this
|
||||||
|
// test would pass for the wrong reason).
|
||||||
|
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe(RAW_SECRET);
|
||||||
|
expect(testid('webhook-secret-error')).toBeTruthy();
|
||||||
|
|
||||||
|
const nodeB = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://b.example/y' });
|
||||||
|
lastProps = nodeB;
|
||||||
|
rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);
|
||||||
|
|
||||||
|
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe('');
|
||||||
|
expect(container.textContent).not.toContain(RAW_SECRET);
|
||||||
|
expect(testid('webhook-secret-error')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a "Secret stored." banner does not follow the selection either', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: async () => ({ success: true, secret_id: 'whs_42_deadbeefdeadbeef' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||||
|
render(<FormStylePanel selectedId="nodeA" nodeProps={lastProps} />);
|
||||||
|
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||||
|
setValue(input, RAW_SECRET);
|
||||||
|
await blur(input);
|
||||||
|
expect(testid('webhook-secret-saved')).toBeTruthy();
|
||||||
|
|
||||||
|
const nodeB = contactFormProps({ destinationType: 'webhook' });
|
||||||
|
lastProps = nodeB;
|
||||||
|
rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);
|
||||||
|
expect(testid('webhook-secret-saved')).toBeNull();
|
||||||
|
expect(testid('webhook-secret-status')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isHttpsWebhookUrl / the inline URL warning', () => {
|
||||||
|
test.each([
|
||||||
|
['https://hooks.example.com/x', true],
|
||||||
|
['https://hooks.example.com/x?a=1&b=2', true],
|
||||||
|
['http://hooks.example.com/x', false],
|
||||||
|
['hooks.example.com/x', false],
|
||||||
|
['/relative/path', false],
|
||||||
|
['', false],
|
||||||
|
[' ', false],
|
||||||
|
['https://hooks.example.com/x\nHost: evil', false],
|
||||||
|
['javascript:alert(1)', false],
|
||||||
|
])('%s -> %s', (value, expected) => {
|
||||||
|
expect(isHttpsWebhookUrl(value)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a blank URL warns that nothing will be delivered', () => {
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
expect(testid('webhook-url-warning')!.textContent).toContain("won't deliver anything");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-https URL warns without altering the value', () => {
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'http://hooks.example.com/x' });
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
expect(testid('webhook-url-warning')!.textContent).toContain('absolute https:// URL');
|
||||||
|
// Warning only -- the panel must not blank or rewrite the prop, which would
|
||||||
|
// turn the publish step's loud refusal into a silently inert form.
|
||||||
|
expect(lastProps.webhookUrl).toBe('http://hooks.example.com/x');
|
||||||
|
expect((testid('webhook-url') as HTMLInputElement).value).toBe('http://hooks.example.com/x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a valid https URL shows no warning', () => {
|
||||||
|
lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' });
|
||||||
|
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||||
|
expect(testid('webhook-url-warning')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
TEXT_COLORS,
|
TEXT_COLORS,
|
||||||
BG_COLORS,
|
BG_COLORS,
|
||||||
@@ -17,79 +17,7 @@ import {
|
|||||||
useNodeProp,
|
useNodeProp,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { ArrayItemFieldsEditor } from './ArrayItemFields';
|
import { ArrayItemFieldsEditor } from './ArrayItemFields';
|
||||||
import { Modal } from '../../../ui/Modal';
|
import { HtmlCodeField } from './HtmlCodeField';
|
||||||
import { CodeEditor } from '../../../ui/CodeEditor';
|
|
||||||
|
|
||||||
/* ---------- "Edit HTML" modal for the HtmlBlock `code` prop ----------
|
|
||||||
`code` is raw HTML (potentially many lines, embedded <style>/<script>),
|
|
||||||
so it gets a dedicated syntax-highlighted CodeEditor in a modal instead
|
|
||||||
of falling into the generic single-line/textarea string-prop rendering
|
|
||||||
below (see GenericPropsEditor's SKIP of the `code` key). */
|
|
||||||
const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
return (
|
|
||||||
<CollapsibleSection title="HTML Code">
|
|
||||||
<div style={sectionGap}>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '8px 12px', fontSize: 12, fontWeight: 600,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
||||||
background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46',
|
|
||||||
borderRadius: 6, cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="fa fa-code" /> Edit HTML
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Modal open={open} onClose={() => setOpen(false)} width="min(720px, 90vw)">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-bg-surface)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 12,
|
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Edit HTML</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
style={{
|
|
||||||
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
|
|
||||||
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className="fa fa-times" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: 16 }}>
|
|
||||||
<CodeEditor value={value} onChange={onChange} language="html" height={420} />
|
|
||||||
</div>
|
|
||||||
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'flex-end' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
style={{
|
|
||||||
padding: '7px 20px', fontSize: 13, fontWeight: 600,
|
|
||||||
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</CollapsibleSection>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
|
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
|
||||||
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
|
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { CollapsibleSection, sectionGap } from './shared';
|
||||||
|
import { Modal } from '../../../ui/Modal';
|
||||||
|
import { CodeEditor, type CodeEditorHandle } from '../../../ui/CodeEditor';
|
||||||
|
import { HtmlToolbar } from './HtmlToolbar';
|
||||||
|
import { formatHtml } from '../../../utils/format-html';
|
||||||
|
|
||||||
|
/* "Edit HTML" modal for the HtmlBlock `code` prop. `code` is raw HTML
|
||||||
|
(potentially many lines, embedded <style>/<script>), so it gets a
|
||||||
|
dedicated syntax-highlighted CodeEditor in a modal rather than the
|
||||||
|
generic single-line/textarea string-prop rendering. */
|
||||||
|
export const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const editorRef = useRef<CodeEditorHandle>(null);
|
||||||
|
const handleFormat = (): void => {
|
||||||
|
const current = editorRef.current?.getValue() ?? value;
|
||||||
|
onChange(formatHtml(current));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<CollapsibleSection title="HTML Code">
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '8px 12px', fontSize: 12, fontWeight: 600,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
|
background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46',
|
||||||
|
borderRadius: 6, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa fa-code" /> Edit HTML
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Modal open={open} onClose={() => setOpen(false)} width="min(720px, 90vw)">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-bg-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Edit HTML</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
style={{
|
||||||
|
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
|
||||||
|
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<HtmlToolbar editorRef={editorRef} onFormat={handleFormat} />
|
||||||
|
<CodeEditor ref={editorRef} value={value} onChange={onChange} language="html" height={420} />
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
style={{
|
||||||
|
padding: '7px 20px', fontSize: 13, fontWeight: 600,
|
||||||
|
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, test, expect, vi } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({ actions: { setProp: vi.fn() }, query: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { HtmlStylePanel } from './HtmlStylePanel';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HtmlStylePanel', () => {
|
||||||
|
test('renders the Edit HTML control', () => {
|
||||||
|
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
|
||||||
|
expect(container.textContent).toContain('Edit HTML');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders NO colour or style controls (they never reach the live page)', () => {
|
||||||
|
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
|
||||||
|
expect(container.querySelector('input[type="color"]')).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain('Background');
|
||||||
|
expect(container.textContent).not.toContain('Text Color');
|
||||||
|
expect(container.textContent).not.toContain('Padding');
|
||||||
|
expect(container.textContent).not.toContain('Border Radius');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the security advisory', () => {
|
||||||
|
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
|
||||||
|
expect(container.textContent).toContain('Use this block with care.');
|
||||||
|
expect(container.textContent).toContain('Scripts and event handlers are stripped');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNodeProp } from './shared';
|
||||||
|
import { HtmlCodeField } from './HtmlCodeField';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTML block's entire style panel.
|
||||||
|
*
|
||||||
|
* Deliberately ONLY the code editor. `HtmlBlock.toHtml()` emits nothing but
|
||||||
|
* the purified `code`, so every colour/padding/alignment control the generic
|
||||||
|
* editor used to offer here was dead on the published page. Styling an HTML
|
||||||
|
* block is done inside the user's own markup.
|
||||||
|
*/
|
||||||
|
export const HtmlStylePanel: React.FC<{ selectedId: string; nodeProps: Record<string, any> }> = ({
|
||||||
|
selectedId,
|
||||||
|
nodeProps,
|
||||||
|
}) => {
|
||||||
|
const { setProp: setPropValue } = useNodeProp(selectedId);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HtmlCodeField
|
||||||
|
value={typeof nodeProps.code === 'string' ? nodeProps.code : ''}
|
||||||
|
onChange={(v) => setPropValue('code', v)}
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', lineHeight: 1.4, padding: '0 2px' }}>
|
||||||
|
Style this block inside your own markup — a wrapper set here would show
|
||||||
|
in the editor but not on the published page.
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', lineHeight: 1.4, padding: '0 2px' }}>
|
||||||
|
<strong>Use this block with care.</strong> It renders your markup as-is
|
||||||
|
on the published site. Scripts and event handlers are stripped
|
||||||
|
automatically, but anything that survives — forms, iframes, images —
|
||||||
|
can still send data to wherever it points. Only paste code you
|
||||||
|
understand or trust.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, test, expect, vi } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { HtmlToolbar, SNIPPETS } from './HtmlToolbar';
|
||||||
|
import type { CodeEditorHandle } from '../../../ui/CodeEditor';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeHandle() {
|
||||||
|
return { insertAtCursor: vi.fn(), getValue: vi.fn(() => '') } as unknown as CodeEditorHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HtmlToolbar', () => {
|
||||||
|
test('every snippet button inserts its snippet with its caret offset', () => {
|
||||||
|
const handle = fakeHandle();
|
||||||
|
const ref = { current: handle } as React.RefObject<CodeEditorHandle>;
|
||||||
|
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
|
||||||
|
|
||||||
|
for (const snippet of SNIPPETS) {
|
||||||
|
const btn = container.querySelector(`[data-snippet="${snippet.label}"]`) as HTMLButtonElement;
|
||||||
|
expect(btn, `missing button for ${snippet.label}`).not.toBeNull();
|
||||||
|
act(() => { btn.click(); });
|
||||||
|
expect(handle.insertAtCursor).toHaveBeenCalledWith(snippet.text, snippet.caret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the colour input inserts a style attribute at the caret', () => {
|
||||||
|
const handle = fakeHandle();
|
||||||
|
const ref = { current: handle } as React.RefObject<CodeEditorHandle>;
|
||||||
|
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
|
||||||
|
|
||||||
|
const colour = container.querySelector('input[type="color"]') as HTMLInputElement;
|
||||||
|
colour.value = '#ff8800';
|
||||||
|
act(() => { colour.dispatchEvent(new Event('input', { bubbles: true })); });
|
||||||
|
|
||||||
|
expect(handle.insertAtCursor).toHaveBeenCalledWith(' style="color: #ff8800"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Format calls onFormat', () => {
|
||||||
|
const onFormat = vi.fn();
|
||||||
|
const ref = { current: fakeHandle() } as React.RefObject<CodeEditorHandle>;
|
||||||
|
render(<HtmlToolbar editorRef={ref} onFormat={onFormat} />);
|
||||||
|
const btn = container.querySelector('[data-action="format"]') as HTMLButtonElement;
|
||||||
|
act(() => { btn.click(); });
|
||||||
|
expect(onFormat).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a null editor ref is a no-op, not a crash', () => {
|
||||||
|
const ref = { current: null } as React.RefObject<CodeEditorHandle>;
|
||||||
|
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
|
||||||
|
const btn = container.querySelector('[data-snippet="div"]') as HTMLButtonElement;
|
||||||
|
expect(() => act(() => { btn.click(); })).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { CodeEditorHandle } from '../../../ui/CodeEditor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snippet buttons for the Edit HTML modal. `caret` is the offset from the
|
||||||
|
* start of the inserted text where the caret should land -- i.e. between the
|
||||||
|
* open and close tags, so the next keystroke types content rather than
|
||||||
|
* landing after the closing tag.
|
||||||
|
*/
|
||||||
|
export const SNIPPETS: { label: string; icon: string; title: string; text: string; caret: number }[] = [
|
||||||
|
{ label: 'div', icon: 'fa-square-o', title: 'Insert a div', text: '<div></div>', caret: 5 },
|
||||||
|
{ label: 'section', icon: 'fa-window-maximize', title: 'Insert a section', text: '<section></section>', caret: 9 },
|
||||||
|
{ label: 'h2', icon: 'fa-header', title: 'Insert a heading', text: '<h2></h2>', caret: 4 },
|
||||||
|
{ label: 'p', icon: 'fa-paragraph', title: 'Insert a paragraph', text: '<p></p>', caret: 3 },
|
||||||
|
{ label: 'a', icon: 'fa-link', title: 'Insert a link', text: '<a href="#"></a>', caret: 12 },
|
||||||
|
{ label: 'ul', icon: 'fa-list-ul', title: 'Insert a list', text: '<ul>\n <li></li>\n</ul>', caret: 11 },
|
||||||
|
{ label: 'img', icon: 'fa-image', title: 'Insert an image', text: '<img src="" alt="">', caret: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const btnStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
minWidth: 28, height: 28, padding: '0 7px',
|
||||||
|
background: '#27272a', color: '#e4e4e7',
|
||||||
|
border: '1px solid #3f3f46', borderRadius: 5,
|
||||||
|
fontSize: 11, cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HtmlToolbar: React.FC<{
|
||||||
|
editorRef: React.RefObject<CodeEditorHandle>;
|
||||||
|
onFormat: () => void;
|
||||||
|
}> = ({ editorRef, onFormat }) => {
|
||||||
|
const insert = (text: string, caret?: number): void => {
|
||||||
|
// The ref is null until CodeEditor mounts; clicking early must no-op.
|
||||||
|
// Only forward a second argument when a caret offset was actually
|
||||||
|
// given -- `insertAtCursor(text, undefined)` is a distinct call from
|
||||||
|
// `insertAtCursor(text)` (an explicit undefined still occupies the
|
||||||
|
// argument list), and the colour control relies on the latter so the
|
||||||
|
// caret lands at the end of the inserted attribute, not mid-string.
|
||||||
|
if (caret === undefined) {
|
||||||
|
editorRef.current?.insertAtCursor(text);
|
||||||
|
} else {
|
||||||
|
editorRef.current?.insertAtCursor(text, caret);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="HTML editing tools"
|
||||||
|
style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 10, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{SNIPPETS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.label}
|
||||||
|
type="button"
|
||||||
|
data-snippet={s.label}
|
||||||
|
title={s.title}
|
||||||
|
aria-label={s.title}
|
||||||
|
style={btnStyle}
|
||||||
|
onClick={() => insert(s.text, s.caret)}
|
||||||
|
>
|
||||||
|
<i className={`fa ${s.icon}`} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span style={{ width: 1, height: 20, background: '#3f3f46', margin: '0 3px' }} aria-hidden="true" />
|
||||||
|
|
||||||
|
<label
|
||||||
|
title="Insert a colour style attribute at the cursor"
|
||||||
|
style={{ ...btnStyle, padding: 0, overflow: 'hidden', position: 'relative' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
aria-label="Insert colour"
|
||||||
|
defaultValue="#3b82f6"
|
||||||
|
onInput={(e) => insert(` style="color: ${(e.target as HTMLInputElement).value}"`)}
|
||||||
|
style={{ width: 40, height: 34, border: 'none', background: 'none', cursor: 'pointer', padding: 0 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="format"
|
||||||
|
title="Re-indent the markup"
|
||||||
|
style={{ ...btnStyle, marginLeft: 'auto', fontWeight: 600, gap: 5 }}
|
||||||
|
onClick={onFormat}
|
||||||
|
>
|
||||||
|
<i className="fa fa-indent" aria-hidden="true" /> Format
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
/* Same DOM-harness pattern as MediaStylePanel.video.test.tsx -- mock
|
||||||
|
@craftjs/core's useEditor so setProp calls can be observed without
|
||||||
|
mounting a real <Editor> tree, and mock utils/assets so AssetPicker
|
||||||
|
doesn't hit the network. */
|
||||||
|
const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
|
||||||
|
updater(lastProps);
|
||||||
|
});
|
||||||
|
let lastProps: any;
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({ actions: { setProp: setPropSpy } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../utils/assets', () => ({
|
||||||
|
uploadAsset: vi.fn(),
|
||||||
|
listAssets: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ImageStylePanel } from './ImageStylePanel';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => { root.unmount(); });
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function q<T extends Element = Element>(testId: string): T | null {
|
||||||
|
return container.querySelector(`[data-testid="${testId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function qAll<T extends Element = Element>(testId: string): T[] {
|
||||||
|
return Array.from(container.querySelectorAll(`[data-testid="${testId}"]`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click the preset button with this exact label inside a given data-testid
|
||||||
|
* root (AspectRatioControl / PresetButtonGrid render plain buttons keyed by
|
||||||
|
* label, no per-button testid). */
|
||||||
|
function clickPresetByLabel(root: Element | null, label: string) {
|
||||||
|
const btn = Array.from(root?.querySelectorAll('button') ?? []).find((b) => b.textContent === label);
|
||||||
|
expect(btn).toBeTruthy();
|
||||||
|
act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setPropSpy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container) unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ImageStylePanel crop-fills-by-default (fix-anim-image B)', () => {
|
||||||
|
test('applying a non-empty aspect ratio with objectFit unset also sets objectFit to cover', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: {} };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), '1:1');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('1 / 1');
|
||||||
|
expect(lastProps.style.objectFit).toBe('cover');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applying a ratio when objectFit is already "contain" leaves it as contain (no forced override)', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { objectFit: 'contain' } };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), '16:9');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('16 / 9');
|
||||||
|
expect(lastProps.style.objectFit).toBe('contain');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing the ratio (Original) does not force-clear objectFit', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), 'Original');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('');
|
||||||
|
expect(lastProps.style.objectFit).toBe('cover');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Object Fit control (Cover/Contain/Fill/None) remains present and usable', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
// PresetButtonGrid buttons have no dedicated per-button testid; find by label text.
|
||||||
|
const containBtn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent === 'Contain');
|
||||||
|
expect(containBtn).toBeTruthy();
|
||||||
|
act(() => { containBtn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||||
|
expect(lastProps.style.objectFit).toBe('contain');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ImageStylePanel Height control gated on aspect-ratio (fix-anim-image C)', () => {
|
||||||
|
test('no aspect-ratio set: both Width and Height SizeControls render', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: {} };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
expect(qAll('size-control').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aspect-ratio set: only the Width SizeControl renders (Height is hidden)', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
expect(qAll('size-control').length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applying an aspect ratio clears a stale height so it cannot linger and conflict', () => {
|
||||||
|
lastProps = { src: '/uploads/photo.jpg', alt: '', style: { height: '50%' } };
|
||||||
|
render(<ImageStylePanel selectedId="img-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), '9:16');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('9 / 16');
|
||||||
|
expect(lastProps.style.height).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,19 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
|
|
||||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||||
|
|
||||||
|
// Applying an aspect-ratio crop should FILL the frame by default (object-fit:
|
||||||
|
// cover) rather than leave letterboxed empty bands, and width + ratio + cover
|
||||||
|
// fully determine the box -- so a stale `height` can't linger and conflict
|
||||||
|
// (kills the %-height no-op that made resize look like it wasn't working).
|
||||||
|
// Clearing the ratio (back to 'Original') leaves objectFit as the user left it.
|
||||||
|
const applyAspectRatio = (v: string) => {
|
||||||
|
setPropStyle('aspectRatio', v);
|
||||||
|
if (v) {
|
||||||
|
if (!style.objectFit) setPropStyle('objectFit', 'cover');
|
||||||
|
setPropStyle('height', '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Image source */}
|
{/* Image source */}
|
||||||
@@ -54,18 +67,24 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
value={(style.maxWidth as string) || ''}
|
value={(style.maxWidth as string) || ''}
|
||||||
onChange={(v) => setPropStyle('maxWidth', v)}
|
onChange={(v) => setPropStyle('maxWidth', v)}
|
||||||
/>
|
/>
|
||||||
|
{/* Height is only meaningful when there's no aspect-ratio crop -- once a
|
||||||
|
ratio is set, Width + ratio + cover fully determine the box, so a
|
||||||
|
separate Height control would only conflict/mislead (see
|
||||||
|
applyAspectRatio, which clears any stale height at that moment). */}
|
||||||
|
{!style.aspectRatio && (
|
||||||
<SizeControl
|
<SizeControl
|
||||||
label="Height"
|
label="Height"
|
||||||
value={(style.height as string) || ''}
|
value={(style.height as string) || ''}
|
||||||
onChange={(v) => setPropStyle('height', v)}
|
onChange={(v) => setPropStyle('height', v)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Crop & Framing -- aspect-ratio + object-fit + object-position on the
|
{/* Crop & Framing -- aspect-ratio + object-fit + object-position on the
|
||||||
<img> itself is a CSS framing crop (no server-side image processing
|
<img> itself is a CSS framing crop (no server-side image processing
|
||||||
needed). */}
|
needed). */}
|
||||||
<AspectRatioControl
|
<AspectRatioControl
|
||||||
value={(style.aspectRatio as string) || ''}
|
value={(style.aspectRatio as string) || ''}
|
||||||
onChange={(v) => setPropStyle('aspectRatio', v)}
|
onChange={applyAspectRatio}
|
||||||
/>
|
/>
|
||||||
<div className="guided-section">
|
<div className="guided-section">
|
||||||
<SectionLabel>Object Fit</SectionLabel>
|
<SectionLabel>Object Fit</SectionLabel>
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
|
|
||||||
const style = nodeProps.style || {};
|
const style = nodeProps.style || {};
|
||||||
|
|
||||||
|
// Same crop-fills-by-default + no stale-height-conflict treatment as
|
||||||
|
// ImageStylePanel (see there for the full rationale): applying a ratio
|
||||||
|
// defaults objectFit to 'cover' when unset, and clears height so Width +
|
||||||
|
// ratio + cover is the single source of truth for the box.
|
||||||
|
const applyAspectRatio = (v: string) => {
|
||||||
|
setPropStyle('aspectRatio', v);
|
||||||
|
if (v) {
|
||||||
|
if (!style.objectFit) setPropStyle('objectFit', 'cover');
|
||||||
|
setPropStyle('height', '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Video source -- upload/browse/paste-URL (paste-URL still handles
|
{/* Video source -- upload/browse/paste-URL (paste-URL still handles
|
||||||
@@ -63,9 +75,14 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
|||||||
value={(style.width as string) || ''}
|
value={(style.width as string) || ''}
|
||||||
onChange={(v) => setPropStyle('width', v)}
|
onChange={(v) => setPropStyle('width', v)}
|
||||||
/>
|
/>
|
||||||
|
{/* NOTE: unlike ImageStylePanel, there is no separate Height control
|
||||||
|
here to gate on aspect-ratio -- VideoBlock's <video>/iframe size
|
||||||
|
themselves from `width` + `aspectRatio` directly (see
|
||||||
|
VideoBlock.tsx), not from an outer-wrapper height, so adding one
|
||||||
|
would reintroduce the exact empty-space bug this fix targets. */}
|
||||||
<AspectRatioControl
|
<AspectRatioControl
|
||||||
value={(style.aspectRatio as string) || ''}
|
value={(style.aspectRatio as string) || ''}
|
||||||
onChange={(v) => setPropStyle('aspectRatio', v)}
|
onChange={applyAspectRatio}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -121,6 +121,58 @@ describe('MediaStylePanel Video size controls (Width + Aspect Ratio) are gated o
|
|||||||
expect(q('size-control')).toBeNull();
|
expect(q('size-control')).toBeNull();
|
||||||
expect(q('aspect-ratio-control')).toBeNull();
|
expect(q('aspect-ratio-control')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a video-shaped selection only renders one SizeControl (Width) -- no separate Height control', () => {
|
||||||
|
// See MediaStylePanel.tsx's note: VideoBlock sizes its <video>/iframe from
|
||||||
|
// width + aspectRatio directly, not from an outer-wrapper height, so a
|
||||||
|
// Height control would reintroduce the empty-space bug this fix targets.
|
||||||
|
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: {} };
|
||||||
|
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||||
|
expect(qAll('size-control').length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* FIX (fix-anim-image contract, B+C): applying a crop aspect-ratio to a video
|
||||||
|
should fill by default (objectFit defaults to 'cover' when unset) and clear
|
||||||
|
any stale height, matching ImageStylePanel's treatment -- for prop-schema
|
||||||
|
consistency even though VideoBlock's file-type <video> already hardcodes
|
||||||
|
object-fit: cover today. */
|
||||||
|
describe('MediaStylePanel Video AspectRatioControl applies crop-fills-by-default treatment (fix-anim-image B+C)', () => {
|
||||||
|
function clickPresetByLabel(root: Element | null, label: string) {
|
||||||
|
const btn = Array.from(root?.querySelectorAll('button') ?? []).find((b) => b.textContent === label);
|
||||||
|
expect(btn).toBeTruthy();
|
||||||
|
act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('applying a non-empty ratio with objectFit unset also sets objectFit to cover', () => {
|
||||||
|
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: {} };
|
||||||
|
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), '1:1');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('1 / 1');
|
||||||
|
expect(lastProps.style.objectFit).toBe('cover');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applying a ratio clears a stale height', () => {
|
||||||
|
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: { height: '50%' } };
|
||||||
|
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), '16:9');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('16 / 9');
|
||||||
|
expect(lastProps.style.height).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing the ratio (Original) does not force-clear objectFit', () => {
|
||||||
|
lastProps = { videoUrl: 'https://example.com/clip.mp4', poster: '', style: { aspectRatio: '1 / 1', objectFit: 'cover' } };
|
||||||
|
render(<MediaStylePanel selectedId="vid-1" nodeProps={lastProps} />);
|
||||||
|
|
||||||
|
clickPresetByLabel(q('aspect-ratio-control'), 'Original');
|
||||||
|
|
||||||
|
expect(lastProps.style.aspectRatio).toBe('');
|
||||||
|
expect(lastProps.style.objectFit).toBe('cover');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function openCollapsibleByTitle(title: string) {
|
function openCollapsibleByTitle(title: string) {
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export { SectionTypePanel } from './SectionTypePanel';
|
|||||||
export { PricingStylePanel } from './PricingStylePanel';
|
export { PricingStylePanel } from './PricingStylePanel';
|
||||||
export { BackgroundSectionStylePanel } from './BackgroundSectionStylePanel';
|
export { BackgroundSectionStylePanel } from './BackgroundSectionStylePanel';
|
||||||
export { GenericPropsEditor } from './GenericPropsEditor';
|
export { GenericPropsEditor } from './GenericPropsEditor';
|
||||||
|
export { HtmlStylePanel } from './HtmlStylePanel';
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { PublishWarnings } from './PublishWarnings';
|
||||||
|
|
||||||
|
/* ---------- DOM test harness (no @testing-library/react in this repo, see
|
||||||
|
src/ui/AssetPicker.test.tsx for the same pattern: react-dom/client +
|
||||||
|
react-dom/test-utils `act`, both transitive deps of react-dom already). ---------- */
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(el: Element | null) {
|
||||||
|
if (!el) throw new Error('element not found');
|
||||||
|
act(() => { (el as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container) {
|
||||||
|
act(() => { root.unmount(); });
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PublishWarnings', () => {
|
||||||
|
test('renders nothing when there are no warnings', () => {
|
||||||
|
render(<PublishWarnings warnings={[]} onDismiss={() => {}} />);
|
||||||
|
expect(container.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders each warning', () => {
|
||||||
|
render(
|
||||||
|
<PublishWarnings
|
||||||
|
warnings={['First problem.', 'Second problem.']}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain('First problem.');
|
||||||
|
expect(container.textContent).toContain('Second problem.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dismiss fires the callback', () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(<PublishWarnings warnings={['A problem.']} onDismiss={onDismiss} />);
|
||||||
|
click(container.querySelector('[data-testid="publish-warnings-dismiss"]'));
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface PublishWarningsProps {
|
||||||
|
warnings: string[];
|
||||||
|
onDismiss: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-blocking banner shown after a successful publish. The site IS live --
|
||||||
|
* these are things the customer should fix and re-publish, not failures. */
|
||||||
|
export const PublishWarnings: React.FC<PublishWarningsProps> = ({ warnings, onDismiss }) => {
|
||||||
|
if (!warnings.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="publish-warnings" role="status" data-testid="publish-warnings">
|
||||||
|
<i className="fa fa-exclamation-triangle" aria-hidden="true" />
|
||||||
|
<ul>
|
||||||
|
{warnings.map((w, i) => (
|
||||||
|
<li key={i}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
data-testid="publish-warnings-dismiss"
|
||||||
|
>
|
||||||
|
<i className="fa fa-times" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: (collect?: (state: any) => any) => {
|
||||||
|
const state = { events: { selected: new Set<string>() }, nodes: {} };
|
||||||
|
return {
|
||||||
|
query: { serialize: () => '{"ROOT":{}}' },
|
||||||
|
actions: {},
|
||||||
|
...(collect ? collect(state) : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('../../state/EditorConfigContext', () => ({
|
||||||
|
useEditorConfig: () => ({
|
||||||
|
whpConfig: {
|
||||||
|
apiUrl: '/panel/api/site-builder',
|
||||||
|
csrfToken: 'tok',
|
||||||
|
siteId: 42,
|
||||||
|
siteDomain: 'example.com',
|
||||||
|
},
|
||||||
|
isWHP: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock('../../state/PageContext', () => ({
|
||||||
|
usePages: () => ({
|
||||||
|
activePageId: 'home',
|
||||||
|
pages: [{ id: 'home', name: 'Home', slug: 'index', craftState: null }],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
// Mocked (rather than using the real ring buffer) only so the added
|
||||||
|
// oversized-payload test below can force an over-cap consoleErrors array --
|
||||||
|
// every other test gets a plain empty array, same as a fresh page load.
|
||||||
|
vi.mock('../../utils/console-buffer', () => ({ getRecentConsoleErrors: vi.fn() }));
|
||||||
|
|
||||||
|
import { ReportIssueModal } from './ReportIssueModal';
|
||||||
|
import { getRecentConsoleErrors } from '../../utils/console-buffer';
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(open = true) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(<ReportIssueModal open={open} onClose={vi.fn()} device="desktop" />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The textarea is a controlled input (`onChange`), so React's DOM value
|
||||||
|
// tracker patches its `value` setter to detect "did this really change".
|
||||||
|
// A plain `ta.value = text` assignment goes through that same patched
|
||||||
|
// setter, which updates the tracker's own record of "current value" as a
|
||||||
|
// side effect -- so by the time the dispatched 'input' event is handled,
|
||||||
|
// the tracker sees no difference and the synthetic onChange never fires.
|
||||||
|
// Bypassing the patched setter via the native prototype descriptor (same
|
||||||
|
// idiom as HeadCodeModal.test.tsx / SiteDesignPanel.reset.test.tsx /
|
||||||
|
// shared-controls.test.tsx / MediaStylePanel.*.test.tsx) sets the DOM value
|
||||||
|
// without touching the tracker, so the dispatched event is correctly seen
|
||||||
|
// as a real change.
|
||||||
|
function typeDescription(text: string) {
|
||||||
|
const ta = document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement;
|
||||||
|
act(() => {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!;
|
||||||
|
setter.call(ta, text);
|
||||||
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const btn = document.querySelector('[data-action="submit-report"]') as HTMLButtonElement;
|
||||||
|
act(() => { btn.click(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.mocked(getRecentConsoleErrors).mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReportIssueModal', () => {
|
||||||
|
test('submit is disabled until a description is entered', () => {
|
||||||
|
render();
|
||||||
|
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
typeDescription('something is wrong');
|
||||||
|
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('posts the payload to the report_issue action with the CSRF header', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render();
|
||||||
|
typeDescription('colours do nothing');
|
||||||
|
await act(async () => { submit(); });
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toContain('action=report_issue');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
expect(init.headers['X-CSRF-Token']).toBe('tok');
|
||||||
|
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
expect(body.description).toBe('colours do nothing');
|
||||||
|
expect(body.category).toBe('bug');
|
||||||
|
expect(body.site_id).toBe(42);
|
||||||
|
expect(body.canvas_state).toBe('{"ROOT":{}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unchecking include-contents omits the canvas state', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, reference: 'SB-2', id: 2 }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render();
|
||||||
|
typeDescription('no canvas please');
|
||||||
|
const cb = document.querySelector('[data-testid="report-include-canvas"]') as HTMLInputElement;
|
||||||
|
act(() => { cb.click(); });
|
||||||
|
await act(async () => { submit(); });
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.canvas_state).toBeNull();
|
||||||
|
expect(body.canvas_state_omitted).toBe('opt-out');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the returned reference on success', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
|
||||||
|
}));
|
||||||
|
render();
|
||||||
|
typeDescription('x');
|
||||||
|
await act(async () => { submit(); });
|
||||||
|
expect(document.body.textContent).toContain('SB-1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the text and shows an error when the request fails', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: async () => ({ success: false, error: 'Rate limited' }),
|
||||||
|
}));
|
||||||
|
render();
|
||||||
|
typeDescription('keep me');
|
||||||
|
await act(async () => { submit(); });
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Rate limited');
|
||||||
|
expect((document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement).value).toBe('keep me');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a payload still too large after dropping canvas_state shows an error instead of hanging, and never calls fetch', async () => {
|
||||||
|
// buildReportPayload throws when the body is still over MAX_PAYLOAD_BYTES
|
||||||
|
// even with canvas_state dropped -- see report-payload.ts `finalize()`.
|
||||||
|
// A huge *description* alone can't reach that path (it's truncated to
|
||||||
|
// MAX_DESCRIPTION_CHARS before the size check ever runs -- see
|
||||||
|
// report-payload.test.ts's "description size bounding" suite), so this
|
||||||
|
// forces the same defensive scenario report-payload.test.ts's "honest
|
||||||
|
// size markers" test uses: an oversized consoleErrors array, standing in
|
||||||
|
// for whatever future bug would let that much data through in practice.
|
||||||
|
// The point under test here is purely the modal's reaction to the throw,
|
||||||
|
// not how the oversized condition arises.
|
||||||
|
vi.mocked(getRecentConsoleErrors).mockReturnValue(
|
||||||
|
Array.from({ length: 5000 }, (_, i) => ({ ts: i, message: 'x'.repeat(200) })),
|
||||||
|
);
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render();
|
||||||
|
typeDescription('this report has a huge console-error backlog attached');
|
||||||
|
await act(async () => { submit(); });
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(document.body.textContent).toMatch(/too large/i);
|
||||||
|
// The user's text must survive a rejected submission just as much as a
|
||||||
|
// server-side failure does.
|
||||||
|
expect((document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement).value).toBe(
|
||||||
|
'this report has a huge console-error backlog attached',
|
||||||
|
);
|
||||||
|
// Not stuck on "Sending..." -- the submit button is usable again.
|
||||||
|
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { Modal } from '../../ui/Modal';
|
||||||
|
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||||
|
import { usePages } from '../../state/PageContext';
|
||||||
|
import { buildReportPayload, MAX_DESCRIPTION_CHARS, type ReportCategory } from '../../utils/report-payload';
|
||||||
|
import { getRecentConsoleErrors } from '../../utils/console-buffer';
|
||||||
|
import { editorBuild } from '../../utils/build-stamp';
|
||||||
|
|
||||||
|
export interface ReportIssueModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
device: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES: { value: ReportCategory; label: string }[] = [
|
||||||
|
{ value: 'bug', label: 'Something is broken' },
|
||||||
|
{ value: 'confusing', label: 'Something is confusing' },
|
||||||
|
{ value: 'feature', label: 'I wish it could…' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Only start showing the running character count once it's actually useful
|
||||||
|
* -- i.e. once the user is close enough to MAX_DESCRIPTION_CHARS that
|
||||||
|
* losing text is a real possibility, not on every keystroke from zero. */
|
||||||
|
const COUNTER_THRESHOLD = MAX_DESCRIPTION_CHARS - 500;
|
||||||
|
|
||||||
|
export const ReportIssueModal: React.FC<ReportIssueModalProps> = ({ open, onClose, device }) => {
|
||||||
|
const { whpConfig } = useEditorConfig();
|
||||||
|
const { activePageId, pages } = usePages();
|
||||||
|
// Guarded with optional chaining: some hosts around this component (e.g.
|
||||||
|
// TopBar's own test harness) stub `useEditor` with a minimal collector
|
||||||
|
// state that has no `events`/`nodes` at all -- this must degrade to "no
|
||||||
|
// selection known" rather than throw and take the whole topbar down.
|
||||||
|
const { query, selectedType } = useEditor((state: any) => {
|
||||||
|
const sel = state?.events?.selected;
|
||||||
|
const id = sel && sel.size > 0 ? (Array.from(sel)[0] as string) : null;
|
||||||
|
return { selectedType: id ? (state?.nodes?.[id]?.data?.displayName ?? null) : null };
|
||||||
|
});
|
||||||
|
|
||||||
|
const [category, setCategory] = useState<ReportCategory>('bug');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [includeCanvas, setIncludeCanvas] = useState(true);
|
||||||
|
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||||
|
const [reference, setReference] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const activePage = pages.find((p) => p.id === activePageId);
|
||||||
|
|
||||||
|
const reset = (): void => {
|
||||||
|
setDescription('');
|
||||||
|
setStatus('idle');
|
||||||
|
setReference('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (): Promise<void> => {
|
||||||
|
if (!description.trim() || !whpConfig) return;
|
||||||
|
setStatus('sending');
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
let canvasState: string | null = null;
|
||||||
|
try {
|
||||||
|
canvasState = query.serialize();
|
||||||
|
} catch {
|
||||||
|
// A serialize failure must not block the report -- it is often the
|
||||||
|
// very thing being reported.
|
||||||
|
canvasState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = buildReportPayload({
|
||||||
|
category,
|
||||||
|
description,
|
||||||
|
includeCanvas,
|
||||||
|
siteId: whpConfig.siteId ?? null,
|
||||||
|
siteDomain: whpConfig.siteDomain ?? '',
|
||||||
|
pageId: activePageId,
|
||||||
|
pageSlug: activePage?.slug ?? '',
|
||||||
|
editorVersion: editorBuild(),
|
||||||
|
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
||||||
|
viewport: typeof window !== 'undefined' ? `${window.innerWidth}x${window.innerHeight}` : '',
|
||||||
|
deviceMode: device,
|
||||||
|
selectedType,
|
||||||
|
consoleErrors: getRecentConsoleErrors(),
|
||||||
|
canvasState,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// buildReportPayload throws (rather than returning an oversized body)
|
||||||
|
// when the payload is still over the cap even after dropping
|
||||||
|
// canvas_state -- e.g. an enormous description. The thrown value is a
|
||||||
|
// plain Error with no discriminator, so treat ANY throw here as "too
|
||||||
|
// large" rather than string-matching the message. The user's text is
|
||||||
|
// left untouched in the textarea (state.description is never reset on
|
||||||
|
// this path) so nothing is lost -- they just need to shorten it or
|
||||||
|
// untick "include this page's contents".
|
||||||
|
setError(
|
||||||
|
'This report is too large to send, even without the page contents. ' +
|
||||||
|
'Try unchecking "Include this page\'s contents" below, or shortening your description.',
|
||||||
|
);
|
||||||
|
setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${whpConfig.apiUrl}?action=report_issue`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': whpConfig.csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || !data.success) {
|
||||||
|
setError(data.error || 'Could not send the report. Please try again.');
|
||||||
|
setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setReference(data.reference || `SB-${data.id}`);
|
||||||
|
setStatus('sent');
|
||||||
|
} catch (e) {
|
||||||
|
setError('Could not reach the server. Your text is still here — try again.');
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
if (status === 'sent') reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<Modal open={open} onClose={handleClose} width="min(560px, 92vw)">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-bg-surface)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 12,
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Report an issue</div>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Close"
|
||||||
|
style={{
|
||||||
|
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
|
||||||
|
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'sent' ? (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<i className="fa fa-check-circle" style={{ fontSize: 32, color: '#10b981' }} aria-hidden="true" />
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--color-text)', margin: '12px 0 4px' }}>
|
||||||
|
Thanks — that's been sent.
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: 0 }}>
|
||||||
|
Your reference is <strong>{reference}</strong>. Quote it if you open a support ticket.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
marginTop: 16, padding: '7px 20px', fontSize: 13, fontWeight: 600,
|
||||||
|
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
|
||||||
|
What kind of issue is it?
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
data-testid="report-category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value as ReportCategory)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '6px 8px', fontSize: 12,
|
||||||
|
background: '#27272a', color: '#e4e4e7',
|
||||||
|
border: '1px solid #3f3f46', borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<option key={c.value} value={c.value}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
|
||||||
|
What happened?
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
data-testid="report-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
maxLength={MAX_DESCRIPTION_CHARS}
|
||||||
|
placeholder="What were you doing, and what did you expect to happen instead?"
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '8px 10px', fontSize: 12, lineHeight: 1.5,
|
||||||
|
background: '#27272a', color: '#e4e4e7',
|
||||||
|
border: '1px solid #3f3f46', borderRadius: 4,
|
||||||
|
resize: 'vertical', boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{description.length >= COUNTER_THRESHOLD && (
|
||||||
|
<div
|
||||||
|
data-testid="report-description-count"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: description.length >= MAX_DESCRIPTION_CHARS ? '#fca5a5' : 'var(--color-text-muted)',
|
||||||
|
textAlign: 'right',
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{description.length} / {MAX_DESCRIPTION_CHARS}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
data-testid="report-include-canvas"
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeCanvas}
|
||||||
|
onChange={(e) => setIncludeCanvas(e.target.checked)}
|
||||||
|
style={{ marginTop: 2 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||||
|
Include this page's contents to help debugging. This sends the text and
|
||||||
|
layout of the page you're editing along with your report. Uncheck it and
|
||||||
|
we'll still get your description, the page name, your browser details, and
|
||||||
|
any recent console errors -- but not the page's text or layout.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, color: '#fca5a5', background: 'rgba(239,68,68,0.1)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.35)', borderRadius: 4, padding: '8px 10px',
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 16px', borderTop: '1px solid var(--color-border)',
|
||||||
|
display: 'flex', justifyContent: 'flex-end', gap: 8,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
padding: '7px 16px', fontSize: 13,
|
||||||
|
background: 'var(--color-bg-elevated)', color: 'var(--color-text-muted)',
|
||||||
|
border: '1px solid var(--color-border)', borderRadius: 6, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-action="submit-report"
|
||||||
|
disabled={!description.trim() || status === 'sending'}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
style={{
|
||||||
|
padding: '7px 20px', fontSize: 13, fontWeight: 600,
|
||||||
|
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6,
|
||||||
|
cursor: description.trim() && status !== 'sending' ? 'pointer' : 'not-allowed',
|
||||||
|
opacity: description.trim() && status !== 'sending' ? 1 : 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'sending' ? 'Sending…' : 'Send report'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the gap flagged by the whole-branch review: PublishWarnings.test.tsx
|
||||||
|
* covers the presentational banner in isolation, but nothing asserted that
|
||||||
|
* `result.warnings` from `publish()` actually reaches it through TopBar. That
|
||||||
|
* 3-line seam (handlePublish -> setPublishWarnings -> <PublishWarnings>) is
|
||||||
|
* exactly what regressed before this feature existed: the backend has always
|
||||||
|
* returned `warnings`, and TopBar discarded them by only checking
|
||||||
|
* `result.success` -- so the contact-form-relay warning was dead code for its
|
||||||
|
* entire life. This suite drives the real `<TopBar>` through a real button
|
||||||
|
* click and asserts the warnings show up, survive the 3s "Published" flash,
|
||||||
|
* don't taint the success/error status, and get cleared by a fresh publish.
|
||||||
|
*
|
||||||
|
* Mocks (same DOM-harness + `vi.mock('@craftjs/core', ...)` pattern as
|
||||||
|
* RenderNode.test.tsx / useWhpApi.load.test.tsx -- no @testing-library/react
|
||||||
|
* in this repo):
|
||||||
|
* - `@craftjs/core`'s `useEditor`: TopBar only needs inert undo/redo/query
|
||||||
|
* stubs, not a real Craft.js tree.
|
||||||
|
* - `useWhpApi`: this IS the seam under test -- `publish` is a controllable
|
||||||
|
* mock so each test can choose exactly what the "backend" returns.
|
||||||
|
* - TemplateModal / HeadCodeModal / SitesmithButton: sibling chrome
|
||||||
|
* unrelated to the warnings seam (portals, CodeMirror lazy-load,
|
||||||
|
* useSitesmith's own fetch calls) -- stubbed out so this suite stays
|
||||||
|
* focused, same as MobilePanelBar.test.tsx stubbing its sibling panels.
|
||||||
|
* `usePages`/`useSiteDesign`/`useMobileChrome` are left un-mocked and
|
||||||
|
* un-provided -- their default context values (defined in each context
|
||||||
|
* module) are harmless no-op stubs, and TopBar's desktop render path never
|
||||||
|
* needs more than that.
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
|
||||||
|
const query = {
|
||||||
|
serialize: () => '{}',
|
||||||
|
history: { canUndo: () => false, canRedo: () => false },
|
||||||
|
};
|
||||||
|
const actions = { history: { undo: vi.fn(), redo: vi.fn() } };
|
||||||
|
const collected = collector ? collector({}, query) : {};
|
||||||
|
return { actions, query, ...collected };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const publishMock = vi.fn();
|
||||||
|
vi.mock('../../hooks/useWhpApi', () => ({
|
||||||
|
useWhpApi: () => ({
|
||||||
|
save: vi.fn().mockResolvedValue({ success: true }),
|
||||||
|
publish: publishMock,
|
||||||
|
load: vi.fn().mockResolvedValue(null),
|
||||||
|
uploadAsset: vi.fn(),
|
||||||
|
isWHP: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./TemplateModal', () => ({ TemplateModal: () => null }));
|
||||||
|
vi.mock('./HeadCodeModal', () => ({ HeadCodeModal: () => null }));
|
||||||
|
vi.mock('../sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null }));
|
||||||
|
|
||||||
|
import { TopBar } from './TopBar';
|
||||||
|
import { EditorConfigProvider } from '../../state/EditorConfigContext';
|
||||||
|
import { WhpConfig } from '../../types';
|
||||||
|
|
||||||
|
const whpConfig: WhpConfig = {
|
||||||
|
user: 'testuser',
|
||||||
|
apiUrl: '/panel/api/site-builder',
|
||||||
|
csrfToken: 'tok',
|
||||||
|
siteId: 1,
|
||||||
|
siteDomain: 'example.com',
|
||||||
|
siteName: 'Example Site',
|
||||||
|
backUrl: '/panel/sites',
|
||||||
|
isRoot: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(
|
||||||
|
<EditorConfigProvider config={whpConfig}>
|
||||||
|
<TopBar device="desktop" onDeviceChange={() => {}} showGuides={false} onToggleGuides={() => {}} />
|
||||||
|
</EditorConfigProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishButton(): HTMLButtonElement {
|
||||||
|
const btn = container.querySelector<HTMLButtonElement>('.topbar-btn.publish');
|
||||||
|
if (!btn) throw new Error('Publish button not found');
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** handlePublish does exactly one `await publish()` before touching state;
|
||||||
|
* two microtask flushes inside the same act() batch is enough to carry that
|
||||||
|
* through to the resulting re-render. */
|
||||||
|
async function clickPublish() {
|
||||||
|
await act(async () => {
|
||||||
|
publishButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (container) {
|
||||||
|
act(() => { root.unmount(); });
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
publishMock.mockReset();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TopBar publish-warnings wiring', () => {
|
||||||
|
test('warnings from publish() flow through into the rendered banner', async () => {
|
||||||
|
publishMock.mockResolvedValue({ success: true, warnings: ['Warning one.', 'Warning two.'] });
|
||||||
|
render();
|
||||||
|
|
||||||
|
await clickPublish();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('Warning one.');
|
||||||
|
expect(container.textContent).toContain('Warning two.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a clean publish (no warnings key) renders no banner at all', async () => {
|
||||||
|
publishMock.mockResolvedValue({ success: true });
|
||||||
|
render();
|
||||||
|
|
||||||
|
await clickPublish();
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="publish-warnings"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a warning is non-blocking -- publish still reports success, not a failure', async () => {
|
||||||
|
publishMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
warnings: ['Submissions will not be delivered until an administrator enables it.'],
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
|
||||||
|
await clickPublish();
|
||||||
|
|
||||||
|
expect(container.querySelector('.publish-badge.published')).not.toBeNull();
|
||||||
|
expect(container.querySelector('.save-indicator.error')).toBeNull();
|
||||||
|
expect(container.textContent).toContain('Submissions will not be delivered until an administrator enables it.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warnings survive the 3-second "Published" flash timer', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
publishMock.mockResolvedValue({ success: true, warnings: ['Sticks around after the flash.'] });
|
||||||
|
render();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
publishButton().dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity: both the flash and the warning are up before the timer fires.
|
||||||
|
expect(container.querySelector('.publish-badge.published')).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain('Sticks around after the flash.');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The 3s timer resets publishStatus -> the "Published" flash is gone...
|
||||||
|
expect(container.querySelector('.publish-badge.published')).toBeNull();
|
||||||
|
// ...but publishWarnings lives in its own state and must NOT have been
|
||||||
|
// cleared by that same timer.
|
||||||
|
expect(container.textContent).toContain('Sticks around after the flash.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a new publish attempt clears warnings left over from the previous one', async () => {
|
||||||
|
publishMock.mockResolvedValueOnce({ success: true, warnings: ['Old warning.'] });
|
||||||
|
render();
|
||||||
|
await clickPublish();
|
||||||
|
expect(container.textContent).toContain('Old warning.');
|
||||||
|
|
||||||
|
publishMock.mockResolvedValueOnce({ success: true });
|
||||||
|
await clickPublish();
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain('Old warning.');
|
||||||
|
expect(container.querySelector('[data-testid="publish-warnings"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,9 @@ import { useMobileChrome } from '../../state/MobileChromeContext';
|
|||||||
import { DeviceMode } from '../../types';
|
import { DeviceMode } from '../../types';
|
||||||
import { TemplateModal } from './TemplateModal';
|
import { TemplateModal } from './TemplateModal';
|
||||||
import { HeadCodeModal } from './HeadCodeModal';
|
import { HeadCodeModal } from './HeadCodeModal';
|
||||||
|
import { ReportIssueModal } from './ReportIssueModal';
|
||||||
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
|
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
|
||||||
|
import { PublishWarnings } from './PublishWarnings';
|
||||||
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||||
|
|
||||||
@@ -33,11 +35,13 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
|
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
|
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
|
||||||
|
const [publishWarnings, setPublishWarnings] = useState<string[]>([]);
|
||||||
const [isDraft, setIsDraft] = useState(false);
|
const [isDraft, setIsDraft] = useState(false);
|
||||||
// Mobile-A2: lifted from private useState into MobileChromeContext so
|
// Mobile-A2: lifted from private useState into MobileChromeContext so
|
||||||
// opening a mobile sheet can close these modals (item 3) -- behavior is
|
// opening a mobile sheet can close these modals (item 3) -- behavior is
|
||||||
// otherwise identical for both the desktop and mobile branches below.
|
// otherwise identical for both the desktop and mobile branches below.
|
||||||
const { templateModalOpen, setTemplateModalOpen, headCodeModalOpen, setHeadCodeModalOpen, overflowOpen, setOverflowOpen } = useMobileChrome();
|
const { templateModalOpen, setTemplateModalOpen, headCodeModalOpen, setHeadCodeModalOpen, overflowOpen, setOverflowOpen } = useMobileChrome();
|
||||||
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { open: openSitesmith } = useSitesmithModal();
|
const { open: openSitesmith } = useSitesmithModal();
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -100,11 +104,16 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
|
|
||||||
const handlePublish = useCallback(async () => {
|
const handlePublish = useCallback(async () => {
|
||||||
setPublishStatus('publishing');
|
setPublishStatus('publishing');
|
||||||
|
setPublishWarnings([]);
|
||||||
try {
|
try {
|
||||||
const result = await publish();
|
const result = await publish();
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
setPublishStatus('published');
|
setPublishStatus('published');
|
||||||
setIsDraft(false);
|
setIsDraft(false);
|
||||||
|
// The site published; these are fixable problems, not failures. Held
|
||||||
|
// independently of publishStatus so the 3s "Published" flash doesn't
|
||||||
|
// take the warning down with it.
|
||||||
|
setPublishWarnings(Array.isArray(result.warnings) ? result.warnings : []);
|
||||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||||
} else {
|
} else {
|
||||||
@@ -133,7 +142,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
const handlePreview = useCallback(() => {
|
const handlePreview = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
const serialized = query.serialize();
|
const serialized = query.serialize();
|
||||||
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
|
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml, buildAnimationScript }) => {
|
||||||
// Get header HTML
|
// Get header HTML
|
||||||
let headerHtml = '';
|
let headerHtml = '';
|
||||||
try {
|
try {
|
||||||
@@ -154,8 +163,18 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
}
|
}
|
||||||
} catch (e) { console.warn('Footer export failed:', e); }
|
} catch (e) { console.warn('Footer export failed:', e); }
|
||||||
|
|
||||||
// Compose full page: header + body + footer
|
// Compose full page: header + body + footer. `handlePreview` below
|
||||||
const composedBody = headerHtml + bodyHtml + footerHtml;
|
// replaces the ENTIRE wrapped-doc `<body>` inner (including the
|
||||||
|
// in-body reveal `<script>` wrapInDocument already emitted) with
|
||||||
|
// this composed string, so the script would otherwise be clobbered
|
||||||
|
// and animated elements would stay hidden forever
|
||||||
|
// ([data-animation]{opacity:0} with no IntersectionObserver to ever
|
||||||
|
// add `.animated`). Re-append the reveal script here -- built from
|
||||||
|
// the SAME composed content it will end up living alongside -- so
|
||||||
|
// it survives the replacement below and fires exactly once.
|
||||||
|
const composedBody =
|
||||||
|
headerHtml + bodyHtml + footerHtml +
|
||||||
|
buildAnimationScript(headerHtml + bodyHtml + footerHtml);
|
||||||
|
|
||||||
// PKG-H: fold the active page's own SEO overrides + the site-wide
|
// PKG-H: fold the active page's own SEO overrides + the site-wide
|
||||||
// design tokens/favicon into the Preview export so editor Preview
|
// design tokens/favicon into the Preview export so editor Preview
|
||||||
@@ -210,6 +229,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<nav className="topbar topbar-mobile">
|
<nav className="topbar topbar-mobile">
|
||||||
|
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
{isWHP && (
|
{isWHP && (
|
||||||
<a
|
<a
|
||||||
@@ -285,18 +305,21 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
onToggleGuides={onToggleGuides}
|
onToggleGuides={onToggleGuides}
|
||||||
onOpenTemplates={() => setTemplateModalOpen(true)}
|
onOpenTemplates={() => setTemplateModalOpen(true)}
|
||||||
onOpenHeadCode={() => setHeadCodeModalOpen(true)}
|
onOpenHeadCode={() => setHeadCodeModalOpen(true)}
|
||||||
|
onOpenReportIssue={() => setReportOpen(true)}
|
||||||
onPreview={handlePreview}
|
onPreview={handlePreview}
|
||||||
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} />}
|
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||||
|
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="topbar">
|
<nav className="topbar">
|
||||||
|
<PublishWarnings warnings={publishWarnings} onDismiss={() => setPublishWarnings([])} />
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
{isWHP && (
|
{isWHP && (
|
||||||
<a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel">
|
<a href={whpConfig!.backUrl} className="topbar-btn back-btn" aria-label="Back to Panel">
|
||||||
@@ -358,6 +381,15 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
|
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
|
||||||
<i className="fa fa-eye" />
|
<i className="fa fa-eye" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="topbar-btn icon-only"
|
||||||
|
aria-label="Report an issue"
|
||||||
|
data-tooltip="Report an issue"
|
||||||
|
title="Report an issue"
|
||||||
|
onClick={() => setReportOpen(true)}
|
||||||
|
>
|
||||||
|
<i className="fa fa-bug" />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Draft/Published status badge */}
|
{/* Draft/Published status badge */}
|
||||||
{isWHP && isDraft && publishStatus !== 'published' && (
|
{isWHP && isDraft && publishStatus !== 'published' && (
|
||||||
@@ -422,6 +454,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
|||||||
</div>
|
</div>
|
||||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||||
|
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface TopBarOverflowMenuProps {
|
|||||||
onToggleGuides: () => void;
|
onToggleGuides: () => void;
|
||||||
onOpenTemplates: () => void;
|
onOpenTemplates: () => void;
|
||||||
onOpenHeadCode: () => void;
|
onOpenHeadCode: () => void;
|
||||||
|
onOpenReportIssue: () => void;
|
||||||
onPreview: () => void;
|
onPreview: () => void;
|
||||||
/** Rendered `<SitesmithButton onClick={...} />` -- passed in rather than
|
/** Rendered `<SitesmithButton onClick={...} />` -- passed in rather than
|
||||||
* re-implemented here so the mobile menu reuses the exact same
|
* re-implemented here so the mobile menu reuses the exact same
|
||||||
@@ -33,6 +34,7 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
|||||||
onToggleGuides,
|
onToggleGuides,
|
||||||
onOpenTemplates,
|
onOpenTemplates,
|
||||||
onOpenHeadCode,
|
onOpenHeadCode,
|
||||||
|
onOpenReportIssue,
|
||||||
onPreview,
|
onPreview,
|
||||||
sitesmithNode,
|
sitesmithNode,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -91,6 +93,9 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
|||||||
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenHeadCode)}>
|
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenHeadCode)}>
|
||||||
<i className="fa fa-code" aria-hidden="true" /> Head Code
|
<i className="fa fa-code" aria-hidden="true" /> Head Code
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenReportIssue)}>
|
||||||
|
<i className="fa fa-bug" aria-hidden="true" /> Report an issue
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`topbar-overflow-item${showGuides ? ' active' : ''}`}
|
className={`topbar-overflow-item${showGuides ? ' active' : ''}`}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { PageProvider, usePages } from './PageContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 8 review finding: `PageContext.orphan-repair.test.ts` (the brief's
|
||||||
|
* prescribed test) drives `renderEditorHarness()` + `repairOrphanNodes()`
|
||||||
|
* directly -- it never mounts `PageProvider`, so nothing in it actually
|
||||||
|
* exercises `loadState`. If the `repairOrphanNodes` call were deleted from
|
||||||
|
* `loadState` outright, that file would still pass in full.
|
||||||
|
*
|
||||||
|
* This file closes that gap: it mounts a real `PageProvider` (same
|
||||||
|
* `vi.mock('@craftjs/core', ...)` + `deserializeMock` pattern as
|
||||||
|
* `PageContext.pages-productivity.test.tsx`) and drives `switchPage` --
|
||||||
|
* `loadState`'s only reachable-from-the-UI caller for an already-stored
|
||||||
|
* page -- against a page whose stored `craftState` contains a node with no
|
||||||
|
* path back to ROOT. It asserts on what `loadState` actually handed to
|
||||||
|
* `actions.deserialize` (mocked here, same as the sibling suite) rather than
|
||||||
|
* on Craft.js's own reconciliation, which `PageContext.orphan-repair.test.ts`
|
||||||
|
* already covers via the real editor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let serializeReturn = '{}';
|
||||||
|
const deserializeMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({
|
||||||
|
query: { serialize: () => serializeReturn },
|
||||||
|
actions: { deserialize: deserializeMock },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushTimers() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
serializeReturn = '{}';
|
||||||
|
deserializeMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A ROOT with no children plus an orphan ('stray') whose `parent` points at
|
||||||
|
* an id that doesn't exist in the tree, and which no node's `nodes`/
|
||||||
|
* `linkedNodes` lists -- unreachable by BFS from ROOT. */
|
||||||
|
const ORPHAN_STATE = JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
isCanvas: true,
|
||||||
|
props: { style: {}, tag: 'div' },
|
||||||
|
displayName: 'Container',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null,
|
||||||
|
},
|
||||||
|
stray: {
|
||||||
|
type: { resolvedName: 'HtmlBlock' },
|
||||||
|
isCanvas: false,
|
||||||
|
props: { code: '<p>stranded</p>', style: {} },
|
||||||
|
displayName: 'HTML',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadState (via switchPage) repairs an orphaned node before handing it to Craft', () => {
|
||||||
|
test('switching to a page whose stored state has an orphan reattaches it and warns', async () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// pages: [Home]. Add "About" -- addPage switches the live canvas to it.
|
||||||
|
act(() => ctx!.addPage('About', 'about'));
|
||||||
|
await flushTimers();
|
||||||
|
const aboutId = ctx!.pages[1].id;
|
||||||
|
|
||||||
|
// Switch back to Home so About is no longer the active page -- switching
|
||||||
|
// TO an already-active page is a documented no-op in `switchPage`, and
|
||||||
|
// this test needs a real switch-INTO event to fire `loadState`.
|
||||||
|
act(() => ctx!.switchPage('home'));
|
||||||
|
await flushTimers();
|
||||||
|
|
||||||
|
// Seed About's STORED craftState directly with the orphaned tree, the
|
||||||
|
// same way a loaded project's saved state reaches PageContext (e.g. via
|
||||||
|
// `setPagesCraftState` from `useWhpApi`'s `load()`), bypassing the live
|
||||||
|
// canvas entirely so nothing but `loadState` itself can repair it.
|
||||||
|
act(() =>
|
||||||
|
ctx!.setPagesCraftState(
|
||||||
|
ctx!.pages.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
slug: p.slug,
|
||||||
|
craftState: p.id === aboutId ? ORPHAN_STATE : p.craftState,
|
||||||
|
seo: p.seo,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// I5 (review): the orphan-repair log must be console.error, not
|
||||||
|
// console.warn -- console-buffer.ts (feeding the in-builder issue
|
||||||
|
// reporter) only patches console.error, and this reattach signal is the
|
||||||
|
// single most diagnostic clue for the still-unreproduced "elements drop
|
||||||
|
// off the canvas" report.
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
deserializeMock.mockClear();
|
||||||
|
|
||||||
|
// The real switch-into-a-stored-page path.
|
||||||
|
act(() => ctx!.switchPage(aboutId));
|
||||||
|
await flushTimers();
|
||||||
|
|
||||||
|
expect(deserializeMock).toHaveBeenCalled();
|
||||||
|
const passedState = deserializeMock.mock.calls[deserializeMock.mock.calls.length - 1][0];
|
||||||
|
const parsed = JSON.parse(passedState);
|
||||||
|
// The orphan is now an ordinary, reachable child of ROOT.
|
||||||
|
expect(parsed.ROOT.nodes).toContain('stray');
|
||||||
|
expect(parsed.stray.parent).toBe('ROOT');
|
||||||
|
|
||||||
|
// The observable signal that repair actually ran, not just that the
|
||||||
|
// orphan happened to be absent for some unrelated reason. (React's own
|
||||||
|
// act()-environment warnings also go through console.error in this
|
||||||
|
// harness, so search all calls rather than assuming index 0.)
|
||||||
|
expect(errorSpy.mock.calls.some((call) => String(call[0]).includes('reattached'))).toBe(true);
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { renderEditorHarness } from '../test-utils/editorHarness';
|
||||||
|
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||||
|
import { EMPTY_CANVAS } from './PageContext';
|
||||||
|
|
||||||
|
describe('EMPTY_CANVAS is exported and loadable', () => {
|
||||||
|
test('deserializing EMPTY_CANVAS gives a ROOT with no children', () => {
|
||||||
|
const harness = renderEditorHarness({ initialState: EMPTY_CANVAS });
|
||||||
|
const nodes = JSON.parse(harness.getSerialized());
|
||||||
|
expect(nodes.ROOT.nodes).toEqual([]);
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repaired state survives a real Craft deserialize', () => {
|
||||||
|
test('an orphaned HTML block becomes a selectable child of ROOT', () => {
|
||||||
|
const broken = JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
isCanvas: true,
|
||||||
|
props: { style: {}, tag: 'div' },
|
||||||
|
displayName: 'Container',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null,
|
||||||
|
},
|
||||||
|
stray: {
|
||||||
|
type: { resolvedName: 'HtmlBlock' },
|
||||||
|
isCanvas: false,
|
||||||
|
props: { code: '<p>stranded</p>', style: {} },
|
||||||
|
displayName: 'HTML',
|
||||||
|
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { state, repaired } = repairOrphanNodes(broken);
|
||||||
|
expect(repaired).toEqual(['stray']);
|
||||||
|
|
||||||
|
const harness = renderEditorHarness({ initialState: state });
|
||||||
|
const nodes = JSON.parse(harness.getSerialized());
|
||||||
|
expect(nodes.ROOT.nodes).toContain('stray');
|
||||||
|
expect(harness.container.textContent).toContain('stranded');
|
||||||
|
harness.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { PageProvider, usePages, applyLandingInvariant } from './PageContext';
|
||||||
|
import { PageData } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PKG-I: page duplicate / reorder / set-landing.
|
||||||
|
*
|
||||||
|
* The landing-page invariant is: `pages[0]` is the landing page, slug
|
||||||
|
* LOCKED to `'index'`; every other page gets a real, unique slug. This
|
||||||
|
* suite covers:
|
||||||
|
* - `applyLandingInvariant` as a pure function (unit tests, no provider).
|
||||||
|
* - `movePage`/`setLandingPage` re-establishing the invariant after
|
||||||
|
* reordering, via `PageProvider`.
|
||||||
|
* - `duplicatePage` inserting a copy right after the source with a copied
|
||||||
|
* craftState + seo and a unique slug, and switching the canvas to it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makePage(overrides: Partial<PageData> & { id: string }): PageData {
|
||||||
|
return { name: overrides.id, slug: overrides.id, craftState: null, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('applyLandingInvariant (pure)', () => {
|
||||||
|
test('page at index 0 gets slug "index" even if it held a different slug', () => {
|
||||||
|
const pages = [
|
||||||
|
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||||
|
makePage({ id: 'b', name: 'Home', slug: 'index' }),
|
||||||
|
];
|
||||||
|
const result = applyLandingInvariant(pages);
|
||||||
|
expect(result[0].slug).toBe('index');
|
||||||
|
expect(result[0].id).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demotes the old landing page (now at index > 0) to a unique real slug', () => {
|
||||||
|
const pages = [
|
||||||
|
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||||
|
makePage({ id: 'b', name: 'Home', slug: 'index' }),
|
||||||
|
];
|
||||||
|
const result = applyLandingInvariant(pages);
|
||||||
|
const demoted = result.find((p) => p.id === 'b')!;
|
||||||
|
expect(demoted.slug).not.toBe('index');
|
||||||
|
expect(demoted.slug).toBe('home');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never produces two pages with slug "index"', () => {
|
||||||
|
const pages = [
|
||||||
|
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||||
|
makePage({ id: 'b', name: 'Home', slug: 'index' }),
|
||||||
|
makePage({ id: 'c', name: 'Contact', slug: 'contact' }),
|
||||||
|
];
|
||||||
|
const result = applyLandingInvariant(pages);
|
||||||
|
const indexSlugs = result.filter((p) => p.slug === 'index');
|
||||||
|
expect(indexSlugs).toHaveLength(1);
|
||||||
|
expect(indexSlugs[0].id).toBe('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('demoted page slug is deduped against a colliding existing slug elsewhere in the array', () => {
|
||||||
|
const pages = [
|
||||||
|
makePage({ id: 'a', name: 'About', slug: 'about' }),
|
||||||
|
makePage({ id: 'b', name: 'Home', slug: 'index' }), // demoted page, named "Home" -> slugifies to "home"
|
||||||
|
makePage({ id: 'c', name: 'HomePage', slug: 'home' }), // unrelated page already using slug "home"
|
||||||
|
];
|
||||||
|
const result = applyLandingInvariant(pages);
|
||||||
|
expect(result[0].slug).toBe('index');
|
||||||
|
const demoted = result.find((p) => p.id === 'b')!;
|
||||||
|
expect(demoted.slug).toBe('home-2');
|
||||||
|
const untouched = result.find((p) => p.id === 'c')!;
|
||||||
|
expect(untouched.slug).toBe('home');
|
||||||
|
|
||||||
|
const slugs = result.map((p) => p.slug);
|
||||||
|
expect(new Set(slugs).size).toBe(slugs.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-landing pages that already have a real slug are left untouched', () => {
|
||||||
|
const pages = [
|
||||||
|
makePage({ id: 'a', name: 'Home', slug: 'index' }),
|
||||||
|
makePage({ id: 'b', name: 'About', slug: 'about' }),
|
||||||
|
makePage({ id: 'c', name: 'Contact', slug: 'contact' }),
|
||||||
|
];
|
||||||
|
const result = applyLandingInvariant(pages);
|
||||||
|
expect(result[1]).toEqual(pages[1]);
|
||||||
|
expect(result[2]).toEqual(pages[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty array is a no-op', () => {
|
||||||
|
expect(applyLandingInvariant([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- PageProvider-mounted coverage ---------- */
|
||||||
|
|
||||||
|
let serializeReturn = '{}';
|
||||||
|
const deserializeMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@craftjs/core', () => ({
|
||||||
|
useEditor: () => ({
|
||||||
|
query: { serialize: () => serializeReturn },
|
||||||
|
actions: { deserialize: deserializeMock },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
function render(ui: React.ReactElement) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
act(() => {
|
||||||
|
root = createRoot(container);
|
||||||
|
root.render(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmount() {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushTimers() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
serializeReturn = '{}';
|
||||||
|
deserializeMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageContext.movePage', () => {
|
||||||
|
test('moves a page up, swapping with its neighbor', () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => ctx!.addPage('About', 'about'));
|
||||||
|
act(() => ctx!.addPage('Contact', 'contact'));
|
||||||
|
// pages: [Home(index0), About, Contact]
|
||||||
|
const contactId = ctx!.pages[2].id;
|
||||||
|
|
||||||
|
act(() => ctx!.movePage(contactId, 'up'));
|
||||||
|
|
||||||
|
expect(ctx!.pages.map((p) => p.name)).toEqual(['Home', 'Contact', 'About']);
|
||||||
|
// Landing invariant still holds -- Home untouched at index 0.
|
||||||
|
expect(ctx!.pages[0].slug).toBe('index');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is a no-op at the top boundary', () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => ctx!.addPage('About', 'about'));
|
||||||
|
const homeId = ctx!.pages[0].id;
|
||||||
|
const before = ctx!.pages.map((p) => p.id);
|
||||||
|
|
||||||
|
act(() => ctx!.movePage(homeId, 'up'));
|
||||||
|
|
||||||
|
expect(ctx!.pages.map((p) => p.id)).toEqual(before);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is a no-op at the bottom boundary', () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => ctx!.addPage('About', 'about'));
|
||||||
|
const aboutId = ctx!.pages[1].id;
|
||||||
|
const before = ctx!.pages.map((p) => p.id);
|
||||||
|
|
||||||
|
act(() => ctx!.movePage(aboutId, 'down'));
|
||||||
|
|
||||||
|
expect(ctx!.pages.map((p) => p.id)).toEqual(before);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving a non-landing page into index 0 promotes it and demotes the old landing page to a real slug', () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => ctx!.addPage('About', 'about'));
|
||||||
|
// pages: [Home(index0, slug index), About]
|
||||||
|
const aboutId = ctx!.pages[1].id;
|
||||||
|
const homeId = ctx!.pages[0].id;
|
||||||
|
|
||||||
|
act(() => ctx!.movePage(aboutId, 'up'));
|
||||||
|
// pages: [About, Home]
|
||||||
|
|
||||||
|
expect(ctx!.pages.map((p) => p.id)).toEqual([aboutId, homeId]);
|
||||||
|
expect(ctx!.pages[0].slug).toBe('index'); // About is now the landing page
|
||||||
|
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
|
||||||
|
expect(demotedHome.slug).not.toBe('index');
|
||||||
|
expect(demotedHome.slug).toBe('home');
|
||||||
|
|
||||||
|
// Exactly one 'index' slug, always at index 0.
|
||||||
|
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
|
||||||
|
expect(indexPages).toHaveLength(1);
|
||||||
|
expect(ctx!.pages.indexOf(indexPages[0])).toBe(0);
|
||||||
|
|
||||||
|
// movePage does not touch the canvas.
|
||||||
|
expect(deserializeMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving the current landing page down demotes it and promotes its neighbor', () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => ctx!.addPage('About', 'about'));
|
||||||
|
// pages: [Home(index0, slug index), About]
|
||||||
|
const homeId = ctx!.pages[0].id;
|
||||||
|
const aboutId = ctx!.pages[1].id;
|
||||||
|
|
||||||
|
act(() => ctx!.movePage(homeId, 'down'));
|
||||||
|
// pages: [About, Home]
|
||||||
|
|
||||||
|
expect(ctx!.pages.map((p) => p.id)).toEqual([aboutId, homeId]);
|
||||||
|
|
||||||
|
// Order changed and the landing invariant re-established: index 0
|
||||||
|
// (now About) gets slug 'index'; the moved page (now at index 1, Home)
|
||||||
|
// gets a real, non-'index' unique slug.
|
||||||
|
expect(ctx!.pages[0].id).toBe(aboutId);
|
||||||
|
expect(ctx!.pages[0].slug).toBe('index');
|
||||||
|
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
|
||||||
|
expect(demotedHome.slug).not.toBe('index');
|
||||||
|
expect(demotedHome.slug).toBe('home');
|
||||||
|
|
||||||
|
// Exactly one 'index' slug.
|
||||||
|
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
|
||||||
|
expect(indexPages).toHaveLength(1);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageContext.setLandingPage', () => {
|
||||||
|
test('promotes an arbitrary page to index 0 and demotes the old landing page', () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => ctx!.addPage('About', 'about'));
|
||||||
|
act(() => ctx!.addPage('Contact', 'contact'));
|
||||||
|
const homeId = ctx!.pages[0].id;
|
||||||
|
const contactId = ctx!.pages[2].id;
|
||||||
|
|
||||||
|
act(() => ctx!.setLandingPage(contactId));
|
||||||
|
|
||||||
|
expect(ctx!.pages[0].id).toBe(contactId);
|
||||||
|
expect(ctx!.pages[0].slug).toBe('index');
|
||||||
|
const demotedHome = ctx!.pages.find((p) => p.id === homeId)!;
|
||||||
|
expect(demotedHome.slug).toBe('home');
|
||||||
|
|
||||||
|
const indexPages = ctx!.pages.filter((p) => p.slug === 'index');
|
||||||
|
expect(indexPages).toHaveLength(1);
|
||||||
|
|
||||||
|
// setLandingPage does not touch the canvas.
|
||||||
|
expect(deserializeMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is a no-op when the page is already the landing page', () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const homeId = ctx!.pages[0].id;
|
||||||
|
const before = ctx!.pages.map((p) => ({ ...p }));
|
||||||
|
|
||||||
|
act(() => ctx!.setLandingPage(homeId));
|
||||||
|
|
||||||
|
expect(ctx!.pages).toEqual(before);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageContext.duplicatePage', () => {
|
||||||
|
test('inserts a copy immediately after the source with a copied craftState, seo, and a unique slug', async () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => ctx!.addPage('About', 'about'));
|
||||||
|
await flushTimers();
|
||||||
|
act(() => ctx!.addPage('Contact', 'contact'));
|
||||||
|
await flushTimers();
|
||||||
|
// pages: [Home, About, Contact]; Contact is currently active.
|
||||||
|
|
||||||
|
const aboutId = ctx!.pages[1].id;
|
||||||
|
act(() => ctx!.updatePageSeo(aboutId, { metaTitle: 'About Us' }));
|
||||||
|
|
||||||
|
act(() => ctx!.duplicatePage(aboutId));
|
||||||
|
await flushTimers();
|
||||||
|
|
||||||
|
const names = ctx!.pages.map((p) => p.name);
|
||||||
|
expect(names).toEqual(['Home', 'About', 'About copy', 'Contact']);
|
||||||
|
|
||||||
|
const copy = ctx!.pages[2];
|
||||||
|
expect(copy.name).toBe('About copy');
|
||||||
|
expect(copy.slug).toBe('about-copy');
|
||||||
|
expect(copy.seo).toEqual({ metaTitle: 'About Us' });
|
||||||
|
|
||||||
|
// Every slug in the array is unique.
|
||||||
|
const slugs = ctx!.pages.map((p) => p.slug);
|
||||||
|
expect(new Set(slugs).size).toBe(slugs.length);
|
||||||
|
|
||||||
|
// Landing invariant untouched -- copy is never index 0.
|
||||||
|
expect(ctx!.pages[0].slug).toBe('index');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicating the ACTIVE page saves the live canvas into the copy (and the original)', async () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Home is active by default. Simulate the user having made live edits.
|
||||||
|
serializeReturn = '{"ROOT":{"live":"edit"}}';
|
||||||
|
|
||||||
|
act(() => ctx!.duplicatePage(ctx!.pages[0].id));
|
||||||
|
await flushTimers();
|
||||||
|
|
||||||
|
const copy = ctx!.pages[1];
|
||||||
|
expect(copy.name).toBe('Home copy');
|
||||||
|
expect(copy.craftState).toBe('{"ROOT":{"live":"edit"}}');
|
||||||
|
|
||||||
|
// Original page's stored state was also refreshed to the live canvas.
|
||||||
|
expect(ctx!.pages[0].craftState).toBe('{"ROOT":{"live":"edit"}}');
|
||||||
|
|
||||||
|
// The canvas switched to the new copy.
|
||||||
|
expect(ctx!.activePageId).toBe(copy.id);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicating a non-active page copies its already-stored craftState (no live-canvas read)', async () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => ctx!.addPage('About', 'about'));
|
||||||
|
await flushTimers();
|
||||||
|
// Home is now inactive, stored with whatever it serialized to on switch.
|
||||||
|
const homeId = ctx!.pages[0].id;
|
||||||
|
const homeCraftState = ctx!.pages[0].craftState;
|
||||||
|
|
||||||
|
// Switch the live serialize() return to something else, to prove
|
||||||
|
// duplicating an inactive page does NOT read the live canvas.
|
||||||
|
serializeReturn = '{"ROOT":{"unrelated":"currently-active-page-content"}}';
|
||||||
|
|
||||||
|
act(() => ctx!.duplicatePage(homeId));
|
||||||
|
await flushTimers();
|
||||||
|
|
||||||
|
const copy = ctx!.pages.find((p) => p.name === 'Home copy')!;
|
||||||
|
expect(copy.craftState).toBe(homeCraftState);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicating a non-active page does NOT drop the outgoing active page\'s live unsaved edits (regression lock)', async () => {
|
||||||
|
// Regression test for the Critical bug: duplicatePage(pageId) used to
|
||||||
|
// call saveCurrentState() ONLY when pageId === the active page, yet
|
||||||
|
// ALWAYS ended by tearing down the canvas via loadState() + switching
|
||||||
|
// activePageId to the copy. If the duplicated page was NOT the active
|
||||||
|
// one, the active page's live canvas edits were never serialized into
|
||||||
|
// its slot before that teardown -- silently discarded. This asserts the
|
||||||
|
// outgoing active page ('About') keeps its live-serialized craftState
|
||||||
|
// after duplicating a DIFFERENT page ('Home').
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => ctx!.addPage('About', 'about'));
|
||||||
|
await flushTimers();
|
||||||
|
// pages: [Home, About]; About is active (addPage switches to it).
|
||||||
|
const homeId = ctx!.pages[0].id;
|
||||||
|
const aboutId = ctx!.pages[1].id;
|
||||||
|
expect(ctx!.activePageId).toBe(aboutId);
|
||||||
|
|
||||||
|
// Simulate the user having made live, unsaved edits to About (the
|
||||||
|
// active page) that have not yet been serialized into pages[] state.
|
||||||
|
const liveAboutEdit = '{"ROOT":{"live":"about-edit-not-yet-saved"}}';
|
||||||
|
serializeReturn = liveAboutEdit;
|
||||||
|
|
||||||
|
// Duplicate a DIFFERENT page (Home), not the active one (About).
|
||||||
|
act(() => ctx!.duplicatePage(homeId));
|
||||||
|
await flushTimers();
|
||||||
|
|
||||||
|
// The outgoing active page's live edits must have been persisted into
|
||||||
|
// its own slot before the canvas was torn down and switched away.
|
||||||
|
const aboutAfter = ctx!.pages.find((p) => p.id === aboutId)!;
|
||||||
|
expect(aboutAfter.craftState).toBe(liveAboutEdit);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches the canvas to the new copy (deserialize called with the copy craftState)', async () => {
|
||||||
|
let ctx: ReturnType<typeof usePages> | null = null;
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
ctx = usePages();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<PageProvider>
|
||||||
|
<Consumer />
|
||||||
|
</PageProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
deserializeMock.mockClear();
|
||||||
|
act(() => ctx!.duplicatePage(ctx!.pages[0].id));
|
||||||
|
await flushTimers();
|
||||||
|
|
||||||
|
expect(deserializeMock).toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { PageData, PageSeo } from '../types';
|
|||||||
import { SerializedTreeNode } from '../types/sitesmith';
|
import { SerializedTreeNode } from '../types/sitesmith';
|
||||||
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
||||||
import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree';
|
import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree';
|
||||||
|
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||||
|
|
||||||
interface PageContextValue {
|
interface PageContextValue {
|
||||||
pages: PageData[];
|
pages: PageData[];
|
||||||
@@ -18,6 +19,28 @@ interface PageContextValue {
|
|||||||
addPage: (name: string, slug: string) => void;
|
addPage: (name: string, slug: string) => void;
|
||||||
deletePage: (pageId: string) => void;
|
deletePage: (pageId: string) => void;
|
||||||
renamePage: (pageId: string, name: string, slug: string) => void;
|
renamePage: (pageId: string, name: string, slug: string) => void;
|
||||||
|
/**
|
||||||
|
* Duplicates `pageId`, inserting the copy immediately after the source in
|
||||||
|
* `pages` and switching the canvas to the new copy. If `pageId` is the
|
||||||
|
* active page, its current on-canvas state is saved first so the copy
|
||||||
|
* (and the original) both reflect what's actually on screen. The copy
|
||||||
|
* gets its own unique slug (never `'index'` -- it's never at index 0) and
|
||||||
|
* a name of `"<source name> copy"`; its `seo` is copied from the source.
|
||||||
|
*/
|
||||||
|
duplicatePage: (pageId: string) => void;
|
||||||
|
/**
|
||||||
|
* Reorders `pageId` one slot `'up'` or `'down'` within `pages` (swap with
|
||||||
|
* the adjacent page; no-op at either end). Does NOT touch the live
|
||||||
|
* canvas -- only list order changes. Re-applies the landing-page
|
||||||
|
* invariant afterward (see `applyLandingInvariant`) since a reorder can
|
||||||
|
* move a different page into/out of index 0.
|
||||||
|
*/
|
||||||
|
movePage: (pageId: string, direction: 'up' | 'down') => void;
|
||||||
|
/**
|
||||||
|
* Moves `pageId` to index 0 (making it the new landing page) and
|
||||||
|
* re-applies the landing-page invariant. Does NOT touch the live canvas.
|
||||||
|
*/
|
||||||
|
setLandingPage: (pageId: string) => void;
|
||||||
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
|
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
|
||||||
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
|
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
|
||||||
setHeaderCraftState: (craftState: string) => void;
|
setHeaderCraftState: (craftState: string) => void;
|
||||||
@@ -59,7 +82,7 @@ export function nextPageId(): string {
|
|||||||
return 'page_' + Date.now().toString(36) + '_' + (++pageIdCounter).toString(36);
|
return 'page_' + Date.now().toString(36) + '_' + (++pageIdCounter).toString(36);
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_CANVAS =
|
export const EMPTY_CANVAS =
|
||||||
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||||
|
|
||||||
const EMPTY_HEADER =
|
const EMPTY_HEADER =
|
||||||
@@ -188,6 +211,9 @@ const PageContext = createContext<PageContextValue>({
|
|||||||
addPage: () => {},
|
addPage: () => {},
|
||||||
deletePage: () => {},
|
deletePage: () => {},
|
||||||
renamePage: () => {},
|
renamePage: () => {},
|
||||||
|
duplicatePage: () => {},
|
||||||
|
movePage: () => {},
|
||||||
|
setLandingPage: () => {},
|
||||||
updatePageSeo: () => {},
|
updatePageSeo: () => {},
|
||||||
setHeaderCraftState: () => {},
|
setHeaderCraftState: () => {},
|
||||||
setFooterCraftState: () => {},
|
setFooterCraftState: () => {},
|
||||||
@@ -229,6 +255,59 @@ export function uniqueSlug(base: string, existingSlugs: string[]): string {
|
|||||||
return `${base}-${i}`;
|
return `${base}-${i}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-establishes the landing-page invariant on a REORDERED pages array: the
|
||||||
|
* page now at index 0 is the landing page and its slug is locked to
|
||||||
|
* `'index'` (regardless of whatever slug it held before it was moved there);
|
||||||
|
* every other page keeps its slug UNLESS it's the page that previously held
|
||||||
|
* `'index'` and has now been demoted to index > 0 -- that page needs a real,
|
||||||
|
* unique slug of its own (derived from its name) since a page can no longer
|
||||||
|
* publish to `index.html` from anywhere but index 0.
|
||||||
|
*
|
||||||
|
* Pure function of the array -- used by both `movePage` (swap two adjacent
|
||||||
|
* pages) and `setLandingPage` (move an arbitrary page to index 0) as the
|
||||||
|
* shared "fix the invariant up after reordering" step, and directly
|
||||||
|
* unit-testable without mounting `PageProvider`.
|
||||||
|
*
|
||||||
|
* Normally at most one page enters with slug `'index'` (true for any array
|
||||||
|
* that already satisfied the invariant before the reorder that produced this
|
||||||
|
* input) -- exactly the case both callers hand it. Defensively, though, a
|
||||||
|
* STRAY second page with slug `'index'` at index > 0 (e.g. from legacy
|
||||||
|
* loaded data that predates this invariant) is also demoted rather than left
|
||||||
|
* as a duplicate -- see the running `usedSlugs` accumulation below.
|
||||||
|
*/
|
||||||
|
export function applyLandingInvariant(pages: PageData[]): PageData[] {
|
||||||
|
if (pages.length === 0) return pages;
|
||||||
|
|
||||||
|
// Slugs that must not be collided into: 'index' (reserved for whoever
|
||||||
|
// ends up at index 0) plus every non-landing page's existing slug except
|
||||||
|
// any demoted page's (it currently holds 'index' and is about to be given
|
||||||
|
// a new one). Computed upfront, over the WHOLE array, so a demoted page's
|
||||||
|
// new slug is checked against every other page regardless of array order
|
||||||
|
// -- checking only "slugs seen so far" while walking the array would miss
|
||||||
|
// a collision against a page that appears LATER in the list than the
|
||||||
|
// demoted one. Mutated (pushed to) as pages are demoted below so that two
|
||||||
|
// demoted pages in the same pass can't collide with EACH OTHER either.
|
||||||
|
const usedSlugs: string[] = ['index'];
|
||||||
|
for (let i = 1; i < pages.length; i++) {
|
||||||
|
if (pages[i].slug !== 'index') usedSlugs.push(pages[i].slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages.map((page, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
return page.slug === 'index' ? page : { ...page, slug: 'index' };
|
||||||
|
}
|
||||||
|
if (page.slug === 'index') {
|
||||||
|
// Demoted landing page (or a stray extra 'index' page -- see doc
|
||||||
|
// comment above) -- give it a real, unique slug of its own.
|
||||||
|
const newSlug = uniqueSlug(slugify(page.name), usedSlugs);
|
||||||
|
usedSlugs.push(newSlug);
|
||||||
|
return { ...page, slug: newSlug };
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_PAGE: PageData = {
|
const DEFAULT_PAGE: PageData = {
|
||||||
id: 'home',
|
id: 'home',
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
@@ -290,15 +369,46 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
/** Load a craft state into the Frame */
|
/** Load a craft state into the Frame.
|
||||||
|
*
|
||||||
|
* Every state goes through `repairOrphanNodes` first: a node present in
|
||||||
|
* the serialized state but not reachable from ROOT via `nodes`/
|
||||||
|
* `linkedNodes` is never instantiated by Craft.js's `<Frame>` at all --
|
||||||
|
* it doesn't render, so it isn't merely unselectable, it's invisible and
|
||||||
|
* otherwise unrecoverable. Reattaching it to the end of ROOT makes it an
|
||||||
|
* ordinary child the user can see, select and delete. Cheap (single JSON
|
||||||
|
* round-trip) and a no-op -- returning the identical string -- for the
|
||||||
|
* overwhelmingly common healthy case.
|
||||||
|
*
|
||||||
|
* Note this orphan-node repair is a different mechanism from the
|
||||||
|
* originally-reported symptom (a *visible* element on the canvas that
|
||||||
|
* can't be selected or deleted) -- that report is still unreproduced;
|
||||||
|
* see `orphan-repair.ts` for detail. */
|
||||||
const loadState = useCallback(
|
const loadState = useCallback(
|
||||||
(craftState: string | null, fallback: string) => {
|
(craftState: string | null, fallback: string) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
const source = craftState || fallback;
|
||||||
|
const { state, repaired } = repairOrphanNodes(source);
|
||||||
|
if (repaired.length > 0) {
|
||||||
|
// I5: console-buffer.ts only patches console.error, and this
|
||||||
|
// reattach signal is the single most diagnostic clue for the
|
||||||
|
// still-unreproduced "elements drop off the canvas" report -- it
|
||||||
|
// must reach the in-builder issue reporter's console buffer.
|
||||||
|
console.error(
|
||||||
|
`[site-builder] reattached ${repaired.length} unreachable node(s) to the page root:`,
|
||||||
|
repaired.join(', '),
|
||||||
|
);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
actions.deserialize(craftState || fallback);
|
actions.deserialize(state);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to deserialize state:', e);
|
console.error('Failed to deserialize state:', e);
|
||||||
try {
|
try {
|
||||||
|
// NOT run through repairOrphanNodes: `fallback` must always be
|
||||||
|
// one of the module's own known-safe constants (EMPTY_CANVAS /
|
||||||
|
// EMPTY_HEADER / EMPTY_FOOTER -- true at all current call sites),
|
||||||
|
// never untrusted/stored data, since this is the last line of
|
||||||
|
// defense before giving up silently below.
|
||||||
actions.deserialize(fallback);
|
actions.deserialize(fallback);
|
||||||
} catch (_e2) {
|
} catch (_e2) {
|
||||||
// give up
|
// give up
|
||||||
@@ -409,6 +519,101 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
[loadState],
|
[loadState],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicates `pageId`: inserts a copy immediately after the source in
|
||||||
|
* `pages` and switches the live canvas to it. See the doc comment on
|
||||||
|
* `PageContextValue.duplicatePage`.
|
||||||
|
*/
|
||||||
|
const duplicatePage = useCallback(
|
||||||
|
(pageId: string) => {
|
||||||
|
// Always persist whatever is on the live canvas back into its page
|
||||||
|
// slot BEFORE any teardown below (same as addPage/switchPage/deletePage
|
||||||
|
// do unconditionally). Without this, duplicating a page OTHER than the
|
||||||
|
// active one would tear down and switch the canvas via loadState()
|
||||||
|
// further down without ever serializing the outgoing active page's
|
||||||
|
// live edits into its slot -- silently discarding them.
|
||||||
|
saveCurrentState();
|
||||||
|
|
||||||
|
const isActive = pageId === activePageIdRef.current;
|
||||||
|
const source = pagesRef.current.find((p) => p.id === pageId);
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
// If the source IS the active page, saveCurrentState() above just
|
||||||
|
// wrote the live canvas into `source.craftState`'s slot -- but
|
||||||
|
// `pagesRef.current` (captured above) may still be the pre-update
|
||||||
|
// snapshot depending on render timing, so ask Craft.js directly for
|
||||||
|
// the same value rather than re-reading the ref. If the source is a
|
||||||
|
// NON-active page, its stored craftState is untouched by saving the
|
||||||
|
// (different) active page above, so use it as-is.
|
||||||
|
const sourceCraftState = isActive ? query.serialize() : source.craftState;
|
||||||
|
const otherSlugs = pagesRef.current.map((p) => p.slug);
|
||||||
|
const copyId = nextPageId();
|
||||||
|
const copyName = `${source.name} copy`;
|
||||||
|
// The copy is always inserted AFTER the source (index >= 1), so it
|
||||||
|
// never needs the reserved 'index' slug -- a normal unique slug always
|
||||||
|
// applies here regardless of whether the source itself is the landing
|
||||||
|
// page.
|
||||||
|
const copySlug = uniqueSlug(slugify(copyName), otherSlugs);
|
||||||
|
const copy: PageData = {
|
||||||
|
id: copyId,
|
||||||
|
name: copyName,
|
||||||
|
slug: copySlug,
|
||||||
|
craftState: sourceCraftState,
|
||||||
|
seo: source.seo ? { ...source.seo } : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
setPages((prev) => {
|
||||||
|
const idx = prev.findIndex((p) => p.id === pageId);
|
||||||
|
if (idx === -1) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next.splice(idx + 1, 0, copy);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch the canvas to the new copy so the user lands on it, same as
|
||||||
|
// addPage switching to the freshly created page.
|
||||||
|
loadState(copy.craftState, EMPTY_CANVAS);
|
||||||
|
setActivePageId(copyId);
|
||||||
|
activePageIdRef.current = copyId;
|
||||||
|
},
|
||||||
|
[query, saveCurrentState, loadState],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorders `pageId` one slot up or down (swap with the adjacent page).
|
||||||
|
* Pure list-order change -- does not touch the live canvas. See the doc
|
||||||
|
* comment on `PageContextValue.movePage`.
|
||||||
|
*/
|
||||||
|
const movePage = useCallback((pageId: string, direction: 'up' | 'down') => {
|
||||||
|
setPages((prev) => {
|
||||||
|
const idx = prev.findIndex((p) => p.id === pageId);
|
||||||
|
if (idx === -1) return prev;
|
||||||
|
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||||
|
if (swapIdx < 0 || swapIdx >= prev.length) return prev; // no-op at the ends
|
||||||
|
|
||||||
|
const next = [...prev];
|
||||||
|
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
||||||
|
return applyLandingInvariant(next);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves `pageId` to index 0, making it the new landing page. Pure list-
|
||||||
|
* order change -- does not touch the live canvas. See the doc comment on
|
||||||
|
* `PageContextValue.setLandingPage`.
|
||||||
|
*/
|
||||||
|
const setLandingPage = useCallback((pageId: string) => {
|
||||||
|
setPages((prev) => {
|
||||||
|
const idx = prev.findIndex((p) => p.id === pageId);
|
||||||
|
if (idx <= 0) return prev; // already the landing page, or not found
|
||||||
|
|
||||||
|
const next = [...prev];
|
||||||
|
const [moved] = next.splice(idx, 1);
|
||||||
|
next.unshift(moved);
|
||||||
|
return applyLandingInvariant(next);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
|
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
|
||||||
setPages((prev) =>
|
setPages((prev) =>
|
||||||
prev.map((p, i) => {
|
prev.map((p, i) => {
|
||||||
@@ -556,6 +761,9 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
addPage,
|
addPage,
|
||||||
deletePage,
|
deletePage,
|
||||||
renamePage,
|
renamePage,
|
||||||
|
duplicatePage,
|
||||||
|
movePage,
|
||||||
|
setLandingPage,
|
||||||
updatePageSeo,
|
updatePageSeo,
|
||||||
setHeaderCraftState,
|
setHeaderCraftState,
|
||||||
setFooterCraftState,
|
setFooterCraftState,
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ body {
|
|||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
/* Positioned ancestor for .publish-warnings (position: absolute; top:
|
||||||
|
100%), which is rendered as this <nav>'s first child in both the
|
||||||
|
desktop and mobile branches -- without this, it would anchor to the
|
||||||
|
viewport instead of sitting directly under the topbar. */
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-left,
|
.topbar-left,
|
||||||
@@ -1884,3 +1889,43 @@ body {
|
|||||||
height: 44px !important;
|
height: 44px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Publish warnings banner -- non-blocking; the site DID publish. Anchored
|
||||||
|
to .topbar's `position: relative` (see above) so it drops down directly
|
||||||
|
beneath the bar in both the desktop and mobile branches.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.publish-warnings {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #422006;
|
||||||
|
border-bottom: 1px solid #a16207;
|
||||||
|
color: #fde68a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.publish-warnings ul { margin: 0; padding-left: 16px; flex: 1; }
|
||||||
|
.publish-warnings li { margin: 2px 0; }
|
||||||
|
.publish-warnings button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fde68a;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Layers: display-only rows derived from a composite's array props
|
||||||
|
(Features Grid features, Tabs tabs, ...). Dimmer than real node rows and
|
||||||
|
with no disclosure column, so they don't read as draggable Craft nodes.
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.layer-virtual-row:hover {
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from 'react';
|
|||||||
import { renderEditorHarness, EditorHarness } from '../editorHarness';
|
import { renderEditorHarness, EditorHarness } from '../editorHarness';
|
||||||
import { useNodeActions, NodeActions } from '../../hooks/useNodeActions';
|
import { useNodeActions, NodeActions } from '../../hooks/useNodeActions';
|
||||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
|
||||||
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
|
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real-`@craftjs/core` integration coverage for duplicate/paste.
|
* Real-`@craftjs/core` integration coverage for duplicate/paste.
|
||||||
@@ -67,10 +67,39 @@ const INITIAL_STATE = JSON.stringify({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A second, independent "page" state -- distinct node ids from INITIAL_STATE,
|
||||||
|
// simulating what PageContext.switchPage does: `query.serialize()` the
|
||||||
|
// current page, then `actions.deserialize()` the target page's stored
|
||||||
|
// state, replacing the ENTIRE node map. `social-1` (copied from page A)
|
||||||
|
// does not exist anywhere in this state.
|
||||||
|
const PAGE_B_STATE = JSON.stringify({
|
||||||
|
ROOT: {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
isCanvas: true,
|
||||||
|
props: { style: {}, tag: 'div' },
|
||||||
|
displayName: 'Container',
|
||||||
|
custom: {},
|
||||||
|
hidden: false,
|
||||||
|
nodes: ['page-b-heading-1'],
|
||||||
|
linkedNodes: {},
|
||||||
|
},
|
||||||
|
'page-b-heading-1': {
|
||||||
|
type: { resolvedName: 'Heading' },
|
||||||
|
isCanvas: false,
|
||||||
|
props: { text: 'Page B Heading', level: 'h2' },
|
||||||
|
displayName: 'Heading',
|
||||||
|
custom: {},
|
||||||
|
hidden: false,
|
||||||
|
parent: 'ROOT',
|
||||||
|
nodes: [],
|
||||||
|
linkedNodes: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
let harness: EditorHarness | null = null;
|
let harness: EditorHarness | null = null;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setClipboardNodeId(null);
|
setClipboardTree(null);
|
||||||
if (harness) {
|
if (harness) {
|
||||||
harness.unmount();
|
harness.unmount();
|
||||||
harness = null;
|
harness = null;
|
||||||
@@ -158,7 +187,7 @@ describe('duplicate/paste (real @craftjs/core editor)', () => {
|
|||||||
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }),
|
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(getClipboardNodeId()).toBe('social-1');
|
expect(getClipboardTree()!.rootNodeId).toBe('social-1');
|
||||||
|
|
||||||
// Select heading-1 (a sibling), then paste -- should land as a sibling
|
// Select heading-1 (a sibling), then paste -- should land as a sibling
|
||||||
// of heading-1's parent (ROOT), with brand-new ids.
|
// of heading-1's parent (ROOT), with brand-new ids.
|
||||||
@@ -190,4 +219,76 @@ describe('duplicate/paste (real @craftjs/core editor)', () => {
|
|||||||
expect(pastedProps.links).not.toBe(originalProps.links);
|
expect(pastedProps.links).not.toBe(originalProps.links);
|
||||||
expect(pastedProps).toEqual(originalProps);
|
expect(pastedProps).toEqual(originalProps);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('CROSS-PAGE copy/paste: copy on page A, switch to page B, paste -- the node appears on page B', () => {
|
||||||
|
harness = renderEditorHarness({ initialState: INITIAL_STATE });
|
||||||
|
|
||||||
|
const Consumer: React.FC = () => {
|
||||||
|
useKeyboardShortcuts();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
harness.mountChild(<Consumer />);
|
||||||
|
|
||||||
|
// --- Page A: select + copy social-1. ---
|
||||||
|
harness.act(() => {
|
||||||
|
harness!.actions.selectNode('social-1');
|
||||||
|
});
|
||||||
|
harness.act(() => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(getClipboardTree()!.rootNodeId).toBe('social-1');
|
||||||
|
|
||||||
|
// --- Switch to page B: exactly what PageContext.switchPage does --
|
||||||
|
// serialize (discarded here, a real page switch would stash it) then
|
||||||
|
// deserialize the target page's state, replacing the ENTIRE node map.
|
||||||
|
// `social-1` no longer exists anywhere in `query` after this. ---
|
||||||
|
harness.act(() => {
|
||||||
|
harness!.actions.deserialize(PAGE_B_STATE);
|
||||||
|
});
|
||||||
|
expect(harness.query.getNodes()['social-1']).toBeUndefined();
|
||||||
|
|
||||||
|
// Select page B's only node, then paste.
|
||||||
|
harness.act(() => {
|
||||||
|
harness!.actions.selectNode('page-b-heading-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
const beforePaste = Object.keys(harness.query.getNodes());
|
||||||
|
expect(() => {
|
||||||
|
harness!.act(() => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'v', ctrlKey: true, bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
const afterPaste = Object.keys(harness.query.getNodes());
|
||||||
|
|
||||||
|
// The regression this guards against: with the old id-based clipboard,
|
||||||
|
// `query.node('social-1').get()` returns undefined once page B is
|
||||||
|
// loaded, so the paste handler's guard silently no-ops -- NOTHING gets
|
||||||
|
// added. With the tree-snapshot clipboard, the copied subtree is
|
||||||
|
// detached from any live query and pastes onto page B regardless.
|
||||||
|
expect(afterPaste.length).toBe(beforePaste.length + 1);
|
||||||
|
|
||||||
|
const pastedId = afterPaste.find((id) => !beforePaste.includes(id))!;
|
||||||
|
expect(pastedId).toBeDefined();
|
||||||
|
|
||||||
|
const pastedNode = harness.query.node(pastedId).get();
|
||||||
|
expect(pastedNode.data.displayName).toBe('Social Links');
|
||||||
|
expect(pastedNode.data.props.links).toEqual([
|
||||||
|
{ platform: 'facebook', url: 'https://facebook.com/original' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Actually landed on page B's tree, as a sibling of the selected node.
|
||||||
|
const rootChildren: string[] = harness.query.node('ROOT').get().data.nodes;
|
||||||
|
expect(rootChildren).toContain(pastedId);
|
||||||
|
|
||||||
|
// Real DOM assertion: the pasted SocialLinks component is actually
|
||||||
|
// rendered on the (now page B) canvas.
|
||||||
|
expect(
|
||||||
|
harness.container.querySelectorAll('a[href="https://facebook.com/original"]'),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, test, expect, vi, afterEach } from 'vitest';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot, Root } from 'react-dom/client';
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { CodeEditor } from './CodeEditor';
|
import { CodeEditor, type CodeEditorHandle } from './CodeEditor';
|
||||||
|
|
||||||
/* ---------- DOM test harness (no @testing-library/react in this repo; see
|
/* ---------- DOM test harness (no @testing-library/react in this repo; see
|
||||||
src/ui/AssetPicker.test.tsx / src/ui/Modal.test.tsx for the same
|
src/ui/AssetPicker.test.tsx / src/ui/Modal.test.tsx for the same
|
||||||
@@ -88,3 +88,106 @@ describe('CodeEditor', () => {
|
|||||||
expect((container.firstElementChild as HTMLElement).style.height).toBe('480px');
|
expect((container.firstElementChild as HTMLElement).style.height).toBe('480px');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CodeEditor imperative handle (textarea fallback mode)', () => {
|
||||||
|
test('insertAtCursor replaces the selection and emits onChange', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const ref = React.createRef<CodeEditorHandle>();
|
||||||
|
render(<CodeEditor ref={ref} value="<div></div>" onChange={onChange} />);
|
||||||
|
|
||||||
|
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
|
||||||
|
expect(ta).not.toBeNull();
|
||||||
|
ta.selectionStart = 5;
|
||||||
|
ta.selectionEnd = 5;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
ref.current!.insertAtCursor('<p></p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('<div><p></p></div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getValue returns the current document', () => {
|
||||||
|
const ref = React.createRef<CodeEditorHandle>();
|
||||||
|
render(<CodeEditor ref={ref} value="<span>x</span>" onChange={vi.fn()} />);
|
||||||
|
expect(ref.current!.getValue()).toBe('<span>x</span>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caretOffset positions the caret inside the inserted snippet', () => {
|
||||||
|
const ref = React.createRef<CodeEditorHandle>();
|
||||||
|
render(<CodeEditor ref={ref} value="" onChange={vi.fn()} />);
|
||||||
|
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
|
||||||
|
act(() => {
|
||||||
|
ref.current!.insertAtCursor('<p></p>', 3);
|
||||||
|
});
|
||||||
|
expect(ta.selectionStart).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insertAtCursor replaces a non-empty selection (not just an empty caret)', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const ref = React.createRef<CodeEditorHandle>();
|
||||||
|
render(<CodeEditor ref={ref} value="<div>old</div>" onChange={onChange} />);
|
||||||
|
|
||||||
|
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
|
||||||
|
// "<div>old</div>" -- select "old" (indices 5-8).
|
||||||
|
ta.selectionStart = 5;
|
||||||
|
ta.selectionEnd = 8;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
ref.current!.insertAtCursor('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('<div>new</div>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CodeEditor imperative handle: fallback insert survives a later CodeMirror mount', () => {
|
||||||
|
// Regression test for a data-loss bug: insertAtCursor's textarea-fallback
|
||||||
|
// branch used to advance lastEmittedRef to the post-insertion value. The
|
||||||
|
// mount effect's dynamic import() closes over `value` as of the initial
|
||||||
|
// render, so if CodeMirror finishes loading *after* a fallback-mode
|
||||||
|
// insertion, it mounts with the pre-insertion doc. The only thing that
|
||||||
|
// repairs that is the value-sync effect, which is gated on `value !==
|
||||||
|
// lastEmittedRef.current` -- advancing lastEmittedRef made that gate see
|
||||||
|
// them as already equal and skip the repair, silently dropping the
|
||||||
|
// insertion. This test drives the component through that exact sequence
|
||||||
|
// using the real @codemirror/* packages (no mocks, no fake timers) to
|
||||||
|
// prove the fix holds.
|
||||||
|
test('insertAtCursor in fallback mode is not lost once CodeMirror mounts', async () => {
|
||||||
|
const handleRef = React.createRef<CodeEditorHandle>();
|
||||||
|
function Harness() {
|
||||||
|
const [value, setValue] = React.useState('<div></div>');
|
||||||
|
return <CodeEditor ref={handleRef} value={value} onChange={setValue} />;
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
|
||||||
|
// First tick: the dynamic import() chain is always async, so this is
|
||||||
|
// still the textarea fallback (see the file-level comment above).
|
||||||
|
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
|
||||||
|
expect(ta).not.toBeNull();
|
||||||
|
ta.selectionStart = 5;
|
||||||
|
ta.selectionEnd = 5;
|
||||||
|
act(() => {
|
||||||
|
handleRef.current!.insertAtCursor('<p></p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let the real dynamic import() of @codemirror/* actually resolve and
|
||||||
|
// the view mount (real elapsed time, not mocked/faked).
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm CodeMirror actually mounted (fallback textarea gone, replaced
|
||||||
|
// by the CodeMirror root) -- otherwise this assertion would trivially
|
||||||
|
// pass by reading back the fallback textarea's own value and wouldn't
|
||||||
|
// exercise the bug at all.
|
||||||
|
expect(container.querySelector('[data-testid="code-editor-fallback"]')).toBeNull();
|
||||||
|
|
||||||
|
// The value-sync effect must have pushed the post-insertion value into
|
||||||
|
// the freshly-mounted doc -- the insertion must not have been dropped.
|
||||||
|
expect(handleRef.current!.getValue()).toBe('<div><p></p></div>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
import type { EditorView as EditorViewType } from '@codemirror/view';
|
import type { EditorView as EditorViewType } from '@codemirror/view';
|
||||||
|
|
||||||
export type CodeEditorLanguage = 'html' | 'css' | 'javascript' | 'auto';
|
export type CodeEditorLanguage = 'html' | 'css' | 'javascript' | 'auto';
|
||||||
@@ -14,6 +14,15 @@ export interface CodeEditorProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CodeEditorHandle {
|
||||||
|
/** Replace the current selection (or insert at the caret) with `text`.
|
||||||
|
* Emits onChange. If `caretOffset` is given, the caret lands that many
|
||||||
|
* characters after the insertion start instead of at its end. */
|
||||||
|
insertAtCursor(text: string, caretOffset?: number): void;
|
||||||
|
/** Current document text. */
|
||||||
|
getValue(): string;
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------
|
/* ----------------------------------------------------------------
|
||||||
Lazy-loaded CodeMirror 6.
|
Lazy-loaded CodeMirror 6.
|
||||||
|
|
||||||
@@ -100,6 +109,15 @@ function loadCodeMirror(): Promise<CmModules> {
|
|||||||
return cmModulesPromise;
|
return cmModulesPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guards against a caller passing a `caretOffset` outside [0, text.length]
|
||||||
|
// (e.g. a stale offset computed against different snippet text), which
|
||||||
|
// would otherwise let `insertAtCursor` compute a caret position past the
|
||||||
|
// text it just inserted.
|
||||||
|
function clampCaretOffset(caretOffset: number | undefined, textLength: number): number {
|
||||||
|
if (caretOffset === undefined) return textLength;
|
||||||
|
return Math.min(Math.max(caretOffset, 0), textLength);
|
||||||
|
}
|
||||||
|
|
||||||
function languageExtension(mods: CmModules, language: CodeEditorLanguage) {
|
function languageExtension(mods: CmModules, language: CodeEditorLanguage) {
|
||||||
switch (language) {
|
switch (language) {
|
||||||
case 'css':
|
case 'css':
|
||||||
@@ -133,15 +151,16 @@ const fallbackStyle: React.CSSProperties = {
|
|||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CodeEditor: React.FC<CodeEditorProps> = ({
|
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
language = 'html',
|
language = 'html',
|
||||||
height = 320,
|
height = 320,
|
||||||
placeholder,
|
placeholder,
|
||||||
}) => {
|
}: CodeEditorProps, ref) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewRef = useRef<EditorViewType | null>(null);
|
const viewRef = useRef<EditorViewType | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
// Tracks the last value this component itself emitted, so the
|
// Tracks the last value this component itself emitted, so the
|
||||||
@@ -226,6 +245,51 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|||||||
lastEmittedRef.current = value;
|
lastEmittedRef.current = value;
|
||||||
}, [value, status]);
|
}, [value, status]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, (): CodeEditorHandle => ({
|
||||||
|
getValue: () => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (view) return view.state.doc.toString();
|
||||||
|
return textareaRef.current?.value ?? value;
|
||||||
|
},
|
||||||
|
insertAtCursor: (text: string, caretOffset?: number) => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (view) {
|
||||||
|
const { from, to } = view.state.selection.main;
|
||||||
|
const caret = from + clampCaretOffset(caretOffset, text.length);
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from, to, insert: text },
|
||||||
|
selection: { anchor: caret },
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Textarea fallback: CodeMirror never mounted yet (still loading, or
|
||||||
|
// its lazy chunks failed). The toolbar must keep working either way.
|
||||||
|
//
|
||||||
|
// Deliberately do NOT touch lastEmittedRef here -- same as the plain
|
||||||
|
// textarea onChange handler below, which never touches it either.
|
||||||
|
// The mount effect's dynamic import() closes over `value` at the time
|
||||||
|
// it started, so CodeMirror can finish loading with a stale doc if it
|
||||||
|
// resolves after this insertion. The only thing that catches that is
|
||||||
|
// the value-sync effect above, which is gated on `value !==
|
||||||
|
// lastEmittedRef.current`. If this insertion advanced lastEmittedRef
|
||||||
|
// to `next`, that effect would see value === lastEmittedRef.current
|
||||||
|
// once the parent re-renders and silently skip pushing the insertion
|
||||||
|
// into the freshly-mounted (stale) doc -- losing it for good.
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (!ta) return;
|
||||||
|
const from = ta.selectionStart ?? ta.value.length;
|
||||||
|
const to = ta.selectionEnd ?? from;
|
||||||
|
const next = ta.value.slice(0, from) + text + ta.value.slice(to);
|
||||||
|
const caret = from + clampCaretOffset(caretOffset, text.length);
|
||||||
|
onChangeRef.current(next);
|
||||||
|
ta.value = next;
|
||||||
|
ta.selectionStart = caret;
|
||||||
|
ta.selectionEnd = caret;
|
||||||
|
ta.focus();
|
||||||
|
},
|
||||||
|
}), [value]);
|
||||||
|
|
||||||
const showFallback = status !== 'ready';
|
const showFallback = status !== 'ready';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -243,6 +307,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|||||||
/>
|
/>
|
||||||
{showFallback && (
|
{showFallback && (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
data-testid="code-editor-fallback"
|
data-testid="code-editor-fallback"
|
||||||
data-language={language}
|
data-language={language}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -254,4 +319,4 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { editorBuild } from './build-stamp';
|
||||||
|
|
||||||
|
describe('editorBuild', () => {
|
||||||
|
it("returns 'dev' when __EDITOR_BUILD__ is undefined (vitest does not apply Vite's define)", () => {
|
||||||
|
expect(editorBuild()).toBe('dev');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Reads the compile-time `__EDITOR_BUILD__` define.
|
||||||
|
*
|
||||||
|
* vitest does not apply Vite's `define`, and `npm run dev` in a non-git
|
||||||
|
* checkout may not either, so every read goes through here. `typeof` on an
|
||||||
|
* undeclared identifier is safe in JS -- it does not throw.
|
||||||
|
*/
|
||||||
|
export function editorBuild(): string {
|
||||||
|
return typeof __EDITOR_BUILD__ !== 'undefined' ? __EDITOR_BUILD__ : 'dev';
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
installConsoleErrorBuffer,
|
||||||
|
getRecentConsoleErrors,
|
||||||
|
__resetConsoleErrorBuffer,
|
||||||
|
} from './console-buffer';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__resetConsoleErrorBuffer();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('console error buffer', () => {
|
||||||
|
test('captures console.error calls', () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
console.error('boom', 42);
|
||||||
|
const entries = getRecentConsoleErrors();
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0].message).toBe('boom 42');
|
||||||
|
expect(typeof entries[0].ts).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('always chains to the original console.error', () => {
|
||||||
|
const original = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
console.error('passed through');
|
||||||
|
expect(original).toHaveBeenCalledWith('passed through');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retains only the last 20 entries, oldest first', () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
for (let i = 0; i < 25; i++) console.error(`e${i}`);
|
||||||
|
const entries = getRecentConsoleErrors();
|
||||||
|
expect(entries).toHaveLength(20);
|
||||||
|
expect(entries[0].message).toBe('e5');
|
||||||
|
expect(entries[19].message).toBe('e24');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truncates a long message to 500 characters', () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
console.error('x'.repeat(900));
|
||||||
|
expect(getRecentConsoleErrors()[0].message).toHaveLength(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installing twice does not double-capture', () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
console.error('once');
|
||||||
|
expect(getRecentConsoleErrors()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('captures window error events', () => {
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
window.dispatchEvent(new ErrorEvent('error', { message: 'window blew up' }));
|
||||||
|
expect(getRecentConsoleErrors().some((e) => e.message.includes('window blew up'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRecentConsoleErrors returns a copy, not the live array', () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
console.error('a');
|
||||||
|
const first = getRecentConsoleErrors();
|
||||||
|
first.push({ ts: 0, message: 'injected' });
|
||||||
|
expect(getRecentConsoleErrors()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reinstalling after external code wraps console.error does not double-record, and reset restores the true original', () => {
|
||||||
|
const trueOriginal = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
|
||||||
|
// External code wraps our patch without knowing anything about our
|
||||||
|
// marker convention -- this is exactly what a third-party script or
|
||||||
|
// another monitor might do.
|
||||||
|
const ourPatch = console.error;
|
||||||
|
const external = vi.fn((...args: unknown[]) => {
|
||||||
|
ourPatch(...args);
|
||||||
|
});
|
||||||
|
console.error = external;
|
||||||
|
|
||||||
|
installConsoleErrorBuffer();
|
||||||
|
console.error('dup-check');
|
||||||
|
|
||||||
|
expect(getRecentConsoleErrors().filter((e) => e.message === 'dup-check')).toHaveLength(1);
|
||||||
|
|
||||||
|
__resetConsoleErrorBuffer();
|
||||||
|
expect(console.error).toBe(trueOriginal);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('surviving module re-execution: reinstalling after the module reloads does not re-wrap an already-patched console.error', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const mod1 = await import('./console-buffer');
|
||||||
|
mod1.installConsoleErrorBuffer();
|
||||||
|
const patchedAfterFirstInstall = console.error;
|
||||||
|
|
||||||
|
// Simulate HMR: the module graph re-evaluates, producing a fresh module
|
||||||
|
// instance with its own reset top-level state, while the *global*
|
||||||
|
// console.error is still whatever the previous instance patched it to.
|
||||||
|
vi.resetModules();
|
||||||
|
const mod2 = await import('./console-buffer');
|
||||||
|
mod2.installConsoleErrorBuffer();
|
||||||
|
|
||||||
|
// The marker on the live console.error -- not module-local state -- is
|
||||||
|
// what installConsoleErrorBuffer() consults, so the reloaded instance
|
||||||
|
// must recognize the existing patch and leave it alone rather than
|
||||||
|
// wrapping it a second time.
|
||||||
|
expect(console.error).toBe(patchedAfterFirstInstall);
|
||||||
|
|
||||||
|
console.error('once-across-reload');
|
||||||
|
expect(
|
||||||
|
mod1.getRecentConsoleErrors().filter((e) => e.message === 'once-across-reload')
|
||||||
|
).toHaveLength(1);
|
||||||
|
|
||||||
|
mod1.__resetConsoleErrorBuffer();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* A tiny ring buffer of the most recent errors, attached to the issue
|
||||||
|
* reports users file from inside the builder. Without it a report says
|
||||||
|
* "it broke" and nothing else.
|
||||||
|
*
|
||||||
|
* Message text only -- no stack traces. Production stacks are minified into
|
||||||
|
* uselessness and leak bundle paths for no diagnostic gain.
|
||||||
|
*
|
||||||
|
* The console.error patch ALWAYS chains to the original. A monitor that
|
||||||
|
* swallows diagnostics is worse than no monitor.
|
||||||
|
*
|
||||||
|
* Idempotency and the "true original":
|
||||||
|
*
|
||||||
|
* `installConsoleErrorBuffer()` treats console.error's *live* MARKER stamp
|
||||||
|
* (not module-scoped state) as the source of truth for "am I already the
|
||||||
|
* active patch". That alone survives React 18 StrictMode / hot module
|
||||||
|
* reload re-running this module's top level while the previous instance's
|
||||||
|
* patch is still installed on the global -- a plain `let installed = false`
|
||||||
|
* would reset on re-execution and treat that already-patched function as
|
||||||
|
* virgin, wrapping it again and building a chain that grows on every
|
||||||
|
* reload.
|
||||||
|
*
|
||||||
|
* The marker alone isn't sufficient once something *other* than us has
|
||||||
|
* touched console.error since our last install, though. If external code
|
||||||
|
* wraps our patch (`console.error = L` where L internally calls our patched
|
||||||
|
* function), console.error is unmarked again from our point of view, so a
|
||||||
|
* naive "unmarked == virgin" re-install would capture L -- an intermediate
|
||||||
|
* wrapper, not the true original -- as "the original" to chain to. That
|
||||||
|
* would (a) leave our OLD patch still reachable inside L's closure, so one
|
||||||
|
* console.error() call records twice (once via the new patch, once via the
|
||||||
|
* old one still buried inside L), and (b) make __resetConsoleErrorBuffer()
|
||||||
|
* restore console.error to L instead of the real original, permanently
|
||||||
|
* losing the reference to it.
|
||||||
|
*
|
||||||
|
* The fix is to never re-derive "the original" from whatever the live
|
||||||
|
* console.error happens to be at install time. Instead, the true original
|
||||||
|
* is captured exactly once and stashed as a hidden property directly on the
|
||||||
|
* `console` object (not in module scope, so it also survives module
|
||||||
|
* re-execution) the first time we ever patch. Every subsequent install,
|
||||||
|
* whether triggered by our own idempotent re-install, HMR, or a reinstall
|
||||||
|
* after external code has wrapped or replaced console.error, reuses that
|
||||||
|
* stashed reference and re-wraps it directly -- guaranteeing exactly one
|
||||||
|
* patch layer chains straight to the real original, and that reset can
|
||||||
|
* always find it.
|
||||||
|
*
|
||||||
|
* Trade-off this implies: if install() is called again after some external
|
||||||
|
* code has wrapped console.error, that external wrapper is discarded (we
|
||||||
|
* re-wrap the true original directly, not the external wrapper) rather than
|
||||||
|
* preserved. We accept that over the alternative of chaining through an
|
||||||
|
* unknown wrapper, which cannot be done safely -- there is no way to detect
|
||||||
|
* whether that wrapper still calls through to our old patch (risking double
|
||||||
|
* recording if we also wrap it) or has fully replaced it (risking losing
|
||||||
|
* capture entirely if we don't). An external wrapper installed *after* us
|
||||||
|
* and left alone (i.e. install() is not called again) is completely
|
||||||
|
* unaffected -- it just sits on top of our patch and both continue to work
|
||||||
|
* as normal JS monkey-patch layering.
|
||||||
|
*
|
||||||
|
* Known residual gap (not fixed, documented instead): if this module is hot
|
||||||
|
* reloaded while console.error stays patched from the previous instance,
|
||||||
|
* the live patch's closure still points at the *previous* module
|
||||||
|
* instance's `buffer` array. The marker check correctly stops the new
|
||||||
|
* instance from re-wrapping, but that also means the new instance's own
|
||||||
|
* `getRecentConsoleErrors()` reads its own (empty) buffer forever while
|
||||||
|
* capture silently continues into the orphaned previous instance's buffer.
|
||||||
|
* This only matters across an actual HMR reload during development --
|
||||||
|
* production has exactly one module instance for the lifetime of the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ConsoleErrorEntry {
|
||||||
|
ts: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ENTRIES = 20;
|
||||||
|
const MAX_MESSAGE = 500;
|
||||||
|
const MARKER = '__whpConsoleErrorBufferPatched';
|
||||||
|
const TRUE_ORIGINAL_KEY = '__whpConsoleErrorBufferTrueOriginal';
|
||||||
|
|
||||||
|
type MarkedConsoleError = typeof console.error & { [MARKER]?: true };
|
||||||
|
type ConsoleWithStash = typeof console & { [TRUE_ORIGINAL_KEY]?: typeof console.error };
|
||||||
|
|
||||||
|
function isPatched(fn: typeof console.error): fn is MarkedConsoleError {
|
||||||
|
return typeof fn === 'function' && (fn as MarkedConsoleError)[MARKER] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrueOriginal(): typeof console.error | undefined {
|
||||||
|
return (console as ConsoleWithStash)[TRUE_ORIGINAL_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTrueOriginal(fn: typeof console.error): void {
|
||||||
|
(console as ConsoleWithStash)[TRUE_ORIGINAL_KEY] = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTrueOriginal(): void {
|
||||||
|
delete (console as ConsoleWithStash)[TRUE_ORIGINAL_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-scoped: not consulted for correctness (see doc comment above), only
|
||||||
|
// used to avoid re-adding window listeners within a single module instance.
|
||||||
|
let buffer: ConsoleErrorEntry[] = [];
|
||||||
|
let errorListener: ((e: ErrorEvent) => void) | null = null;
|
||||||
|
let rejectionListener: ((e: PromiseRejectionEvent) => void) | null = null;
|
||||||
|
|
||||||
|
function record(message: string): void {
|
||||||
|
const text = message.length > MAX_MESSAGE ? message.slice(0, MAX_MESSAGE) : message;
|
||||||
|
buffer.push({ ts: Date.now(), message: text });
|
||||||
|
if (buffer.length > MAX_ENTRIES) buffer = buffer.slice(buffer.length - MAX_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyArg(arg: unknown): string {
|
||||||
|
if (typeof arg === 'string') return arg;
|
||||||
|
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg);
|
||||||
|
} catch {
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent. Patches console.error and adds window error listeners. */
|
||||||
|
export function installConsoleErrorBuffer(): void {
|
||||||
|
// We're already the live, active patch -- nothing to do.
|
||||||
|
if (isPatched(console.error)) return;
|
||||||
|
|
||||||
|
// Reuse the stashed true original if we've ever patched before (covers
|
||||||
|
// HMR re-execution and reinstall-after-external-wrap); otherwise this is
|
||||||
|
// a genuinely virgin install and the current console.error IS the true
|
||||||
|
// original.
|
||||||
|
const trueOriginal = getTrueOriginal() ?? console.error;
|
||||||
|
setTrueOriginal(trueOriginal);
|
||||||
|
|
||||||
|
const patched: MarkedConsoleError = (...args: unknown[]): void => {
|
||||||
|
try {
|
||||||
|
record(args.map(stringifyArg).join(' '));
|
||||||
|
} catch {
|
||||||
|
// Recording must never break logging.
|
||||||
|
}
|
||||||
|
trueOriginal.call(console, ...args);
|
||||||
|
};
|
||||||
|
patched[MARKER] = true;
|
||||||
|
console.error = patched;
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && !errorListener) {
|
||||||
|
errorListener = (e: ErrorEvent) => {
|
||||||
|
try {
|
||||||
|
record(`window.onerror: ${e.message}`);
|
||||||
|
} catch {
|
||||||
|
// Recording must never break the page's own error handling.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rejectionListener = (e: PromiseRejectionEvent) => {
|
||||||
|
try {
|
||||||
|
record(`unhandledrejection: ${stringifyArg(e.reason)}`);
|
||||||
|
} catch {
|
||||||
|
// Recording must never break the page's own rejection handling.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('error', errorListener);
|
||||||
|
window.addEventListener('unhandledrejection', rejectionListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oldest-first copy of the retained entries (at most 20). */
|
||||||
|
export function getRecentConsoleErrors(): ConsoleErrorEntry[] {
|
||||||
|
return buffer.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only: clear the buffer and un-patch. */
|
||||||
|
export function __resetConsoleErrorBuffer(): void {
|
||||||
|
buffer = [];
|
||||||
|
const trueOriginal = getTrueOriginal();
|
||||||
|
if (trueOriginal) {
|
||||||
|
console.error = trueOriginal;
|
||||||
|
}
|
||||||
|
clearTrueOriginal();
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (errorListener) window.removeEventListener('error', errorListener);
|
||||||
|
if (rejectionListener) window.removeEventListener('unhandledrejection', rejectionListener);
|
||||||
|
}
|
||||||
|
errorListener = null;
|
||||||
|
rejectionListener = null;
|
||||||
|
}
|
||||||
@@ -36,3 +36,120 @@ describe('relayFormWiring deterministic + unique fid (thread node id, no Math.ra
|
|||||||
expect(w1.actionAttr).toBe(w2.actionAttr);
|
expect(w1.actionAttr).toBe(w2.actionAttr);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ---------- Webhook destination (Task 10) ---------- */
|
||||||
|
|
||||||
|
/** The publish-side parser, verbatim from web-files/libs/FormRelayRewrite.php's
|
||||||
|
* fs_rewrite_contact_forms() -- ported to JS so a drift in the emitter's
|
||||||
|
* attribute ORDER, NAMES or QUOTING fails here rather than at publish time,
|
||||||
|
* where it either leaks the recipient address or refuses the publish. */
|
||||||
|
const PUBLISH_MARKER_RE =
|
||||||
|
/^<!--WHP-FORM id="([^"]+)"((?: [a-z]+="[^"]*")*) recipient="([^"]*)" thankyou="([^"]*)"-->$/;
|
||||||
|
|
||||||
|
describe('relayFormWiring email destination stays byte-identical to the legacy shape', () => {
|
||||||
|
test('no destination arg, type undefined, and type "email" all produce the same marker', () => {
|
||||||
|
const legacy = relayFormWiring('a@b.com', '/thx', '/act', 'n1');
|
||||||
|
const undef = relayFormWiring('a@b.com', '/thx', '/act', 'n1', {});
|
||||||
|
const email = relayFormWiring('a@b.com', '/thx', '/act', 'n1', { type: 'email', url: 'https://x.example/y', secretId: 'whs_1_abc', authMode: 'bearer' });
|
||||||
|
expect(undef.marker).toBe(legacy.marker);
|
||||||
|
expect(email.marker).toBe(legacy.marker);
|
||||||
|
expect(email.actionAttr).toBe(legacy.actionAttr);
|
||||||
|
expect(legacy.marker).toMatch(/^<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b\.com" thankyou="\/thx"-->$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an email form with no recipient is still not a relay at all', () => {
|
||||||
|
const w = relayFormWiring('', '/thx', '/legacy', 'n1', { type: 'email' });
|
||||||
|
expect(w.useRelay).toBe(false);
|
||||||
|
expect(w.marker).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('relayFormWiring webhook marker matches the publish-side parser exactly', () => {
|
||||||
|
const w = relayFormWiring('fb@b.com', '/thx', '#', 'n1', {
|
||||||
|
type: 'webhook', url: 'https://hooks.example.com/x', secretId: 'whs_7_abc123', authMode: 'bearer',
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the whole marker parses with FormRelayRewrite.php\'s pattern', () => {
|
||||||
|
const m = w.marker.match(PUBLISH_MARKER_RE);
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
expect(m![3]).toBe('fb@b.com');
|
||||||
|
expect(m![4]).toBe('/thx');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the optional attributes sit BETWEEN id and recipient, lowercase-named', () => {
|
||||||
|
const attrs = w.marker.match(PUBLISH_MARKER_RE)![2];
|
||||||
|
expect(attrs).toBe(' type="webhook" url="https://hooks.example.com/x" secret="whs_7_abc123" authmode="bearer"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a webhook with no recipient still emits a relay marker', () => {
|
||||||
|
const bare = relayFormWiring('', '', '#', 'n1', { type: 'webhook', url: 'https://hooks.example.com/x' });
|
||||||
|
expect(bare.useRelay).toBe(true);
|
||||||
|
expect(bare.marker).toMatch(PUBLISH_MARKER_RE);
|
||||||
|
expect(bare.marker).toContain('authmode="signature"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two webhook forms with no node id and different urls get different fids', () => {
|
||||||
|
const a = relayFormWiring('', '', '#', undefined, { type: 'webhook', url: 'https://a.example/x' });
|
||||||
|
const b = relayFormWiring('', '', '#', undefined, { type: 'webhook', url: 'https://b.example/x' });
|
||||||
|
expect(a.marker).not.toBe(b.marker);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('relayFormWiring escapes EVERY marker attribute value', () => {
|
||||||
|
/* The property the publish-time strip is built on: no raw `"`, `<`, `>` or a
|
||||||
|
literal `-->` may reach a marker attribute value. An unescaped one truncates
|
||||||
|
the strip mid-marker and ships the recipient address in the page source, or
|
||||||
|
trips the post-condition and refuses the publish outright. */
|
||||||
|
const hostile = 'a"b<c>d\'e-->f';
|
||||||
|
const w = relayFormWiring(`${hostile}@x.com`, hostile, '#', 'n1', {
|
||||||
|
type: 'webhook', url: `https://x/${hostile}`, secretId: hostile, authMode: 'bearer',
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the marker still parses as ONE marker (nothing escaped out of a value)', () => {
|
||||||
|
expect(w.marker).toMatch(PUBLISH_MARKER_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['url', `https://x/${hostile}`],
|
||||||
|
['secret', hostile],
|
||||||
|
['recipient', `${hostile}@x.com`],
|
||||||
|
['thankyou', hostile],
|
||||||
|
])('%s carries no raw ", <, > or -->', (name) => {
|
||||||
|
const value = w.marker.match(new RegExp(` ${name}="([^"]*)"`))![1];
|
||||||
|
expect(value).not.toMatch(/["<>]/);
|
||||||
|
expect(value).not.toContain('-->');
|
||||||
|
expect(value).toContain('"');
|
||||||
|
expect(value).toContain('<');
|
||||||
|
expect(value).toContain('>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the raw hostile string never appears anywhere in the marker', () => {
|
||||||
|
expect(w.marker).not.toContain(hostile);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('relayFormWiring allowlists type and authmode instead of trusting them', () => {
|
||||||
|
test('type is matched case-insensitively -- "Webhook" is a webhook, not a silent email', () => {
|
||||||
|
const w = relayFormWiring('a@b.com', '', '#', 'n1', { type: 'WebHook', url: 'https://x/y' });
|
||||||
|
expect(w.marker).toContain('type="webhook"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown type falls back to the legacy email marker', () => {
|
||||||
|
const w = relayFormWiring('a@b.com', '', '#', 'n1', { type: 'slack', url: 'https://x/y' } as any);
|
||||||
|
expect(w.marker).not.toContain('type=');
|
||||||
|
expect(w.marker).toBe(relayFormWiring('a@b.com', '', '#', 'n1').marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hostile authMode collapses to signature and cannot break out of the attribute', () => {
|
||||||
|
const w = relayFormWiring('a@b.com', '', '#', 'n1', {
|
||||||
|
type: 'webhook', url: 'https://x/y', authMode: 'bearer" onx="1',
|
||||||
|
});
|
||||||
|
expect(w.marker).toContain('authmode="signature"');
|
||||||
|
expect(w.marker).toMatch(PUBLISH_MARKER_RE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"Bearer" is promoted to the exact literal the relay compares against', () => {
|
||||||
|
const w = relayFormWiring('a@b.com', '', '#', 'n1', { type: 'webhook', url: 'https://x/y', authMode: ' Bearer ' });
|
||||||
|
expect(w.marker).toContain('authmode="bearer"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,58 @@
|
|||||||
|
|
||||||
import { escapeAttr, safeUrl, scopeId } from './escape';
|
import { escapeAttr, safeUrl, scopeId } from './escape';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a form's submissions go. Optional on every call site: a form that
|
||||||
|
* passes nothing here (or `type: 'email'`) emits the LEGACY marker, byte for
|
||||||
|
* byte -- see `relayFormWiring` below.
|
||||||
|
*/
|
||||||
|
export interface FormDestination {
|
||||||
|
/** 'webhook' (case-insensitive) selects the webhook path; anything else = email. */
|
||||||
|
type?: string;
|
||||||
|
/** Absolute https URL the relay POSTs to. Validated at publish time. */
|
||||||
|
url?: string;
|
||||||
|
/** Opaque id minted by /api/form-webhook-secret.php. NEVER the raw secret. */
|
||||||
|
secretId?: string;
|
||||||
|
/** 'bearer' or 'signature' (HMAC, the default). */
|
||||||
|
authMode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two destination types this emitter knows how to describe. */
|
||||||
|
const DESTINATION_TYPES = ['email', 'webhook'] as const;
|
||||||
|
/** The two auth modes the relay implements (FormRelayProvisioner::upsertWebhookToken). */
|
||||||
|
const AUTH_MODES = ['signature', 'bearer'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowlist a destination type / auth mode rather than escaping it.
|
||||||
|
*
|
||||||
|
* Same reasoning as `sanitizeInputType`/`sanitizeFormMethod` in ./escape: these
|
||||||
|
* props are declared as unions in TS but arrive raw from a deserialized saved
|
||||||
|
* state or the AI `update_props` path, and the only legitimate values are a
|
||||||
|
* fixed pair. Narrowing here also stops a case-drifted `"Bearer"` from being
|
||||||
|
* silently downgraded to `signature` by the publish step (which compares
|
||||||
|
* `=== 'bearer'` exactly) -- the customer would see unsigned deliveries with
|
||||||
|
* nothing in the UI to explain it.
|
||||||
|
*/
|
||||||
|
function allowlist<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||||
|
const v = (typeof value === 'string' ? value : '').trim().toLowerCase();
|
||||||
|
return (allowed as readonly string[]).includes(v) ? (v as T) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit one ` name="value"` marker attribute.
|
||||||
|
*
|
||||||
|
* THE SINGLE ESCAPING SITE for every optional marker attribute. The publish-time
|
||||||
|
* strip (whp: web-files/libs/FormRelayRewrite.php) has been hardened six times
|
||||||
|
* over exactly this: an unescaped `<`, `>` or a literal `-->` inside a marker
|
||||||
|
* attribute value truncates the strip mid-marker and leaks the customer's
|
||||||
|
* recipient address into their public page source, or trips the post-condition
|
||||||
|
* and refuses the publish outright. `escapeAttr` removing `"` is also what keeps
|
||||||
|
* each value inside the `[^"]*` the publish-side parser expects.
|
||||||
|
*/
|
||||||
|
function markerAttr(name: string, value: string): string {
|
||||||
|
return ` ${name}="${escapeAttr(value)}"`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RelayWiring {
|
export interface RelayWiring {
|
||||||
/** true when a recipient is set (relay path); false = legacy formAction fallback */
|
/** true when a recipient is set (relay path); false = legacy formAction fallback */
|
||||||
useRelay: boolean;
|
useRelay: boolean;
|
||||||
@@ -30,20 +82,63 @@ export interface RelayWiring {
|
|||||||
* the marker/placeholder id deterministically and uniquely --
|
* the marker/placeholder id deterministically and uniquely --
|
||||||
* see `scopeId` in ./escape. Falls back to a stable hash of the
|
* see `scopeId` in ./escape. Falls back to a stable hash of the
|
||||||
* recipient/thankYouUrl/fallbackAction when omitted (never random).
|
* recipient/thankYouUrl/fallbackAction when omitted (never random).
|
||||||
|
* @param destination optional destination descriptor. Omitted, or `type: 'email'`,
|
||||||
|
* yields the LEGACY narrow marker byte for byte -- every
|
||||||
|
* already-published site depends on that shape continuing to
|
||||||
|
* provision an email endpoint.
|
||||||
|
*
|
||||||
|
* The wide (webhook) marker keeps the optional attributes BETWEEN `id` and
|
||||||
|
* `recipient`, which is where the publish-side parser looks for them:
|
||||||
|
*
|
||||||
|
* /<!--WHP-FORM id="([^"]+)"((?: [a-z]+="[^"]*")*) recipient="([^"]*)" thankyou="([^"]*)"-->/
|
||||||
|
*
|
||||||
|
* (FormRelayRewrite.php). Attribute NAMES must therefore be lowercase, and every
|
||||||
|
* VALUE must be free of `"` -- both guaranteed here, the latter by `markerAttr`.
|
||||||
|
*
|
||||||
|
* A webhook marker is emitted whenever the customer selected webhook, even with a
|
||||||
|
* blank URL: the publish step then refuses that one endpoint and logs it (the form
|
||||||
|
* publishes inert). Falling back to the email path instead would deliver mail to a
|
||||||
|
* customer who configured a webhook, with nothing anywhere to explain it -- the
|
||||||
|
* exact silent degradation the publish-side `type` normalisation exists to stop.
|
||||||
*/
|
*/
|
||||||
export function relayFormWiring(
|
export function relayFormWiring(
|
||||||
recipientEmail: string | undefined,
|
recipientEmail: string | undefined,
|
||||||
thankYouUrl: string | undefined,
|
thankYouUrl: string | undefined,
|
||||||
fallbackAction: string | undefined,
|
fallbackAction: string | undefined,
|
||||||
nodeId?: string,
|
nodeId?: string,
|
||||||
|
destination?: FormDestination,
|
||||||
): RelayWiring {
|
): RelayWiring {
|
||||||
if (!recipientEmail) {
|
const destType = allowlist(destination?.type, DESTINATION_TYPES, 'email');
|
||||||
|
const isWebhook = destType === 'webhook';
|
||||||
|
|
||||||
|
// No destination at all: nothing to deliver to, so no relay (unchanged).
|
||||||
|
if (!recipientEmail && !isWebhook) {
|
||||||
return { useRelay: false, marker: '', actionAttr: escapeAttr(safeUrl(fallbackAction || '#')), honeypot: '' };
|
return { useRelay: false, marker: '', actionAttr: escapeAttr(safeUrl(fallbackAction || '#')), honeypot: '' };
|
||||||
}
|
}
|
||||||
const fid = scopeId(nodeId, `${recipientEmail}::${thankYouUrl || ''}::${fallbackAction || ''}`, 'F');
|
|
||||||
|
// Webhook config participates in the fallback seed so two webhook forms with
|
||||||
|
// no node id and no recipient don't collide on one fid. Appended only in the
|
||||||
|
// webhook branch, so the legacy seed -- and therefore every legacy fid -- is
|
||||||
|
// unchanged.
|
||||||
|
const seed = `${recipientEmail || ''}::${thankYouUrl || ''}::${fallbackAction || ''}`
|
||||||
|
+ (isWebhook ? `::webhook::${destination?.url || ''}::${destination?.secretId || ''}` : '');
|
||||||
|
const fid = scopeId(nodeId, seed, 'F');
|
||||||
|
|
||||||
|
// The `url` value is NOT routed through `safeUrl`: it is never a live sink
|
||||||
|
// (it lands in an HTML comment that the publish step strips), and blanking it
|
||||||
|
// here would silently turn a mistyped destination into an inert form with no
|
||||||
|
// log line. The publish step validates it properly -- absolute https, no
|
||||||
|
// control characters -- and refuses loudly when it doesn't hold.
|
||||||
|
const extraAttrs = isWebhook
|
||||||
|
? markerAttr('type', 'webhook')
|
||||||
|
+ markerAttr('url', destination?.url || '')
|
||||||
|
+ markerAttr('secret', destination?.secretId || '')
|
||||||
|
+ markerAttr('authmode', allowlist(destination?.authMode, AUTH_MODES, 'signature'))
|
||||||
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
useRelay: true,
|
useRelay: true,
|
||||||
marker: `<!--WHP-FORM id="${fid}" recipient="${escapeAttr(recipientEmail)}" thankyou="${escapeAttr(thankYouUrl || '')}"-->`,
|
marker: `<!--WHP-FORM id="${fid}"${extraAttrs} recipient="${escapeAttr(recipientEmail || '')}" thankyou="${escapeAttr(thankYouUrl || '')}"-->`,
|
||||||
actionAttr: `__WHP_FORM_ACTION__${fid}__`,
|
actionAttr: `__WHP_FORM_ACTION__${fid}__`,
|
||||||
honeypot: `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`,
|
honeypot: `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { storeWebhookSecret, webhookSecretEndpoint } from './form-webhook-secret';
|
||||||
|
|
||||||
|
const CFG = { apiUrl: '/api/site-builder.php', csrfToken: 'tok-123', siteId: 42 };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(window as any).WHP_CONFIG = { ...CFG };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (window as any).WHP_CONFIG;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('webhookSecretEndpoint', () => {
|
||||||
|
test('derives the sibling endpoint from the configured API url', () => {
|
||||||
|
expect(webhookSecretEndpoint('/api/site-builder.php')).toBe('/api/form-webhook-secret.php');
|
||||||
|
expect(webhookSecretEndpoint('https://panel.example.com/api/site-builder'))
|
||||||
|
.toBe('https://panel.example.com/api/form-webhook-secret.php');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the absolute path when there is no configured url', () => {
|
||||||
|
expect(webhookSecretEndpoint(undefined)).toBe('/api/form-webhook-secret.php');
|
||||||
|
expect(webhookSecretEndpoint('')).toBe('/api/form-webhook-secret.php');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storeWebhookSecret', () => {
|
||||||
|
test('POSTs the secret with the CSRF header and returns only the id', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: async () => ({ success: true, secret_id: 'whs_42_abcdef0123456789' }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const result = await storeWebhookSecret('SUPERSECRET');
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, secretId: 'whs_42_abcdef0123456789' });
|
||||||
|
const [url, opts] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/form-webhook-secret.php');
|
||||||
|
// POST only -- the endpoint has no read route by design.
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
expect(opts.headers['X-CSRF-Token']).toBe('tok-123');
|
||||||
|
expect(JSON.parse(opts.body)).toEqual({ site_id: 42, secret: 'SUPERSECRET' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes the 429 cap message through so a customer can act on it', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: false, status: 429,
|
||||||
|
json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
|
||||||
|
}));
|
||||||
|
const result = await storeWebhookSecret('s');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toContain('Too many webhook secrets stored for this site');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a success:false body is a failure even with HTTP 200', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true, status: 200, json: async () => ({ success: false, error: 'Invalid CSRF token' }),
|
||||||
|
}));
|
||||||
|
expect(await storeWebhookSecret('s')).toEqual({ ok: false, error: 'Invalid CSRF token' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a network failure resolves with an error rather than throwing', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
|
||||||
|
const result = await storeWebhookSecret('s');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.secretId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standalone mode (no WHP_CONFIG) never posts anywhere', async () => {
|
||||||
|
delete (window as any).WHP_CONFIG;
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
const result = await storeWebhookSecret('s');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/* ---------- Contact-form webhook secret: WRITE-ONLY client ----------
|
||||||
|
Posts a raw shared secret to the panel endpoint and gets back an opaque id.
|
||||||
|
|
||||||
|
The raw secret is never stored anywhere on the client: it is passed to
|
||||||
|
`storeWebhookSecret` from local component state, and only the returned
|
||||||
|
`secret_id` is ever written to a craft prop. Craft props are serialised into
|
||||||
|
the saved project and into published output, which is the wrong tier for a
|
||||||
|
credential -- that is the whole reason this endpoint exists.
|
||||||
|
|
||||||
|
THERE IS NO READ. The panel endpoint (web-files/api/form-webhook-secret.php)
|
||||||
|
is POST-only by design: a "show me my secret" route would re-open the exact
|
||||||
|
problem this closes. Rotation is another POST, which mints a NEW id. So the
|
||||||
|
UI can offer set / replace / remove, and never "view". */
|
||||||
|
|
||||||
|
export interface StoreSecretResult {
|
||||||
|
ok: boolean;
|
||||||
|
/** Present only on success -- `whs_<siteId>_<hex>`. */
|
||||||
|
secretId?: string;
|
||||||
|
/** Customer-facing message on failure (the endpoint's own, when it sent one). */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the secret endpoint from the configured API url, so a deployment that
|
||||||
|
* moves the panel API (or the vite dev proxy) doesn't need a second constant
|
||||||
|
* kept in sync: `/api/site-builder.php` -> `/api/form-webhook-secret.php`.
|
||||||
|
*/
|
||||||
|
export function webhookSecretEndpoint(apiUrl?: string): string {
|
||||||
|
const base = typeof apiUrl === 'string' ? apiUrl.trim() : '';
|
||||||
|
if (base.includes('/')) return base.replace(/[^/]*$/, 'form-webhook-secret.php');
|
||||||
|
return '/api/form-webhook-secret.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a raw webhook secret for the current site; resolves with its opaque id.
|
||||||
|
*
|
||||||
|
* Never throws and never returns the secret. Errors are returned as text fit to
|
||||||
|
* show a customer -- including the endpoint's 429 ("Too many webhook secrets
|
||||||
|
* stored for this site"), which is actionable (contact support) and so is
|
||||||
|
* passed through rather than flattened into a generic failure.
|
||||||
|
*/
|
||||||
|
export async function storeWebhookSecret(secret: string): Promise<StoreSecretResult> {
|
||||||
|
const cfg = (window as any).WHP_CONFIG;
|
||||||
|
if (!cfg) {
|
||||||
|
return { ok: false, error: 'Saving a webhook secret needs the builder to be open inside the control panel.' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(webhookSecretEndpoint(cfg.apiUrl), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': cfg.csrfToken },
|
||||||
|
body: JSON.stringify({ site_id: cfg.siteId, secret }),
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => null);
|
||||||
|
if (resp.ok && data && data.success === true && typeof data.secret_id === 'string' && data.secret_id !== '') {
|
||||||
|
return { ok: true, secretId: data.secret_id };
|
||||||
|
}
|
||||||
|
const message = data && typeof data.error === 'string' && data.error !== ''
|
||||||
|
? data.error
|
||||||
|
: `Could not store the secret (HTTP ${resp.status}).`;
|
||||||
|
return { ok: false, error: message };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'Could not reach the control panel to store the secret.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { formatHtml } from './format-html';
|
||||||
|
|
||||||
|
describe('formatHtml', () => {
|
||||||
|
test('indents nested block elements two spaces per level', () => {
|
||||||
|
expect(formatHtml('<div><section><p>hi</p></section></div>')).toBe(
|
||||||
|
'<div>\n <section>\n <p>hi</p>\n </section>\n</div>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves inline tags on the same line as their text', () => {
|
||||||
|
expect(formatHtml('<p>hello <strong>world</strong> now</p>')).toBe(
|
||||||
|
'<p>hello <strong>world</strong> now</p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('void elements do not open an indent level', () => {
|
||||||
|
expect(formatHtml('<div><img src="a.png"><br><p>x</p></div>')).toBe(
|
||||||
|
'<div>\n <img src="a.png">\n <br>\n <p>x</p>\n</div>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves <pre> contents verbatim', () => {
|
||||||
|
const src = '<div><pre> keep\n this</pre></div>';
|
||||||
|
expect(formatHtml(src)).toBe('<div>\n <pre> keep\n this</pre>\n</div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is idempotent', () => {
|
||||||
|
const once = formatHtml('<div><section><p>hi</p></section></div>');
|
||||||
|
expect(formatHtml(once)).toBe(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty and whitespace-only input round-trip to an empty string', () => {
|
||||||
|
expect(formatHtml('')).toBe('');
|
||||||
|
expect(formatHtml(' \n ')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unbalanced markup never produces negative indent', () => {
|
||||||
|
expect(formatHtml('</div><p>x</p>')).toBe('</div>\n<p>x</p>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression coverage from code review: only <pre> was originally exempted
|
||||||
|
// from the naive '<'/'>' tag-boundary scan, which let a '>' inside a quoted
|
||||||
|
// attribute value corrupt output, and let '<'/'>' inside <script>/<style>
|
||||||
|
// content be misparsed as tag boundaries.
|
||||||
|
describe('formatHtml - raw content and quoted attributes', () => {
|
||||||
|
test('a ">" inside a quoted attribute value does not split the tag', () => {
|
||||||
|
const src = '<div title="a>b"><p>hi</p></div><footer>bye</footer>';
|
||||||
|
expect(formatHtml(src)).toBe(
|
||||||
|
'<div title="a>b">\n <p>hi</p>\n</div>\n<footer>bye</footer>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"<" and ">" inside <script> content do not desync sibling nesting', () => {
|
||||||
|
const src = '<div><script>a < b;</script></div><div><script>c < d;</script></div>';
|
||||||
|
expect(formatHtml(src)).toBe(
|
||||||
|
'<div>\n <script>a < b;</script>\n</div>\n<div>\n <script>c < d;</script>\n</div>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a CSS child combinator inside <style> content is not treated as markup', () => {
|
||||||
|
const src = '<div><style>div > p { color: red; }</style></div>';
|
||||||
|
expect(formatHtml(src)).toBe(
|
||||||
|
'<div>\n <style>div > p { color: red; }</style>\n</div>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is idempotent across a quoted ">" attribute and <script> content', () => {
|
||||||
|
const quotedAttr = formatHtml('<div title="a>b"><p>hi</p></div><footer>bye</footer>');
|
||||||
|
expect(formatHtml(quotedAttr)).toBe(quotedAttr);
|
||||||
|
|
||||||
|
const scriptSrc = '<div><script>a < b;</script></div><div><script>c < d;</script></div>';
|
||||||
|
const scripted = formatHtml(scriptSrc);
|
||||||
|
expect(formatHtml(scripted)).toBe(scripted);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unclosed <pre> is swallowed verbatim to the end of the document', () => {
|
||||||
|
expect(formatHtml('<div><pre>no closing tag here')).toBe(
|
||||||
|
'<div>\n <pre>no closing tag here',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression coverage from round 2 of code review: a close tag whose
|
||||||
|
// innermost open frame doesn't match it must not wedge the stack for the
|
||||||
|
// rest of the document. An optional end tag skipped by the author (e.g.
|
||||||
|
// an unclosed <p>) must self-drain against its real ancestor close tag,
|
||||||
|
// while a close tag with no opener anywhere on the stack still has nothing
|
||||||
|
// to pair with and is left exactly where it is.
|
||||||
|
describe('formatHtml - mismatched close recovery', () => {
|
||||||
|
test('an unclosed <p> before </div> drains against the ancestor close instead of wedging the stack', () => {
|
||||||
|
const src = '<div><p>one<p>two</p></div><p>three</p>';
|
||||||
|
expect(formatHtml(src)).toBe(
|
||||||
|
'<div>\n <p>\n one\n <p>two</p>\n</div>\n<p>three</p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a close tag with no opener anywhere on the stack is left in place', () => {
|
||||||
|
const src = '<div><p>x</p></footer></div>';
|
||||||
|
expect(formatHtml(src)).toBe('<div>\n <p>x</p>\n </footer>\n</div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is idempotent across an unclosed <p> and a stray close with no opener', () => {
|
||||||
|
const unclosedP = formatHtml('<div><p>one<p>two</p></div><p>three</p>');
|
||||||
|
expect(formatHtml(unclosedP)).toBe(unclosedP);
|
||||||
|
|
||||||
|
const strayClose = formatHtml('<div><p>x</p></footer></div>');
|
||||||
|
expect(formatHtml(strayClose)).toBe(strayClose);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression coverage from round 3 of code review: the previous fix for the
|
||||||
|
// phantom-space idempotency bug used "does this boundary whitespace contain
|
||||||
|
// a newline" as its drop/keep rule, which also stripped hand-wrapped text
|
||||||
|
// like "hello\n<strong>" -- a single-pass content change, not a
|
||||||
|
// repeated-pass artifact. The correct discriminator is what the whitespace
|
||||||
|
// borders: whitespace between two inline-level things (text, <strong>, ...)
|
||||||
|
// is always significant and must survive regardless of newlines; whitespace
|
||||||
|
// touching a block-tag boundary carries no rendered meaning and is always
|
||||||
|
// dropped, regardless of newlines.
|
||||||
|
describe('formatHtml - inline whitespace vs block-boundary whitespace', () => {
|
||||||
|
test('a newline between text and an inline tag is kept as a single space', () => {
|
||||||
|
expect(formatHtml('<p>hello\n<strong>world</strong></p>')).toBe(
|
||||||
|
'<p>hello <strong>world</strong></p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a literal space in the same position is kept unchanged (pin)', () => {
|
||||||
|
expect(formatHtml('<p>hello <strong>world</strong></p>')).toBe(
|
||||||
|
'<p>hello <strong>world</strong></p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a newline at a block-tag boundary is still dropped', () => {
|
||||||
|
expect(formatHtml('<div>\n</div>')).toBe('<div></div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is idempotent across inline-boundary whitespace, block-boundary whitespace, and the case that originally exposed the phantom space', () => {
|
||||||
|
const wrapped = formatHtml('<p>hello\n<strong>world</strong></p>');
|
||||||
|
expect(formatHtml(wrapped)).toBe(wrapped);
|
||||||
|
|
||||||
|
const sameLine = formatHtml('<p>hello <strong>world</strong></p>');
|
||||||
|
expect(formatHtml(sameLine)).toBe(sameLine);
|
||||||
|
|
||||||
|
const blockGap = formatHtml('<div>\n</div>');
|
||||||
|
expect(formatHtml(blockGap)).toBe(blockGap);
|
||||||
|
|
||||||
|
const phantomSpaceCase = formatHtml('<div><p>one<p>two</p></div><p>three</p>');
|
||||||
|
expect(formatHtml(phantomSpaceCase)).toBe(phantomSpaceCase);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* Indent-only HTML prettifier for the Edit HTML modal's Format button.
|
||||||
|
*
|
||||||
|
* Deliberately small and dependency-free: it re-indents BLOCK-level tags onto
|
||||||
|
* their own lines and nests them two spaces per level. It does not reflow
|
||||||
|
* text, reorder attributes, or normalise quoting -- a formatter that rewrites
|
||||||
|
* user markup is a formatter people stop trusting.
|
||||||
|
*
|
||||||
|
* Inline tags (<strong>, <a>, <span>, ...) are left exactly where they sit,
|
||||||
|
* and <pre>/<script>/<style> contents are copied through verbatim -- their
|
||||||
|
* content is scanned only for the literal closing tag, never treated as
|
||||||
|
* markup, so `<` / `>` inside JS comparisons or CSS combinators can't be
|
||||||
|
* mistaken for tag boundaries.
|
||||||
|
*
|
||||||
|
* The tag-boundary scan itself is quote-aware: a `>` inside a single- or
|
||||||
|
* double-quoted attribute value (e.g. `title="a>b"`) does not end the tag.
|
||||||
|
*
|
||||||
|
* Design note: a block-open tag is not committed to its own output line the
|
||||||
|
* moment it is seen. It stays "pending" on a stack frame; if only inline
|
||||||
|
* content follows before its matching close (e.g. `<p>hi</p>`), the open
|
||||||
|
* tag, the inline content, and the close tag are merged onto a single line.
|
||||||
|
* The pending open is only forced onto its own line ("committed") once a
|
||||||
|
* nested block-level token (open/void/verbatim) proves the element spans
|
||||||
|
* more than one line.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BLOCK_TAGS = new Set([
|
||||||
|
'html', 'head', 'body', 'div', 'section', 'article', 'aside', 'header', 'footer',
|
||||||
|
'main', 'nav', 'form', 'fieldset', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
|
||||||
|
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'blockquote', 'figure', 'figcaption', 'pre', 'hr', 'br', 'img', 'iframe', 'video',
|
||||||
|
'audio', 'source', 'canvas', 'script', 'style', 'select', 'option', 'textarea',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const VOID_TAGS = new Set([
|
||||||
|
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
||||||
|
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Elements whose content is never markup -- read verbatim to the literal closing tag. */
|
||||||
|
const RAW_TEXT_TAGS = new Set(['pre', 'script', 'style']);
|
||||||
|
|
||||||
|
const INDENT = ' ';
|
||||||
|
|
||||||
|
/** True for a real, known block-level tag name (see BLOCK_TAGS); '' (no tag) is not block. */
|
||||||
|
function isBlockTag(tag: string): boolean {
|
||||||
|
return tag !== '' && BLOCK_TAGS.has(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
/** Raw text of the token. Text runs have interior whitespace collapsed. */
|
||||||
|
text: string;
|
||||||
|
/** Lowercased tag name, or '' for a text run. */
|
||||||
|
tag: string;
|
||||||
|
kind: 'open' | 'close' | 'void' | 'text' | 'verbatim';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a raw run of text (between two tags) into a text token, collapsing
|
||||||
|
* interior whitespace to single spaces. A run that is nothing but
|
||||||
|
* whitespace is dropped entirely UNLESS it sits between two inline-level
|
||||||
|
* things, in which case it's a real (if content-free) word gap -- e.g.
|
||||||
|
* `<span>a</span> <span>b</span>` -- and collapses to one significant space.
|
||||||
|
*
|
||||||
|
* For a run with real content, its leading/trailing whitespace is boiled
|
||||||
|
* down to at most one space each, kept only on sides that border something
|
||||||
|
* inline. The discriminator is deliberately NOT "does this whitespace
|
||||||
|
* contain a newline" -- that would also strip a hand-wrapped
|
||||||
|
* `"hello\n<strong>"`, turning it into "helloworld" and rewriting the
|
||||||
|
* user's markup. It's "what does this boundary sit next to": whitespace
|
||||||
|
* between two inline-level things (text, `<strong>`, `<a>`, ...) is always
|
||||||
|
* significant in HTML and must survive regardless of newlines; whitespace
|
||||||
|
* touching a block-tag boundary carries no rendered meaning and is always
|
||||||
|
* dropped, regardless of newlines. Because this formatter's own emitted
|
||||||
|
* indentation always sits at a block boundary (an element's own line is
|
||||||
|
* only ever created next to another block tag), that side of the rule is
|
||||||
|
* also what keeps repeated formatting from accumulating phantom spaces.
|
||||||
|
*
|
||||||
|
* `prevIsBlock`/`nextIsBlock` describe whatever sits immediately before/
|
||||||
|
* after this run: true for a block tag or "nothing there" (start/end of
|
||||||
|
* document, or an unterminated tag), false for an inline tag.
|
||||||
|
*/
|
||||||
|
function pushTextToken(tokens: Token[], raw: string, prevIsBlock: boolean, nextIsBlock: boolean): void {
|
||||||
|
const core = raw.trim();
|
||||||
|
|
||||||
|
if (!core) {
|
||||||
|
if (!prevIsBlock && !nextIsBlock) {
|
||||||
|
tokens.push({ text: ' ', tag: '', kind: 'text' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadSpace = /^\s/.test(raw) && !prevIsBlock ? ' ' : '';
|
||||||
|
const trailSpace = /\s$/.test(raw) && !nextIsBlock ? ' ' : '';
|
||||||
|
const text = leadSpace + core.replace(/\s+/g, ' ') + trailSpace;
|
||||||
|
|
||||||
|
tokens.push({ text, tag: '', kind: 'text' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the '>' that closes the tag opened at `lt` (the index of its '<'),
|
||||||
|
* without being fooled by a '>' inside a single- or double-quoted
|
||||||
|
* attribute value. Returns -1 if the tag is never closed.
|
||||||
|
*/
|
||||||
|
function findTagEnd(src: string, lt: number): number {
|
||||||
|
let i = lt + 1;
|
||||||
|
let quote: string | null = null;
|
||||||
|
|
||||||
|
while (i < src.length) {
|
||||||
|
const ch = src[i];
|
||||||
|
if (quote) {
|
||||||
|
if (ch === quote) quote = null;
|
||||||
|
} else if (ch === '"' || ch === "'") {
|
||||||
|
quote = ch;
|
||||||
|
} else if (ch === '>') {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split source into tags and text runs, treating raw-text element bodies as one atom. */
|
||||||
|
function tokenize(src: string): Token[] {
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
// Whatever tag most recently landed in `tokens` (undefined at the very
|
||||||
|
// start of the document, which counts as a block-like boundary).
|
||||||
|
const prevIsBlock = (): boolean =>
|
||||||
|
tokens.length === 0 || isBlockTag(tokens[tokens.length - 1].tag);
|
||||||
|
|
||||||
|
while (i < src.length) {
|
||||||
|
const lt = src.indexOf('<', i);
|
||||||
|
|
||||||
|
if (lt === -1) {
|
||||||
|
// Nothing more to tokenize after this -- end of document is a
|
||||||
|
// block-like boundary too.
|
||||||
|
pushTextToken(tokens, src.slice(i), prevIsBlock(), true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peek the upcoming tag's name once, up front: it decides both the
|
||||||
|
// trailing-space behaviour of the text run before it (if any) and,
|
||||||
|
// below, how this tag itself is tokenized -- no need to re-scan it.
|
||||||
|
const gt = findTagEnd(src, lt);
|
||||||
|
const raw = gt === -1 ? '' : src.slice(lt, gt + 1);
|
||||||
|
const nameMatch = raw ? /^<\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(raw) : null;
|
||||||
|
const tag = nameMatch ? nameMatch[1].toLowerCase() : '';
|
||||||
|
// An unterminated tag never really opens/closes anything, so treat it
|
||||||
|
// like the end of the document for the preceding text run's purposes.
|
||||||
|
const nextIsBlock = gt === -1 || isBlockTag(tag);
|
||||||
|
|
||||||
|
if (lt > i) {
|
||||||
|
pushTextToken(tokens, src.slice(i, lt), prevIsBlock(), nextIsBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gt === -1) {
|
||||||
|
// Unterminated '<' -- emit the remainder as text rather than looping.
|
||||||
|
pushTextToken(tokens, src.slice(lt), prevIsBlock(), true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// <pre>/<script>/<style> swallow everything up to their closing tag,
|
||||||
|
// untouched -- their content is never scanned as markup. If no closing
|
||||||
|
// tag exists, swallow to the end of the document rather than risk
|
||||||
|
// misparsing raw JS/CSS as tags.
|
||||||
|
if (RAW_TEXT_TAGS.has(tag) && !raw.startsWith('</')) {
|
||||||
|
const closeRe = new RegExp(`</${tag}\\s*>`, 'i');
|
||||||
|
const rest = src.slice(gt + 1);
|
||||||
|
const match = closeRe.exec(rest);
|
||||||
|
const end = match ? gt + 1 + match.index + match[0].length : src.length;
|
||||||
|
tokens.push({ text: src.slice(lt, end), tag, kind: 'verbatim' });
|
||||||
|
i = end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isClose = raw.startsWith('</');
|
||||||
|
const selfClosing = /\/>\s*$/.test(raw);
|
||||||
|
const kind: Token['kind'] = isClose
|
||||||
|
? 'close'
|
||||||
|
: selfClosing || VOID_TAGS.has(tag)
|
||||||
|
? 'void'
|
||||||
|
: 'open';
|
||||||
|
|
||||||
|
tokens.push({ text: raw, tag, kind });
|
||||||
|
i = gt + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pending block-level element: may still merge onto a single line. */
|
||||||
|
interface Frame {
|
||||||
|
/** Raw text of the open tag. */
|
||||||
|
text: string;
|
||||||
|
/** Lowercased tag name, used to pair this frame with its real close tag. */
|
||||||
|
tag: string;
|
||||||
|
/** Indent depth at which this element's tags render. */
|
||||||
|
depth: number;
|
||||||
|
/** Inline content accumulated directly under this element since it opened
|
||||||
|
* (or since it was last committed). */
|
||||||
|
inline: string;
|
||||||
|
/** Whether the open tag has already been written to its own line. */
|
||||||
|
committed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit whatever a still-open frame needs, without ever fabricating a
|
||||||
|
* closing tag for it: its own open-tag line if it was never committed, and
|
||||||
|
* any inline content it was holding. Used both for elements that are
|
||||||
|
* genuinely never closed anywhere in the document, and for elements
|
||||||
|
* implicitly closed by an ancestor's close tag (e.g. an optional end tag
|
||||||
|
* like `<p>` that the author skipped) -- either way, no synthetic close tag
|
||||||
|
* is written; only indentation for content that really was opened.
|
||||||
|
*/
|
||||||
|
function implicitlyClose(lines: string[], frame: Frame): void {
|
||||||
|
if (!frame.committed) {
|
||||||
|
lines.push(INDENT.repeat(frame.depth) + frame.text);
|
||||||
|
}
|
||||||
|
if (frame.inline) {
|
||||||
|
lines.push(INDENT.repeat(frame.depth + 1) + frame.inline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHtml(src: string): string {
|
||||||
|
if (!src || !src.trim()) return '';
|
||||||
|
|
||||||
|
const tokens = tokenize(src);
|
||||||
|
const lines: string[] = [];
|
||||||
|
const stack: Frame[] = [];
|
||||||
|
/** Inline/text content seen while no block frame is open. */
|
||||||
|
let rootInline = '';
|
||||||
|
|
||||||
|
const flushRootInline = (): void => {
|
||||||
|
if (!rootInline) return;
|
||||||
|
lines.push(rootInline);
|
||||||
|
rootInline = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Force the innermost pending frame onto its own line, if not already. */
|
||||||
|
const commitTop = (): void => {
|
||||||
|
if (stack.length === 0) return;
|
||||||
|
const top = stack[stack.length - 1];
|
||||||
|
if (top.committed) return;
|
||||||
|
lines.push(INDENT.repeat(top.depth) + top.text);
|
||||||
|
if (top.inline) {
|
||||||
|
lines.push(INDENT.repeat(top.depth + 1) + top.inline);
|
||||||
|
top.inline = '';
|
||||||
|
}
|
||||||
|
top.committed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
const isBlock = isBlockTag(token.tag);
|
||||||
|
|
||||||
|
if (!isBlock) {
|
||||||
|
// Inline tag or text -- accumulate against the innermost open element.
|
||||||
|
if (stack.length) {
|
||||||
|
stack[stack.length - 1].inline += token.text;
|
||||||
|
} else {
|
||||||
|
rootInline += token.text;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.kind === 'close') {
|
||||||
|
// Find the nearest still-open frame with this tag name, anywhere on
|
||||||
|
// the stack -- not just the top. HTML permits skipping optional end
|
||||||
|
// tags (e.g. `<p>`), so the element a close tag pairs with is not
|
||||||
|
// always the innermost open element.
|
||||||
|
let matchIndex = -1;
|
||||||
|
for (let k = stack.length - 1; k >= 0; k -= 1) {
|
||||||
|
if (stack[k].tag === token.tag) {
|
||||||
|
matchIndex = k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchIndex === -1) {
|
||||||
|
// Genuinely stray: no frame anywhere was opened with this tag
|
||||||
|
// name, so there is nothing to pair it with. Don't fabricate a
|
||||||
|
// pairing -- emit it in place and leave the stack untouched.
|
||||||
|
if (stack.length === 0) flushRootInline();
|
||||||
|
lines.push(INDENT.repeat(stack.length) + token.text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything above the match was opened but never explicitly closed
|
||||||
|
// in the source (e.g. a skipped `</p>`). Draining them here -- with
|
||||||
|
// no synthetic close tag -- keeps the stack from staying wedged for
|
||||||
|
// the rest of the document, restoring the self-draining property
|
||||||
|
// without fabricating markup.
|
||||||
|
while (stack.length - 1 > matchIndex) {
|
||||||
|
implicitlyClose(lines, stack.pop()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = stack.pop()!;
|
||||||
|
if (!top.committed) {
|
||||||
|
// Nothing block-level ever interrupted this element: merge the
|
||||||
|
// open tag, its inline content, and the close tag onto one line.
|
||||||
|
lines.push(INDENT.repeat(top.depth) + top.text + top.inline + token.text);
|
||||||
|
} else {
|
||||||
|
if (top.inline) {
|
||||||
|
lines.push(INDENT.repeat(top.depth + 1) + top.inline);
|
||||||
|
}
|
||||||
|
lines.push(INDENT.repeat(top.depth) + token.text);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// token.kind is 'open' | 'void' | 'verbatim': a block-level element is
|
||||||
|
// about to render at the current depth, so any still-pending parent
|
||||||
|
// frame can no longer merge onto a single line.
|
||||||
|
commitTop();
|
||||||
|
if (stack.length === 0) flushRootInline();
|
||||||
|
const depth = stack.length;
|
||||||
|
|
||||||
|
if (token.kind === 'open') {
|
||||||
|
stack.push({ text: token.text, tag: token.tag, depth, inline: '', committed: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// void or verbatim: renders on its own line, opens no new frame.
|
||||||
|
lines.push(INDENT.repeat(depth) + token.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbalanced opens: nothing ever closed them. Flush what's left rather
|
||||||
|
// than silently dropping content.
|
||||||
|
while (stack.length) {
|
||||||
|
implicitlyClose(lines, stack.pop()!);
|
||||||
|
}
|
||||||
|
|
||||||
|
flushRootInline();
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, test, expect } from 'vitest';
|
import { describe, test, expect } from 'vitest';
|
||||||
import { exportBodyHtml, exportToHtml, ExportOptions } from './html-export';
|
import { exportBodyHtml, exportToHtml, buildAnimationScript, ExportOptions } from './html-export';
|
||||||
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
|
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -335,3 +335,211 @@ describe('PKG-H: SEO/meta + favicon + design-token <head> emission', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)}}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -52,12 +52,29 @@ function buildDataAttrs(props: Record<string, any>): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Inject data attributes into the first HTML opening tag of a rendered string.
|
* Inject data attributes into the first HTML opening tag of a rendered string.
|
||||||
|
*
|
||||||
|
* For a void/self-closing tag (e.g. `<img src="x" />`) the first `>` is
|
||||||
|
* preceded by a `/` -- naively inserting before the `>` produces the
|
||||||
|
* malformed `<img ... / data-animation="...">` (attrs land AFTER the
|
||||||
|
* self-close slash, outside the tag). Detect that trailing `/` and insert
|
||||||
|
* the attrs before it instead, yielding well-formed `<img ... data-animation="..."/>`.
|
||||||
|
* Non-void tags (no trailing `/`) are unaffected.
|
||||||
*/
|
*/
|
||||||
function injectAttrs(html: string, attrs: string): string {
|
function injectAttrs(html: string, attrs: string): string {
|
||||||
if (!attrs) return html;
|
if (!attrs) return html;
|
||||||
// Find the first > of the opening tag and inject before it
|
// Find the first > of the opening tag and inject before it
|
||||||
const idx = html.indexOf('>');
|
const idx = html.indexOf('>');
|
||||||
if (idx === -1) return html;
|
if (idx === -1) return html;
|
||||||
|
if (idx > 0 && html[idx - 1] === '/') {
|
||||||
|
// Void/self-closing tag (`<img ... />`): inserting before `>` would land
|
||||||
|
// the attrs after the `/`, outside the tag (`<img ... / data-x="y">`).
|
||||||
|
// Insert before the `/` instead -- also trim any whitespace directly
|
||||||
|
// preceding it so we don't end up with a double space, since `attrs`
|
||||||
|
// already carries its own leading space(s).
|
||||||
|
let contentEnd = idx - 1;
|
||||||
|
while (contentEnd > 0 && /\s/.test(html[contentEnd - 1])) contentEnd--;
|
||||||
|
return html.slice(0, contentEnd) + attrs + html.slice(idx - 1);
|
||||||
|
}
|
||||||
return html.slice(0, idx) + attrs + html.slice(idx);
|
return html.slice(0, idx) + attrs + html.slice(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +338,7 @@ const ANIMATION_CSS = `
|
|||||||
@keyframes slideLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } }
|
@keyframes slideLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } }
|
||||||
@keyframes slideRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
|
@keyframes slideRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
|
||||||
@keyframes zoomIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
|
@keyframes zoomIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
|
||||||
@keyframes bounce { 0% { opacity: 0; transform: translateY(30px); } 60% { opacity: 1; transform: translateY(-5px); } 100% { transform: translateY(0); } }
|
@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); } }
|
||||||
|
|
||||||
[data-animation] { opacity: 0; }
|
[data-animation] { opacity: 0; }
|
||||||
[data-animation].animated { animation-duration: 0.6s; animation-fill-mode: both; }
|
[data-animation].animated { animation-duration: 0.6s; animation-fill-mode: both; }
|
||||||
@@ -332,18 +349,34 @@ const ANIMATION_CSS = `
|
|||||||
[data-animation="zoom-in"].animated { animation-name: zoomIn; }
|
[data-animation="zoom-in"].animated { animation-name: zoomIn; }
|
||||||
[data-animation="bounce"].animated { animation-name: bounce; }`;
|
[data-animation="bounce"].animated { animation-name: bounce; }`;
|
||||||
|
|
||||||
const ANIMATION_CSS_MINIFIED = `@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(30px)}60%{opacity:1;transform:translateY(-5px)}100%{transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation="fade-in"].animated{animation-name:fadeIn}[data-animation="slide-up"].animated{animation-name:slideUp}[data-animation="slide-left"].animated{animation-name:slideLeft}[data-animation="slide-right"].animated{animation-name:slideRight}[data-animation="zoom-in"].animated{animation-name:zoomIn}[data-animation="bounce"].animated{animation-name:bounce}`;
|
const ANIMATION_CSS_MINIFIED = `@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@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)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation="fade-in"].animated{animation-name:fadeIn}[data-animation="slide-up"].animated{animation-name:slideUp}[data-animation="slide-left"].animated{animation-name:slideLeft}[data-animation="slide-right"].animated{animation-name:slideRight}[data-animation="zoom-in"].animated{animation-name:zoomIn}[data-animation="bounce"].animated{animation-name:bounce}`;
|
||||||
|
|
||||||
const ANIMATION_SCRIPT = `<script>
|
const ANIMATION_SCRIPT = `<script>
|
||||||
document.querySelectorAll('[data-animation]').forEach(function(el) {
|
document.querySelectorAll('[data-animation]').forEach(function(el) {
|
||||||
var delay = el.getAttribute('data-animation-delay');
|
var delay = el.getAttribute('data-animation-delay');
|
||||||
if (delay) el.style.animationDelay = delay;
|
if (delay) el.style.animationDelay = /^-?[0-9.]+$/.test(delay) ? delay + 's' : delay;
|
||||||
new IntersectionObserver(function(entries) {
|
new IntersectionObserver(function(entries) {
|
||||||
entries.forEach(function(e) { if (e.isIntersecting) { el.classList.add('animated'); } });
|
entries.forEach(function(e) { if (e.isIntersecting) { el.classList.add('animated'); } });
|
||||||
}, { threshold: 0.1 }).observe(el);
|
}, { threshold: 0.1 }).observe(el);
|
||||||
});
|
});
|
||||||
</script>`;
|
</script>`;
|
||||||
|
|
||||||
|
// No-JS safety net (contract "No-JS safety"): un-hides animated elements
|
||||||
|
// when JS is disabled, so `[data-animation]{opacity:0}` never permanently
|
||||||
|
// hides content that the reveal script would otherwise never run for.
|
||||||
|
const ANIMATION_NOSCRIPT = `<noscript><style>[data-animation]{opacity:1}</style></noscript>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the reveal `<script>` (byte-identical to the shared contract, and
|
||||||
|
* to the backend's `generateCompiledHTML` emission) when `bodyHtml` contains
|
||||||
|
* an animated element, else `''`. Single source of the script string so
|
||||||
|
* every caller (wrapInDocument's in-body emission, and TopBar's Preview
|
||||||
|
* body-replacement) stays in sync.
|
||||||
|
*/
|
||||||
|
export function buildAnimationScript(bodyHtml: string): string {
|
||||||
|
return bodyHtml.includes('data-animation') ? ANIMATION_SCRIPT : '';
|
||||||
|
}
|
||||||
|
|
||||||
function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
||||||
const title = options.title || 'Untitled Page';
|
const title = options.title || 'Untitled Page';
|
||||||
const minify = options.minifyCss !== false;
|
const minify = options.minifyCss !== false;
|
||||||
@@ -361,10 +394,15 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
|||||||
const seoMeta = buildSeoMeta(options, title);
|
const seoMeta = buildSeoMeta(options, title);
|
||||||
const tokenCss = buildTokenCss(design);
|
const tokenCss = buildTokenCss(design);
|
||||||
|
|
||||||
// Only include animation CSS + script if body contains data-animation
|
// Only include animation CSS + noscript fallback + script if body contains
|
||||||
|
// data-animation (contract gate). `buildAnimationScript` is the single
|
||||||
|
// source of the reveal-script string -- TopBar's Preview body-replacement
|
||||||
|
// uses the same helper so the two emissions never drift apart.
|
||||||
const hasAnimations = bodyHtml.includes('data-animation');
|
const hasAnimations = bodyHtml.includes('data-animation');
|
||||||
const animationBlock = hasAnimations ? animation : '';
|
const animationBlock = hasAnimations ? animation : '';
|
||||||
const animationScript = hasAnimations ? `\n${ANIMATION_SCRIPT}` : '';
|
const animationNoscript = hasAnimations ? `\n ${ANIMATION_NOSCRIPT}` : '';
|
||||||
|
const revealScript = buildAnimationScript(bodyHtml);
|
||||||
|
const animationScript = revealScript ? `\n${revealScript}` : '';
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -372,7 +410,7 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
${seoMeta}${fonts}
|
${seoMeta}${fonts}
|
||||||
<style>${reset}${responsive}${visibility}${animationBlock}${tokenCss}</style>${headCode}
|
<style>${reset}${responsive}${visibility}${animationBlock}${tokenCss}</style>${animationNoscript}${headCode}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
${bodyHtml}${animationScript}
|
${bodyHtml}${animationScript}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { findUnreachableNodeIds, repairOrphanNodes } from './orphan-repair';
|
||||||
|
|
||||||
|
/** Minimal Craft-shaped node. */
|
||||||
|
function node(over: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
isCanvas: false,
|
||||||
|
props: {},
|
||||||
|
displayName: 'Container',
|
||||||
|
custom: {},
|
||||||
|
hidden: false,
|
||||||
|
nodes: [],
|
||||||
|
linkedNodes: {},
|
||||||
|
parent: 'ROOT',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthy = JSON.stringify({
|
||||||
|
ROOT: node({ isCanvas: true, parent: null, nodes: ['a'] }),
|
||||||
|
a: node({ parent: 'ROOT' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findUnreachableNodeIds', () => {
|
||||||
|
test('a healthy tree has no unreachable nodes', () => {
|
||||||
|
expect(findUnreachableNodeIds(JSON.parse(healthy))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a node ROOT does not list is unreachable even when its parent says ROOT', () => {
|
||||||
|
const nodes = JSON.parse(healthy);
|
||||||
|
nodes.stray = node({ parent: 'ROOT', displayName: 'HTML' });
|
||||||
|
expect(findUnreachableNodeIds(nodes)).toEqual(['stray']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a node whose parent no longer exists is unreachable', () => {
|
||||||
|
const nodes = JSON.parse(healthy);
|
||||||
|
nodes.stray = node({ parent: 'ghost' });
|
||||||
|
expect(findUnreachableNodeIds(nodes)).toEqual(['stray']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('children of an unreachable node are also unreachable', () => {
|
||||||
|
const nodes = JSON.parse(healthy);
|
||||||
|
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
|
||||||
|
nodes.strayChild = node({ parent: 'stray' });
|
||||||
|
expect(findUnreachableNodeIds(nodes).sort()).toEqual(['stray', 'strayChild']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('linkedNodes children count as reachable', () => {
|
||||||
|
const nodes = JSON.parse(healthy);
|
||||||
|
nodes.ROOT.linkedNodes = { inner: 'linked' };
|
||||||
|
nodes.linked = node({ parent: 'ROOT' });
|
||||||
|
expect(findUnreachableNodeIds(nodes)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a cycle among orphans terminates instead of hanging', () => {
|
||||||
|
const nodes = JSON.parse(healthy);
|
||||||
|
nodes.x = node({ parent: 'y', nodes: ['y'] });
|
||||||
|
nodes.y = node({ parent: 'x', nodes: ['x'] });
|
||||||
|
expect(findUnreachableNodeIds(nodes).sort()).toEqual(['x', 'y']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('repairOrphanNodes', () => {
|
||||||
|
test('a healthy tree is returned byte-identical with nothing repaired', () => {
|
||||||
|
const out = repairOrphanNodes(healthy);
|
||||||
|
expect(out.repaired).toEqual([]);
|
||||||
|
expect(out.state).toBe(healthy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an orphan is appended to the end of ROOT.nodes and reparented', () => {
|
||||||
|
const nodes = JSON.parse(healthy);
|
||||||
|
nodes.stray = node({ parent: 'ghost', displayName: 'HTML' });
|
||||||
|
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||||
|
|
||||||
|
expect(out.repaired).toEqual(['stray']);
|
||||||
|
const parsed = JSON.parse(out.state);
|
||||||
|
expect(parsed.ROOT.nodes).toEqual(['a', 'stray']);
|
||||||
|
expect(parsed.stray.parent).toBe('ROOT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the top of an orphan subtree is reattached; its children ride along', () => {
|
||||||
|
const nodes = JSON.parse(healthy);
|
||||||
|
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
|
||||||
|
nodes.strayChild = node({ parent: 'stray' });
|
||||||
|
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||||
|
|
||||||
|
expect(out.repaired).toEqual(['stray']);
|
||||||
|
const parsed = JSON.parse(out.state);
|
||||||
|
expect(parsed.ROOT.nodes).toEqual(['a', 'stray']);
|
||||||
|
expect(parsed.strayChild.parent).toBe('stray');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed JSON comes back unchanged rather than throwing', () => {
|
||||||
|
const out = repairOrphanNodes('{not json');
|
||||||
|
expect(out.state).toBe('{not json');
|
||||||
|
expect(out.repaired).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state with no ROOT comes back unchanged', () => {
|
||||||
|
const orphanOnly = JSON.stringify({ a: node({ parent: null }) });
|
||||||
|
const out = repairOrphanNodes(orphanOnly);
|
||||||
|
expect(out.state).toBe(orphanOnly);
|
||||||
|
expect(out.repaired).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A cluster of orphans whose `parent` fields (and child lists) reference
|
||||||
|
// only each other has no member pointing "outside" the cluster, so the
|
||||||
|
// simple "reattach the top" rule finds no top at all. Regression coverage
|
||||||
|
// for the invariant: after repair, nothing in the returned state may still
|
||||||
|
// be unreachable -- see the loop comment in the implementation.
|
||||||
|
|
||||||
|
test('a 2-node orphan cycle is fully reachable after repair', () => {
|
||||||
|
const nodes = JSON.parse(healthy);
|
||||||
|
nodes.x = node({ parent: 'y', nodes: ['y'] });
|
||||||
|
nodes.y = node({ parent: 'x', nodes: ['x'] });
|
||||||
|
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||||
|
|
||||||
|
expect(out.repaired.length).toBeGreaterThan(0);
|
||||||
|
const parsed = JSON.parse(out.state);
|
||||||
|
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 3-node orphan cycle is fully reachable after repair', () => {
|
||||||
|
const nodes = JSON.parse(healthy);
|
||||||
|
nodes.p = node({ parent: 'r', nodes: ['q'] });
|
||||||
|
nodes.q = node({ parent: 'p', nodes: ['r'] });
|
||||||
|
nodes.r = node({ parent: 'q', nodes: ['p'] });
|
||||||
|
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||||
|
|
||||||
|
expect(out.repaired.length).toBeGreaterThan(0);
|
||||||
|
const parsed = JSON.parse(out.state);
|
||||||
|
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an ordinary orphan subtree and a separate orphan cycle in the same document are both repaired', () => {
|
||||||
|
const nodes = JSON.parse(healthy);
|
||||||
|
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
|
||||||
|
nodes.strayChild = node({ parent: 'stray' });
|
||||||
|
nodes.x = node({ parent: 'y', nodes: ['y'] });
|
||||||
|
nodes.y = node({ parent: 'x', nodes: ['x'] });
|
||||||
|
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||||
|
|
||||||
|
const parsed = JSON.parse(out.state);
|
||||||
|
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
||||||
|
// The ordinary subtree keeps its established "only the top is
|
||||||
|
// reattached" behaviour: stray is reparented, strayChild rides along
|
||||||
|
// untouched.
|
||||||
|
expect(out.repaired).toContain('stray');
|
||||||
|
expect(parsed.strayChild.parent).toBe('stray');
|
||||||
|
// Exactly one representative per component is force-reattached: stray
|
||||||
|
// (found via the normal rule) plus one of x/y (via the cycle fallback).
|
||||||
|
expect(out.repaired.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Editor-state integrity: every node must be reachable from ROOT.
|
||||||
|
*
|
||||||
|
* A node that exists in `SerializedNodes` but appears in no parent's `nodes`
|
||||||
|
* or `linkedNodes` list is invisible to the Layers tree AND to Craft's own
|
||||||
|
* selection machinery -- but not because it "renders but can't be selected".
|
||||||
|
* Craft.js's `<Frame>` only instantiates nodes it can actually walk to via
|
||||||
|
* `data.nodes`/`linkedNodes` starting from ROOT, so an unreachable node is
|
||||||
|
* never rendered at all: it doesn't appear on the canvas, it's just inert
|
||||||
|
* data sitting in the serialized state. Reachability is computed from the
|
||||||
|
* PARENT'S child lists, not from each node's own `parent` pointer: a stale
|
||||||
|
* `parent: 'ROOT'` on a node ROOT never lists is precisely the broken case
|
||||||
|
* we're looking for.
|
||||||
|
*
|
||||||
|
* This is a DIFFERENT mechanism from the originally-reported symptom -- a
|
||||||
|
* *visible* element on the canvas that can't be selected or deleted. That
|
||||||
|
* symptom requires the node to be both rendered AND excluded from Craft's
|
||||||
|
* selection/interaction machinery, which is not what an unreachable-from-
|
||||||
|
* ROOT node produces (it isn't rendered at all). This repair fixes the
|
||||||
|
* "node is present in state but invisible/unrecoverable" case; the
|
||||||
|
* originally-reported "visible but unselectable" case is still
|
||||||
|
* unreproduced and is presumed to be a different bug.
|
||||||
|
*
|
||||||
|
* Pure functions over serialized state -- no React, no Craft instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROOT_ID = 'ROOT';
|
||||||
|
|
||||||
|
function childIdsOf(node: any): string[] {
|
||||||
|
const nodes: string[] = Array.isArray(node?.nodes) ? node.nodes : [];
|
||||||
|
const linked: string[] = node?.linkedNodes ? Object.values(node.linkedNodes) : [];
|
||||||
|
return [...nodes, ...linked];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ids of nodes present in `nodes` whose parent chain does not reach 'ROOT'. */
|
||||||
|
export function findUnreachableNodeIds(nodes: Record<string, any>): string[] {
|
||||||
|
if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) return [];
|
||||||
|
|
||||||
|
const reachable = new Set<string>([ROOT_ID]);
|
||||||
|
const queue: string[] = [ROOT_ID];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const id = queue.shift()!;
|
||||||
|
for (const childId of childIdsOf(nodes[id])) {
|
||||||
|
// The `reachable` guard also terminates on a cycle among real nodes.
|
||||||
|
if (typeof childId === 'string' && nodes[childId] && !reachable.has(childId)) {
|
||||||
|
reachable.add(childId);
|
||||||
|
queue.push(childId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(nodes).filter((id) => !reachable.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reattach every unreachable node to the end of ROOT.nodes. Returns the
|
||||||
|
* possibly-rewritten serialized state and the ids that were moved. Never
|
||||||
|
* throws: malformed input comes back unchanged with an empty `repaired`.
|
||||||
|
*
|
||||||
|
* Invariant: for any input that parses and has a ROOT, calling
|
||||||
|
* `findUnreachableNodeIds` on the returned `state` (parsed) always yields
|
||||||
|
* `[]` -- there is no orphan configuration this leaves half-repaired. */
|
||||||
|
export function repairOrphanNodes(serialized: string): { state: string; repaired: string[] } {
|
||||||
|
let nodes: Record<string, any>;
|
||||||
|
try {
|
||||||
|
nodes = JSON.parse(serialized);
|
||||||
|
} catch {
|
||||||
|
// A page must never fail to load because the repair pass couldn't parse
|
||||||
|
// it -- hand the original string straight back to deserialize().
|
||||||
|
return { state: serialized, repaired: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) {
|
||||||
|
return { state: serialized, repaired: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let unreachable = findUnreachableNodeIds(nodes);
|
||||||
|
if (unreachable.length === 0) return { state: serialized, repaired: [] };
|
||||||
|
|
||||||
|
if (!Array.isArray(nodes[ROOT_ID].nodes)) nodes[ROOT_ID].nodes = [];
|
||||||
|
const repaired: string[] = [];
|
||||||
|
|
||||||
|
// Loop because a single pass can leave orphan CYCLES untouched: if every
|
||||||
|
// member of a cluster points only at other members of that same cluster,
|
||||||
|
// none of them has a parent pointing "out", so nothing qualifies as a top
|
||||||
|
// and a one-shot pass would report `repaired: []` while the cluster is
|
||||||
|
// still unreachable. Each iteration re-derives `unreachable` from the
|
||||||
|
// current (partially repaired) state and terminates once it's empty --
|
||||||
|
// this is what makes the invariant hold rather than just being hoped for.
|
||||||
|
while (unreachable.length > 0) {
|
||||||
|
const orphanSet = new Set(unreachable);
|
||||||
|
|
||||||
|
// Reattach the TOP of each orphan subtree: a node whose `parent` points
|
||||||
|
// outside the current orphan set (to something real, to nothing, or is
|
||||||
|
// null). Its existing child list carries the rest of its subtree along
|
||||||
|
// for free once BFS can reach it again.
|
||||||
|
let tops = unreachable.filter((id) => {
|
||||||
|
const parent = nodes[id]?.parent;
|
||||||
|
return !(typeof parent === 'string' && orphanSet.has(parent));
|
||||||
|
});
|
||||||
|
|
||||||
|
// No such node exists only when every remaining orphan's `parent`
|
||||||
|
// points at another orphan -- i.e. a closed cycle (2+ nodes referencing
|
||||||
|
// only each other). There is no legitimate "outside" anchor to prefer,
|
||||||
|
// so break the cycle by force-reattaching one representative member
|
||||||
|
// (first in iteration order, for determinism). Because a cycle is
|
||||||
|
// strongly connected via the actual nodes/linkedNodes edges, reattaching
|
||||||
|
// any single member pulls the rest of that cycle in on the next
|
||||||
|
// `findUnreachableNodeIds` pass without touching their `parent` fields.
|
||||||
|
if (tops.length === 0) {
|
||||||
|
tops = [unreachable[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of tops) {
|
||||||
|
nodes[id].parent = ROOT_ID;
|
||||||
|
nodes[ROOT_ID].nodes.push(id);
|
||||||
|
repaired.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
unreachable = findUnreachableNodeIds(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: JSON.stringify(nodes), repaired };
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { buildReportPayload, MAX_PAYLOAD_BYTES } from './report-payload';
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
category: 'bug' as const,
|
||||||
|
description: 'The HTML block colours do nothing',
|
||||||
|
includeCanvas: true,
|
||||||
|
siteId: 42,
|
||||||
|
siteDomain: 'example.com',
|
||||||
|
pageId: 'home',
|
||||||
|
pageSlug: 'index',
|
||||||
|
editorVersion: 'abc1234-2026-08-08',
|
||||||
|
userAgent: 'Mozilla/5.0 test',
|
||||||
|
viewport: '1920x1080',
|
||||||
|
deviceMode: 'desktop',
|
||||||
|
selectedType: 'HTML',
|
||||||
|
consoleErrors: [{ ts: 1, message: 'oops' }],
|
||||||
|
canvasState: '{"ROOT":{}}',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildReportPayload', () => {
|
||||||
|
test('carries every context field through', () => {
|
||||||
|
const p = buildReportPayload(base);
|
||||||
|
expect(p.category).toBe('bug');
|
||||||
|
expect(p.description).toBe('The HTML block colours do nothing');
|
||||||
|
expect(p.site_id).toBe(42);
|
||||||
|
expect(p.site_domain).toBe('example.com');
|
||||||
|
expect(p.page_slug).toBe('index');
|
||||||
|
expect(p.editor_version).toBe('abc1234-2026-08-08');
|
||||||
|
expect(p.device_mode).toBe('desktop');
|
||||||
|
expect(p.selected_type).toBe('HTML');
|
||||||
|
expect(p.console_errors).toEqual([{ ts: 1, message: 'oops' }]);
|
||||||
|
expect(p.canvas_state).toBe('{"ROOT":{}}');
|
||||||
|
expect(p.canvas_state_omitted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includeCanvas=false drops the canvas and records why', () => {
|
||||||
|
const p = buildReportPayload({ ...base, includeCanvas: false });
|
||||||
|
expect(p.canvas_state).toBeNull();
|
||||||
|
expect(p.canvas_state_omitted).toBe('opt-out');
|
||||||
|
expect(p.console_errors).toHaveLength(1);
|
||||||
|
expect(p.site_domain).toBe('example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an oversized canvas is dropped rather than truncated', () => {
|
||||||
|
const huge = 'x'.repeat(MAX_PAYLOAD_BYTES + 1000);
|
||||||
|
const p = buildReportPayload({ ...base, canvasState: huge });
|
||||||
|
expect(p.canvas_state).toBeNull();
|
||||||
|
expect(p.canvas_state_omitted).toBe('size');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the resulting payload always fits under the cap', () => {
|
||||||
|
const huge = 'x'.repeat(MAX_PAYLOAD_BYTES * 2);
|
||||||
|
const p = buildReportPayload({ ...base, canvasState: huge });
|
||||||
|
expect(new Blob([JSON.stringify(p)]).size).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('description is trimmed', () => {
|
||||||
|
expect(buildReportPayload({ ...base, description: ' spaced ' }).description).toBe('spaced');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a null canvasState is reported as opt-out-free but still null', () => {
|
||||||
|
const p = buildReportPayload({ ...base, canvasState: null });
|
||||||
|
expect(p.canvas_state).toBeNull();
|
||||||
|
expect(p.canvas_state_omitted).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('description size bounding', () => {
|
||||||
|
const hugeDescription = 'y'.repeat(600 * 1024);
|
||||||
|
|
||||||
|
test('oversized description does not blow the cap with canvas included', () => {
|
||||||
|
const p = buildReportPayload({ ...base, description: hugeDescription });
|
||||||
|
expect(new Blob([JSON.stringify(p)]).size).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oversized description does not blow the cap when opted out', () => {
|
||||||
|
const p = buildReportPayload({ ...base, description: hugeDescription, includeCanvas: false });
|
||||||
|
expect(new Blob([JSON.stringify(p)]).size).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES);
|
||||||
|
expect(p.canvas_state).toBeNull();
|
||||||
|
expect(p.canvas_state_omitted).toBe('opt-out');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oversized description does not blow the cap with no canvas state', () => {
|
||||||
|
const p = buildReportPayload({ ...base, description: hugeDescription, canvasState: null });
|
||||||
|
expect(new Blob([JSON.stringify(p)]).size).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES);
|
||||||
|
expect(p.canvas_state).toBeNull();
|
||||||
|
expect(p.canvas_state_omitted).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('description is truncated to 5000 characters', () => {
|
||||||
|
const p = buildReportPayload({ ...base, description: hugeDescription });
|
||||||
|
expect(p.description.length).toBeLessThanOrEqual(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('honest size markers', () => {
|
||||||
|
test('the size marker never appears on a payload that is still oversized after dropping canvas_state', () => {
|
||||||
|
// consoleErrors is normally bounded upstream (console-buffer.ts caps it at
|
||||||
|
// 20 entries x 500 chars), but this function must not trust that -- it's
|
||||||
|
// just an array of the exported type as far as the signature is concerned.
|
||||||
|
const massiveErrors = Array.from({ length: 5000 }, (_, i) => ({ ts: i, message: 'x'.repeat(200) }));
|
||||||
|
expect(() => buildReportPayload({ ...base, consoleErrors: massiveErrors })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import type { ConsoleErrorEntry } from './console-buffer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles the JSON body for an in-builder issue report.
|
||||||
|
*
|
||||||
|
* Pure: every environment value (user agent, viewport, serialized canvas)
|
||||||
|
* is passed IN rather than read from globals, so the whole thing is testable
|
||||||
|
* without a DOM and the caller decides what it is willing to send.
|
||||||
|
*
|
||||||
|
* The canvas state is the only field that can be large. When it would push
|
||||||
|
* the body over the cap it is DROPPED WHOLE and flagged -- a truncated craft
|
||||||
|
* state is not merely useless, it is misleading (it looks like a valid tree
|
||||||
|
* that lost nodes).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MAX_PAYLOAD_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the limit the server-side validator enforces (a later task) so the
|
||||||
|
* client never builds a body the server would reject outright. Truncated
|
||||||
|
* silently, not flagged: unlike canvas_state (a serialized tree, where a cut
|
||||||
|
* mid-structure looks like a smaller-but-still-valid tree and actively
|
||||||
|
* misleads whoever reads it), a truncated free-text description is exactly
|
||||||
|
* what it looks like -- text that stops partway through. Nothing about the
|
||||||
|
* cut invents false structure, and the limit mirrors what the server would
|
||||||
|
* have discarded anyway.
|
||||||
|
*/
|
||||||
|
export const MAX_DESCRIPTION_CHARS = 5000;
|
||||||
|
|
||||||
|
export type ReportCategory = 'bug' | 'confusing' | 'feature';
|
||||||
|
|
||||||
|
export interface BuildReportPayloadInput {
|
||||||
|
category: ReportCategory;
|
||||||
|
description: string;
|
||||||
|
includeCanvas: boolean;
|
||||||
|
siteId: number | null;
|
||||||
|
siteDomain: string;
|
||||||
|
pageId: string;
|
||||||
|
pageSlug: string;
|
||||||
|
editorVersion: string;
|
||||||
|
userAgent: string;
|
||||||
|
viewport: string;
|
||||||
|
deviceMode: string;
|
||||||
|
selectedType: string | null;
|
||||||
|
consoleErrors: ConsoleErrorEntry[];
|
||||||
|
canvasState: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportPayload {
|
||||||
|
category: ReportCategory;
|
||||||
|
description: string;
|
||||||
|
site_id: number | null;
|
||||||
|
site_domain: string;
|
||||||
|
page_id: string;
|
||||||
|
page_slug: string;
|
||||||
|
editor_version: string;
|
||||||
|
user_agent: string;
|
||||||
|
viewport: string;
|
||||||
|
device_mode: string;
|
||||||
|
selected_type: string | null;
|
||||||
|
console_errors: ConsoleErrorEntry[];
|
||||||
|
canvas_state: string | null;
|
||||||
|
/** Present only when the canvas state was dropped. */
|
||||||
|
canvas_state_omitted?: 'size' | 'opt-out';
|
||||||
|
}
|
||||||
|
|
||||||
|
function byteLength(value: string): number {
|
||||||
|
// TextEncoder is standard in every environment this module actually runs
|
||||||
|
// in (Node/vitest and every real browser target). The fallback below is
|
||||||
|
// unreachable by design -- kept only so a missing global degrades to a
|
||||||
|
// conservative-ish count rather than throwing, not because it's expected
|
||||||
|
// to fire. (It undercounts multi-byte UTF-8, so treat it as dead code.)
|
||||||
|
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(value).length;
|
||||||
|
return value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadBytes(payload: ReportPayload): number {
|
||||||
|
return byteLength(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every return path funnels through here so the cap is checked
|
||||||
|
* unconditionally, not just on the canvas-included branch. If the payload
|
||||||
|
* is still over budget after every available reduction (dropping
|
||||||
|
* canvas_state, truncating description), there is nothing left to cut --
|
||||||
|
* returning it anyway would ship an oversized body that, if it carries
|
||||||
|
* `canvas_state_omitted: 'size'`, falsely claims the drop fixed things.
|
||||||
|
* Throwing surfaces that as a distinct, honest failure instead.
|
||||||
|
*/
|
||||||
|
function finalize(payload: ReportPayload): ReportPayload {
|
||||||
|
if (payloadBytes(payload) > MAX_PAYLOAD_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`Report payload is ${payloadBytes(payload)} bytes, over the ${MAX_PAYLOAD_BYTES}-byte cap, ` +
|
||||||
|
'even with canvas_state dropped. Nothing left to reduce.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReportPayload(input: BuildReportPayloadInput): ReportPayload {
|
||||||
|
const description = input.description.trim().slice(0, MAX_DESCRIPTION_CHARS);
|
||||||
|
|
||||||
|
const base: ReportPayload = {
|
||||||
|
category: input.category,
|
||||||
|
description,
|
||||||
|
site_id: input.siteId,
|
||||||
|
site_domain: input.siteDomain,
|
||||||
|
page_id: input.pageId,
|
||||||
|
page_slug: input.pageSlug,
|
||||||
|
editor_version: input.editorVersion,
|
||||||
|
user_agent: input.userAgent,
|
||||||
|
viewport: input.viewport,
|
||||||
|
device_mode: input.deviceMode,
|
||||||
|
selected_type: input.selectedType,
|
||||||
|
console_errors: input.consoleErrors,
|
||||||
|
canvas_state: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!input.includeCanvas) {
|
||||||
|
// The opt-out promise is inviolable: canvas_state is never populated
|
||||||
|
// from input.canvasState on this path, no matter what else changes
|
||||||
|
// below it. Only the cap is checked here, not whether to include canvas.
|
||||||
|
return finalize({ ...base, canvas_state_omitted: 'opt-out' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.canvasState) return finalize(base);
|
||||||
|
|
||||||
|
const withCanvas: ReportPayload = { ...base, canvas_state: input.canvasState };
|
||||||
|
if (payloadBytes(withCanvas) <= MAX_PAYLOAD_BYTES) return withCanvas;
|
||||||
|
|
||||||
|
// canvas_state alone pushed this over budget: drop it whole (never
|
||||||
|
// truncate -- a partial Craft tree looks valid but is missing nodes,
|
||||||
|
// which is worse than no tree) and re-measure. The 'size' marker is only
|
||||||
|
// attached once we've confirmed dropping the canvas actually brought the
|
||||||
|
// payload back under the cap; finalize() throws instead of returning it
|
||||||
|
// if it didn't.
|
||||||
|
return finalize({ ...base, canvas_state: null, canvas_state_omitted: 'size' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { scopeCss } from './scope-css';
|
||||||
|
|
||||||
|
const SCOPE = '.whp-html-1a2b3c4d';
|
||||||
|
|
||||||
|
describe('scopeCss -- basic selector scoping', () => {
|
||||||
|
test('a single simple selector gets prefixed', () => {
|
||||||
|
expect(scopeCss('h1 { color: red; }', SCOPE)).toBe(`${SCOPE} h1 { color: red; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple rules each get prefixed independently', () => {
|
||||||
|
const input = 'h1 { color: red; } p { color: blue; }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
|
||||||
|
expect(out).toContain(`${SCOPE} p { color: blue; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a compound descendant selector is prefixed as a whole, not per-token', () => {
|
||||||
|
expect(scopeCss('div.card > h2 { color: red; }', SCOPE)).toBe(`${SCOPE} div.card > h2 { color: red; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pseudo-classes/elements survive attached to their element', () => {
|
||||||
|
expect(scopeCss('a:hover { color: red; }', SCOPE)).toBe(`${SCOPE} a:hover { color: red; }`);
|
||||||
|
expect(scopeCss('p::before { content: "x"; }', SCOPE)).toBe(`${SCOPE} p::before { content: "x"; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the universal selector is prefixed', () => {
|
||||||
|
expect(scopeCss('* { box-sizing: border-box; }', SCOPE)).toBe(`${SCOPE} * { box-sizing: border-box; }`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopeCss -- comma-separated selector lists (every selector must be scoped)', () => {
|
||||||
|
test('h1, h2 > p scopes BOTH selectors, not just the first', () => {
|
||||||
|
const out = scopeCss('h1, h2 > p { margin: 0; }', SCOPE);
|
||||||
|
expect(out).toBe(`${SCOPE} h1, ${SCOPE} h2 > p { margin: 0; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long comma list scopes every entry', () => {
|
||||||
|
const out = scopeCss('h1, h2, h3, h4 { font-weight: bold; }', SCOPE);
|
||||||
|
expect(out).toBe(`${SCOPE} h1, ${SCOPE} h2, ${SCOPE} h3, ${SCOPE} h4 { font-weight: bold; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a comma inside :not(...) is not treated as a selector-list separator', () => {
|
||||||
|
const out = scopeCss('div:not(h1, h2) { color: red; }', SCOPE);
|
||||||
|
expect(out).toBe(`${SCOPE} div:not(h1, h2) { color: red; }`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopeCss -- @media / @supports / @container recurse into the body', () => {
|
||||||
|
test('@media keeps its condition prelude intact and scopes the selector inside', () => {
|
||||||
|
const input = '@media (min-width: 600px) { h1 { color: red; } }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1 { color: red; } }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@supports keeps its condition prelude intact and scopes the selector inside', () => {
|
||||||
|
const input = '@supports (display: grid) { .grid { display: grid; } }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toBe(`@supports (display: grid) { ${SCOPE} .grid { display: grid; } }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@container keeps its condition prelude intact and scopes the selector inside', () => {
|
||||||
|
const input = '@container (min-width: 400px) { .card { padding: 8px; } }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toBe(`@container (min-width: 400px) { ${SCOPE} .card { padding: 8px; } }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple rules inside one @media block are each scoped', () => {
|
||||||
|
const input = '@media (min-width: 600px) { h1 { color: red; } p { color: blue; } }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1 { color: red; } ${SCOPE} p { color: blue; } }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a comma-separated selector list inside @media is fully scoped', () => {
|
||||||
|
const input = '@media (min-width: 600px) { h1, h2 { color: red; } }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1, ${SCOPE} h2 { color: red; } }`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopeCss -- @keyframes body is left untouched', () => {
|
||||||
|
test('keyframe selectors (from/to/percentages) are not scoped', () => {
|
||||||
|
const input = '@keyframes spin { from { opacity: 0; } 50% { opacity: 0.5; } to { opacity: 1; } }';
|
||||||
|
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vendor-prefixed @-webkit-keyframes body is also left untouched', () => {
|
||||||
|
const input = '@-webkit-keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
|
||||||
|
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a normal rule after a @keyframes block is still scoped (parser resyncs correctly)', () => {
|
||||||
|
const input = '@keyframes spin { from { opacity: 0; } to { opacity: 1; } } h1 { color: red; }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toBe(`@keyframes spin { from { opacity: 0; } to { opacity: 1; } } ${SCOPE} h1 { color: red; }`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopeCss -- @font-face is left entirely alone (no selector to scope)', () => {
|
||||||
|
test('@font-face block passes through byte-identical', () => {
|
||||||
|
const input = "@font-face { font-family: 'Custom'; src: url(custom.woff2) format('woff2'); }";
|
||||||
|
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopeCss -- @import is stripped', () => {
|
||||||
|
test('a bare @import statement is removed', () => {
|
||||||
|
const out = scopeCss('@import url("https://evil.example/x.css");', SCOPE);
|
||||||
|
expect(out).not.toContain('@import');
|
||||||
|
expect(out).not.toContain('evil.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@import surrounded by real rules: only the import is removed, the rules survive scoped', () => {
|
||||||
|
const input = '@import url("x.css"); h1 { color: red; } p { color: blue; }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).not.toContain('@import');
|
||||||
|
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
|
||||||
|
expect(out).toContain(`${SCOPE} p { color: blue; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@import with a semicolon inside its quoted url is still recognized as ONE statement', () => {
|
||||||
|
// The url itself doesn't contain a semicolon in practice, but this
|
||||||
|
// proves the statement-terminator scan is string-aware in general: a
|
||||||
|
// quoted string's contents (whatever they are) never end the statement
|
||||||
|
// early.
|
||||||
|
const input = '@import url("foo.css?x=1;y=2"); h1 { color: red; }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).not.toContain('@import');
|
||||||
|
expect(out).not.toContain('foo.css');
|
||||||
|
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopeCss -- :root / html / body map to the scope root itself', () => {
|
||||||
|
test(':root custom properties target the wrapper, not a nonexistent descendant', () => {
|
||||||
|
expect(scopeCss(':root { --brand: red; }', SCOPE)).toBe(`${SCOPE} { --brand: red; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('html selector maps to the scope root', () => {
|
||||||
|
expect(scopeCss('html { background: #fff; }', SCOPE)).toBe(`${SCOPE} { background: #fff; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('body selector maps to the scope root', () => {
|
||||||
|
expect(scopeCss('body { margin: 0; }', SCOPE)).toBe(`${SCOPE} { margin: 0; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case-insensitive: HTML and BODY also map to the scope root', () => {
|
||||||
|
expect(scopeCss('HTML { color: red; }', SCOPE)).toBe(`${SCOPE} { color: red; }`);
|
||||||
|
expect(scopeCss('BODY { color: red; }', SCOPE)).toBe(`${SCOPE} { color: red; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(':root mixed into a comma list scopes the other entries normally', () => {
|
||||||
|
const out = scopeCss(':root, h1 { color: red; }', SCOPE);
|
||||||
|
expect(out).toBe(`${SCOPE}, ${SCOPE} h1 { color: red; }`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopeCss -- comments and strings are not treated as syntax', () => {
|
||||||
|
test('a brace inside a comment does not confuse block matching', () => {
|
||||||
|
const input = 'h1 { color: red; /* comment with a { brace */ }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toBe(`${SCOPE} ${input}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a comma inside a comment does not split a selector list', () => {
|
||||||
|
const input = 'h1 /* a, b */ , p { color: red; }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toBe(`${SCOPE} h1 /* a, b */, ${SCOPE} p { color: red; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an @ inside a comment does not trigger at-rule handling', () => {
|
||||||
|
// The comment sits in front of the selector text, so it stays part of
|
||||||
|
// what gets prefixed (a CSS comment is insignificant whitespace to the
|
||||||
|
// parser -- `.scope /* c */ h1` is equivalent to `.scope h1`). What
|
||||||
|
// this test really guards: the leading "@import" text INSIDE the
|
||||||
|
// comment must not make the classifier treat this as an @import
|
||||||
|
// statement and strip the whole rule.
|
||||||
|
const input = '/* @import fake */ h1 { color: red; }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toBe(`${SCOPE} /* @import fake */ h1 { color: red; }`);
|
||||||
|
expect(out).toContain('color: red');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a brace inside a quoted content string does not confuse block matching', () => {
|
||||||
|
const input = 'p::before { content: "{ not a brace }"; }';
|
||||||
|
expect(scopeCss(input, SCOPE)).toBe(`${SCOPE} ${input}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a comma inside a quoted string does not split a selector list', () => {
|
||||||
|
const input = 'h1[data-x="a,b"], p { color: red; }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toBe(`${SCOPE} h1[data-x="a,b"], ${SCOPE} p { color: red; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a semicolon inside a quoted string does not end an @import early', () => {
|
||||||
|
const input = 'h1::before { content: "a;b"; } p { color: red; }';
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toBe(`${SCOPE} h1::before { content: "a;b"; } ${SCOPE} p { color: red; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unterminated comment consumes to end of string without throwing', () => {
|
||||||
|
expect(() => scopeCss('h1 { color: red; } /* unterminated', SCOPE)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopeCss -- idempotency (running twice must not double-prefix)', () => {
|
||||||
|
test('a plain selector is not re-prefixed on a second pass', () => {
|
||||||
|
const once = scopeCss('h1 { color: red; }', SCOPE);
|
||||||
|
const twice = scopeCss(once, SCOPE);
|
||||||
|
expect(twice).toBe(once);
|
||||||
|
expect(twice.match(new RegExp(SCOPE.replace('.', '\\.'), 'g'))?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(':root-mapped rule is not re-prefixed on a second pass', () => {
|
||||||
|
const once = scopeCss(':root { --brand: red; }', SCOPE);
|
||||||
|
const twice = scopeCss(once, SCOPE);
|
||||||
|
expect(twice).toBe(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a comma list is not re-prefixed on a second pass', () => {
|
||||||
|
const once = scopeCss('h1, h2 > p { margin: 0; }', SCOPE);
|
||||||
|
const twice = scopeCss(once, SCOPE);
|
||||||
|
expect(twice).toBe(once);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rule inside @media is not re-prefixed on a second pass', () => {
|
||||||
|
const once = scopeCss('@media (min-width: 600px) { h1 { color: red; } }', SCOPE);
|
||||||
|
const twice = scopeCss(once, SCOPE);
|
||||||
|
expect(twice).toBe(once);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopeCss -- misc/edge cases', () => {
|
||||||
|
test('empty input returns empty string', () => {
|
||||||
|
expect(scopeCss('', SCOPE)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whitespace-only input round-trips without throwing', () => {
|
||||||
|
expect(() => scopeCss(' \n ', SCOPE)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a relative selector starting with a combinator is scoped as a descendant of the wrapper', () => {
|
||||||
|
// ">h1" is unusual outside CSS nesting but should not crash the scanner.
|
||||||
|
const out = scopeCss('> h1 { color: red; }', SCOPE);
|
||||||
|
expect(out).toBe(`${SCOPE} > h1 { color: red; }`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown braced at-rule (e.g. @page) is left untouched', () => {
|
||||||
|
const input = '@page { margin: 1in; }';
|
||||||
|
expect(scopeCss(input, SCOPE)).toBe(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Wraps `inner` in `depth` levels of nested `@media`, each with a trivial
|
||||||
|
* always-true-shaped condition. Used to probe/prove the recursion depth cap. */
|
||||||
|
function nestMedia(inner: string, depth: number): string {
|
||||||
|
let css = inner;
|
||||||
|
for (let i = 0; i < depth; i++) css = `@media (min-width: 1px) {${css}}`;
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('scopeCss -- review finding: bounded recursion depth (was: unbounded, crashed on ~7000 nested @media)', () => {
|
||||||
|
test('nesting comfortably under the cap: the innermost selector IS scoped', () => {
|
||||||
|
const input = nestMedia('h1{color:red}', 5);
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
expect(out).toContain(`${SCOPE} h1{color:red}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nesting far past the cap does not throw, and stops scoping beyond the cap (unscoped fallback, not a crash)', () => {
|
||||||
|
const input = nestMedia('h1{color:red}', 1000);
|
||||||
|
expect(() => scopeCss(input, SCOPE)).not.toThrow();
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
// The innermost rule sits far beyond MAX_NESTING_DEPTH -- it must come
|
||||||
|
// through UNSCOPED (the documented fallback), not silently dropped and
|
||||||
|
// not scoped from some unexpected point.
|
||||||
|
expect(out).not.toContain(SCOPE);
|
||||||
|
expect(out).toContain('h1{color:red}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the exact review repro: ~7000 nested @media, ~190KB-shaped input, does not throw', () => {
|
||||||
|
const input = nestMedia('h1{color:red}', 7000);
|
||||||
|
expect(() => scopeCss(input, SCOPE)).not.toThrow();
|
||||||
|
// Structural integrity: every opened @media brace is still closed --
|
||||||
|
// the cap changes WHAT gets scoped, never the brace structure/count.
|
||||||
|
const out = scopeCss(input, SCOPE);
|
||||||
|
const opens = (out.match(/\{/g) || []).length;
|
||||||
|
const closes = (out.match(/\}/g) || []).length;
|
||||||
|
expect(opens).toBe(closes);
|
||||||
|
expect(opens).toBe(7001); // 7000 @media wrapper braces + the innermost rule's own brace pair
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scopeCss -- review finding: never throws, on any input (property test over malformed/adversarial strings)', () => {
|
||||||
|
// Deterministic pseudo-random generator (mulberry32) -- NOT Math.random.
|
||||||
|
// A property test that can flake between CI runs is worse than no
|
||||||
|
// property test: a failure must be reproducible from the fixed seed
|
||||||
|
// below, every time, so it can actually be debugged.
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed;
|
||||||
|
return () => {
|
||||||
|
a |= 0;
|
||||||
|
a = (a + 0x6d2b79f5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALPHABET = ['{', '}', '(', ')', ';', ',', '"', "'", '@', '/', '*', ':', 'a', 'h1', ' ', '\n', '\\', '<', '>'];
|
||||||
|
|
||||||
|
function randomGarbageCss(rand: () => number, length: number): string {
|
||||||
|
let out = '';
|
||||||
|
while (out.length < length) {
|
||||||
|
out += ALPHABET[Math.floor(rand() * ALPHABET.length)];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('1000 random malformed CSS strings (unbalanced braces, dangling quotes/comments, stray @/,/:) never throw', () => {
|
||||||
|
const rand = mulberry32(42);
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const garbage = randomGarbageCss(rand, 1 + Math.floor(rand() * 200));
|
||||||
|
expect(() => scopeCss(garbage, SCOPE)).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('specific known-nasty malformed inputs never throw', () => {
|
||||||
|
const nasty = [
|
||||||
|
'{{{{{{{{{{',
|
||||||
|
'}}}}}}}}}}',
|
||||||
|
'{'.repeat(5000),
|
||||||
|
'/*'.repeat(2000),
|
||||||
|
'"'.repeat(2000),
|
||||||
|
'@media'.repeat(2000),
|
||||||
|
'h1'.repeat(50000), // pathologically long single token, no braces at all
|
||||||
|
'',
|
||||||
|
' ',
|
||||||
|
' | ||||||