mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(web): add Inspect mode for live per-element style tuning
This commit is contained in:
parent
bb2015766a
commit
38eb78a382
9 changed files with 2080 additions and 106 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -27,6 +27,8 @@ export interface UrlLoadDecision {
|
|||
isDeck: boolean;
|
||||
/** Comment mode is active — needs the comment bridge. */
|
||||
commentMode: boolean;
|
||||
/** Inspect mode is active — needs the selection bridge for live tuning. */
|
||||
inspectMode?: boolean;
|
||||
/** User explicitly opted into the inline path via ?forceInline=1. */
|
||||
forceInline: boolean;
|
||||
}
|
||||
|
|
@ -41,6 +43,9 @@ export function shouldUrlLoadHtmlPreview(d: UrlLoadDecision): boolean {
|
|||
if (d.mode !== 'preview') return false;
|
||||
if (d.isDeck) return false;
|
||||
if (d.commentMode) return false;
|
||||
// Inspect needs the selection bridge injected via buildSrcdoc; a raw
|
||||
// URL-loaded iframe has no listener to apply per-element overrides.
|
||||
if (d.inspectMode) return false;
|
||||
if (d.forceInline) return false;
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@ export const ar: Dict = {
|
|||
'promptTemplates.emptyImage': 'لم يتم تثبيت قوالب صور بعد.',
|
||||
'promptTemplates.emptyVideo': 'لم يتم تثبيت قوالب فيديو بعد.',
|
||||
'promptTemplates.emptyNoMatch': 'لا توجد قوالب تطابق بحثك.',
|
||||
'promptTemplates.allSources': 'جميع المصادر',
|
||||
'promptTemplates.sourceFilterAria': 'تصفية حسب المصدر',
|
||||
'promptTemplates.attributionFooter': 'مقتبس من مكتبات الأوامر العامة. كل بطاقة تشير إلى المؤلف الأصلي.',
|
||||
'promptTemplates.openPreviewTitle': 'فتح الأمر والمعاينة',
|
||||
'promptTemplates.sourcePrefix': 'المصدر:',
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@ export const fr: Dict = {
|
|||
'promptTemplates.emptyImage': 'Aucun modèle de prompt d\'image installé pour l\'instant.',
|
||||
'promptTemplates.emptyVideo': 'Aucun modèle de prompt vidéo installé pour l\'instant.',
|
||||
'promptTemplates.emptyNoMatch': 'Aucun modèle ne correspond à votre recherche.',
|
||||
'promptTemplates.allSources': 'Toutes les sources',
|
||||
'promptTemplates.sourceFilterAria': 'Filtrer par source',
|
||||
'promptTemplates.attributionFooter': 'Adapté de bibliothèques de prompts publiques. Chaque carte renvoie vers l\'auteur original.',
|
||||
'promptTemplates.openPreviewTitle': 'Ouvrir le prompt et l\'aperçu',
|
||||
'promptTemplates.sourcePrefix': 'Source :',
|
||||
|
|
|
|||
|
|
@ -5862,6 +5862,134 @@ button.connector-action.is-loading {
|
|||
background: var(--red-bg);
|
||||
border-color: var(--red-border);
|
||||
}
|
||||
|
||||
/* Inspect panel — sibling of the comment popover. Anchored to the
|
||||
right side of the preview surface. Width is fixed so layout doesn't
|
||||
reflow as the user scrubs slider values; controls reserve space for
|
||||
their numeric readouts. */
|
||||
.inspect-panel {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 5;
|
||||
width: 296px;
|
||||
max-height: calc(100% - 28px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 12px 14px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-panel);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-size: 12px;
|
||||
}
|
||||
.inspect-panel-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.inspect-panel-title {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.inspect-panel-title strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
.inspect-panel-title code {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.inspect-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.inspect-section-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.inspect-row {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.inspect-row > label {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.inspect-row input[type='color'] {
|
||||
width: 28px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
.inspect-row input[type='text'],
|
||||
.inspect-row select {
|
||||
min-width: 0;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.inspect-row input[type='range'] {
|
||||
width: 100%;
|
||||
}
|
||||
.inspect-row-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
}
|
||||
.inspect-panel-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.inspect-panel-error {
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--red-border);
|
||||
border-radius: 4px;
|
||||
background: var(--red-bg);
|
||||
color: var(--red);
|
||||
font-size: 11px;
|
||||
}
|
||||
.inspect-empty-hint {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 5;
|
||||
padding: 8px 12px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.inspect-empty-hint code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.comments-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export type SrcdocOptions = {
|
|||
baseHref?: string;
|
||||
initialSlideIndex?: number;
|
||||
commentBridge?: boolean;
|
||||
inspectBridge?: boolean;
|
||||
editBridge?: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -49,8 +50,21 @@ export function buildSrcdoc(
|
|||
const withBase = options.baseHref ? injectBaseHref(withSourcePaths, options.baseHref) : withSourcePaths;
|
||||
const withShim = injectSandboxShim(withBase);
|
||||
const withDeck = options.deck ? injectDeckBridge(withShim, options.initialSlideIndex) : withShim;
|
||||
const withComment = options.commentBridge ? injectCommentBridge(withDeck) : withDeck;
|
||||
return options.editBridge ? injectManualEditBridge(withComment) : withComment;
|
||||
// Comment + Inspect share an element-selection bridge: both pick a
|
||||
// [data-od-id] / [data-screen-label] node and route the host's reply
|
||||
// to either the comment popover (annotate) or the inspect panel
|
||||
// (live-style overrides). Inject once when either mode is on. Pass the
|
||||
// requested modes through so the bridge boots with picking already
|
||||
// active — without that initial seed there is a window after each
|
||||
// srcdoc rebuild where the host's `od:*-mode` postMessage races the
|
||||
// bridge's own listener install and the iframe ignores clicks.
|
||||
const withSelection = options.commentBridge || options.inspectBridge
|
||||
? injectSelectionBridge(withDeck, {
|
||||
initialCommentMode: !!options.commentBridge,
|
||||
initialInspectMode: !!options.inspectBridge,
|
||||
})
|
||||
: withDeck;
|
||||
return options.editBridge ? injectManualEditBridge(withSelection) : withSelection;
|
||||
}
|
||||
|
||||
function annotateManualEditSourcePaths(doc: string): string {
|
||||
|
|
@ -161,118 +175,250 @@ function injectSandboxShim(doc: string): string {
|
|||
return shim + doc;
|
||||
}
|
||||
|
||||
function injectCommentBridge(doc: string): string {
|
||||
const script = `<script data-od-comment-bridge>(function(){
|
||||
var enabled = true;
|
||||
// Selection bridge: shared substrate for Comment mode and Inspect mode.
|
||||
// Both modes pick a [data-od-id] / [data-screen-label] element on click;
|
||||
// the difference is what the host does with the selection — annotate
|
||||
// (Comment) or live-tune basic styles (Inspect).
|
||||
//
|
||||
// Inspect adds four messages on top of the comment protocol:
|
||||
// in: { type: 'od:inspect-set', elementId, selector, prop, value }
|
||||
// Apply (or unset, when value === '') a per-element CSS override.
|
||||
// in: { type: 'od:inspect-reset', elementId? } Clear overrides for one
|
||||
// element, or all if elementId is omitted.
|
||||
// in: { type: 'od:inspect-extract' } Reply with the cumulative
|
||||
// override map so the host can persist to source.
|
||||
// in: { type: 'od:inspect-replay', overrides } Replace the in-memory
|
||||
// override map with the host's authoritative set so the iframe
|
||||
// preview matches host state after every srcdoc rebuild. Without
|
||||
// this the bridge re-hydrates only the persisted <style> block on
|
||||
// load, so any unsaved edit the host still holds disappears from
|
||||
// the preview while saveInspectToSource() can later commit CSS the
|
||||
// user is no longer seeing. Re-validates every entry under the
|
||||
// same allow-list / value sanitizer applied to od:inspect-set.
|
||||
// out: { type: 'od:inspect-overrides', overrides } The current snapshot,
|
||||
// sent in reply to extract and after every set/reset/replay. The
|
||||
// host re-derives the persisted CSS body from the structured map
|
||||
// under its own allow-list — the bridge's own stylesheet text is
|
||||
// NOT included in this message because artifact JS can forge a
|
||||
// same-source od:inspect-overrides containing a hostile `css`.
|
||||
//
|
||||
// Overrides are written into a single <style data-od-inspect-overrides>
|
||||
// block in <head>, with `!important` on every property so the bridge
|
||||
// can defeat author inline styles (common in agent-generated HTML).
|
||||
//
|
||||
// Security: this bridge runs inside a sandboxed iframe but still shares the
|
||||
// host page context for the override <style> element. The message listener
|
||||
// does NOT validate ev.origin — the web app runs on configurable ports and
|
||||
// preview domains, so the host origin is not stable. The bridge therefore
|
||||
// trusts any parent that can postMessage to it and relies on iframe
|
||||
// sandboxing + the prop allow-list / value sanitization below to contain
|
||||
// damage. Any parent able to postMessage here can already mount the iframe.
|
||||
function injectSelectionBridge(
|
||||
doc: string,
|
||||
options: { initialCommentMode?: boolean; initialInspectMode?: boolean } = {},
|
||||
): string {
|
||||
const initialComment = options.initialCommentMode ? 'true' : 'false';
|
||||
const initialInspect = options.initialInspectMode ? 'true' : 'false';
|
||||
const script = `<script data-od-selection-bridge>(function(){
|
||||
var commentEnabled = ${initialComment};
|
||||
var inspectEnabled = ${initialInspect};
|
||||
// Comment mode has two sub-tools (kept on the host side as boardTool):
|
||||
// 'picker' — click-to-select an element for annotation.
|
||||
// 'pod' — pointer-drag a freeform stroke that the host turns into a
|
||||
// pod selection covering whatever the stroke encloses.
|
||||
// Inspect mode always uses 'picker'-style click selection regardless of
|
||||
// this value.
|
||||
var mode = 'picker';
|
||||
var hoveredId = null;
|
||||
var selectableCache = null;
|
||||
var drawing = false;
|
||||
var stroke = [];
|
||||
var postTargetsTimer = null;
|
||||
var MAX_TARGETS = 400;
|
||||
// overrides[elementId] = { selector: '[data-od-id="x"]', props: { color: '#fff', ... } }
|
||||
var overrides = Object.create(null);
|
||||
var styleEl = null;
|
||||
// Allow-list of CSS properties the host may override. A malicious parent
|
||||
// could otherwise smuggle arbitrary CSS (or, with </style>, raw HTML)
|
||||
// through od:inspect-set. Keep this in sync with the InspectPanel UI.
|
||||
var ALLOWED_PROPS = {
|
||||
'color': true,
|
||||
'background-color': true,
|
||||
'font-size': true,
|
||||
'font-weight': true,
|
||||
'font-family': true,
|
||||
'line-height': true,
|
||||
'text-align': true,
|
||||
'padding': true,
|
||||
'padding-top': true,
|
||||
'padding-right': true,
|
||||
'padding-bottom': true,
|
||||
'padding-left': true,
|
||||
'border-radius': true
|
||||
};
|
||||
// Reject any value that could break out of a 'prop: value' declaration:
|
||||
// semicolons (extra declarations), braces (close the rule), angle
|
||||
// brackets (close the <style> tag), and newlines (defense in depth).
|
||||
var UNSAFE_VALUE = /[;{}<>\\n\\r]/;
|
||||
function active(){ return commentEnabled || inspectEnabled; }
|
||||
function esc(value){ try { return window.CSS && CSS.escape ? CSS.escape(value) : String(value).replace(/"/g, '\\\\"'); } catch (_) { return String(value); } }
|
||||
function isSelectableElement(el){
|
||||
if (!el || !el.tagName) return false;
|
||||
var tag = el.tagName.toLowerCase();
|
||||
if (tag === 'html' || tag === 'body' || tag === 'head' || tag === 'script' || tag === 'style' || tag === 'meta' || tag === 'link' || tag === 'title') return false;
|
||||
var rect = el.getBoundingClientRect();
|
||||
if (!rect || rect.width < 2 || rect.height < 2) return false;
|
||||
var style = window.getComputedStyle ? window.getComputedStyle(el) : null;
|
||||
if (style && (style.display === 'none' || style.visibility === 'hidden' || style.pointerEvents === 'none')) return false;
|
||||
return true;
|
||||
}
|
||||
function meaningfulText(el){
|
||||
return (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 160);
|
||||
}
|
||||
function shortLabel(el){
|
||||
var tag = el.tagName ? el.tagName.toLowerCase() : 'element';
|
||||
var id = typeof el.id === 'string' && el.id.trim() ? '#' + el.id.trim() : '';
|
||||
var cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
|
||||
return tag + id + cls;
|
||||
}
|
||||
function cssPath(el){
|
||||
if (el.hasAttribute && el.hasAttribute('data-od-id')) {
|
||||
return '[data-od-id="' + esc(el.getAttribute('data-od-id')) + '"]';
|
||||
// Recompute the selector from elementId rather than trusting the one in
|
||||
// the inbound message — a forged selector like
|
||||
// '} </style><script>...' would otherwise be concatenated into the
|
||||
// override <style> sheet verbatim. The hint string is only inspected to
|
||||
// decide which attribute kind (data-od-id vs data-screen-label) was the
|
||||
// user's pick at click time, so we tune the same node the host
|
||||
// serializer keys off; the hint itself is never written into CSS.
|
||||
function safeSelectorFor(elementId, hint){
|
||||
var id = String(elementId);
|
||||
var kind = null;
|
||||
if (typeof hint === 'string') {
|
||||
if (hint.indexOf('[data-od-id=') === 0) kind = 'data-od-id';
|
||||
else if (hint.indexOf('[data-screen-label=') === 0) kind = 'data-screen-label';
|
||||
}
|
||||
if (el.id) return '#' + esc(el.id);
|
||||
var parts = [];
|
||||
var node = el;
|
||||
while (node && node.nodeType === 1 && node !== document.body && parts.length < 6) {
|
||||
var part = node.tagName.toLowerCase();
|
||||
var cls = typeof node.className === 'string' && node.className.trim()
|
||||
? node.className
|
||||
.trim()
|
||||
.split(/\\s+/)
|
||||
.slice(0, 2)
|
||||
.map(function(token){ return esc(token); })
|
||||
.join('.')
|
||||
: '';
|
||||
if (cls) part += '.' + cls;
|
||||
var index = 1;
|
||||
var sib = node;
|
||||
while ((sib = sib.previousElementSibling)) {
|
||||
if (sib.tagName === node.tagName) index++;
|
||||
if (kind === 'data-screen-label' && document.querySelector('[data-screen-label="' + esc(id) + '"]')) {
|
||||
return '[data-screen-label="' + esc(id) + '"]';
|
||||
}
|
||||
if (kind === 'data-od-id' && document.querySelector('[data-od-id="' + esc(id) + '"]')) {
|
||||
return '[data-od-id="' + esc(id) + '"]';
|
||||
}
|
||||
if (document.querySelector('[data-od-id="' + esc(id) + '"]')) {
|
||||
return '[data-od-id="' + esc(id) + '"]';
|
||||
}
|
||||
if (document.querySelector('[data-screen-label="' + esc(id) + '"]')) {
|
||||
return '[data-screen-label="' + esc(id) + '"]';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function ensureStyleEl(){
|
||||
if (styleEl && styleEl.isConnected) return styleEl;
|
||||
styleEl = document.querySelector('style[data-od-inspect-overrides]');
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.setAttribute('data-od-inspect-overrides', '');
|
||||
(document.head || document.documentElement).appendChild(styleEl);
|
||||
}
|
||||
return styleEl;
|
||||
}
|
||||
// Hydrate the in-memory override map from any persisted
|
||||
// <style data-od-inspect-overrides> block already in the document.
|
||||
// Without this, the first od:inspect-set rebuilds the sheet from an
|
||||
// empty map and silently drops every previously saved rule for other
|
||||
// elements — a subsequent Save-to-source would then erase them from
|
||||
// the artifact too.
|
||||
function hydrateOverridesFromDom(){
|
||||
var existing = document.querySelector('style[data-od-inspect-overrides]');
|
||||
if (!existing) return;
|
||||
var text = existing.textContent || '';
|
||||
var ruleRe = /(\\[data-(?:od-id|screen-label)="[^"]*"\\])\\s*\\{\\s*([^}]*)\\}/g;
|
||||
var match;
|
||||
while ((match = ruleRe.exec(text)) !== null) {
|
||||
var selector = match[1];
|
||||
var declBody = match[2];
|
||||
var idMatch = selector.match(/="([^"]*)"/);
|
||||
if (!idMatch) continue;
|
||||
var elementId = idMatch[1];
|
||||
var props = Object.create(null);
|
||||
var decls = declBody.split(';');
|
||||
for (var d = 0; d < decls.length; d++) {
|
||||
var raw = decls[d];
|
||||
if (!raw) continue;
|
||||
var colon = raw.indexOf(':');
|
||||
if (colon <= 0) continue;
|
||||
var name = raw.slice(0, colon).trim().toLowerCase();
|
||||
if (!Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, name)) continue;
|
||||
var value = raw.slice(colon + 1).replace(/!important/i, '').trim();
|
||||
if (!value || UNSAFE_VALUE.test(value)) continue;
|
||||
props[name] = value;
|
||||
}
|
||||
part += ':nth-of-type(' + index + ')';
|
||||
parts.unshift(part);
|
||||
node = node.parentElement;
|
||||
if (node && node.id) {
|
||||
parts.unshift('#' + esc(node.id));
|
||||
break;
|
||||
if (Object.keys(props).length) {
|
||||
overrides[elementId] = { selector: selector, props: props };
|
||||
}
|
||||
}
|
||||
return parts.join(' > ') || shortLabel(el);
|
||||
styleEl = existing;
|
||||
}
|
||||
function stableElementId(el){
|
||||
if (el.hasAttribute && el.hasAttribute('data-od-id')) {
|
||||
return el.getAttribute('data-od-id');
|
||||
}
|
||||
if (el.id) return el.id;
|
||||
return cssPath(el);
|
||||
function rebuildStyleSheet(){
|
||||
var el = ensureStyleEl();
|
||||
var lines = [];
|
||||
Object.keys(overrides).forEach(function(id){
|
||||
var entry = overrides[id];
|
||||
if (!entry) return;
|
||||
var props = entry.props || {};
|
||||
var keys = Object.keys(props);
|
||||
if (!keys.length) return;
|
||||
var body = keys.map(function(k){ return k + ': ' + props[k] + ' !important'; }).join('; ');
|
||||
lines.push(entry.selector + ' { ' + body + ' }');
|
||||
});
|
||||
el.textContent = lines.join('\\n');
|
||||
}
|
||||
function postOverrides(){
|
||||
var clean = {};
|
||||
Object.keys(overrides).forEach(function(id){
|
||||
var entry = overrides[id];
|
||||
if (entry && entry.props && Object.keys(entry.props).length) {
|
||||
clean[id] = { selector: entry.selector, props: Object.assign({}, entry.props) };
|
||||
}
|
||||
});
|
||||
// Intentionally do NOT include a css string here. Artifact code
|
||||
// running inside this iframe shares window.parent and could forge
|
||||
// od:inspect-overrides with a hostile css (e.g. </style><script>...).
|
||||
// The host re-derives CSS from the structured overrides map under
|
||||
// its own allow-list, so any stray css field on the wire would only
|
||||
// be a false-trust trap.
|
||||
try { window.parent.postMessage({ type: 'od:inspect-overrides', overrides: clean }, '*'); } catch (_) {}
|
||||
}
|
||||
function styleSnapshot(el){
|
||||
try {
|
||||
var cs = window.getComputedStyle(el);
|
||||
return {
|
||||
color: cs.color,
|
||||
backgroundColor: cs.backgroundColor,
|
||||
fontSize: cs.fontSize,
|
||||
fontWeight: cs.fontWeight,
|
||||
lineHeight: cs.lineHeight,
|
||||
paddingTop: cs.paddingTop,
|
||||
paddingRight: cs.paddingRight,
|
||||
paddingBottom: cs.paddingBottom,
|
||||
paddingLeft: cs.paddingLeft,
|
||||
borderRadius: cs.borderTopLeftRadius,
|
||||
textAlign: cs.textAlign,
|
||||
fontFamily: cs.fontFamily
|
||||
};
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
function targetFrom(el){
|
||||
if (!isSelectableElement(el)) return null;
|
||||
var id = stableElementId(el);
|
||||
var id = el.getAttribute('data-od-id') || el.getAttribute('data-screen-label');
|
||||
if (!id) return null;
|
||||
var rect = el.getBoundingClientRect();
|
||||
var tag = el.tagName ? el.tagName.toLowerCase() : 'element';
|
||||
var cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
|
||||
var html = '';
|
||||
try { html = (el.outerHTML || '').replace(/\\s+/g, ' ').match(/^<[^>]+>/)?.[0] || ''; } catch (_) {}
|
||||
return {
|
||||
type: 'od:comment-target',
|
||||
elementId: id,
|
||||
selector: cssPath(el),
|
||||
label: shortLabel(el),
|
||||
text: meaningfulText(el),
|
||||
selector: el.hasAttribute('data-od-id') ? '[data-od-id="' + esc(id) + '"]' : '[data-screen-label="' + esc(id) + '"]',
|
||||
label: tag + cls,
|
||||
text: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 160),
|
||||
position: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
||||
htmlHint: html.slice(0, 180)
|
||||
htmlHint: html.slice(0, 180),
|
||||
style: styleSnapshot(el)
|
||||
};
|
||||
}
|
||||
function relativePoint(ev){
|
||||
return { x: Math.round(ev.clientX), y: Math.round(ev.clientY) };
|
||||
}
|
||||
function postStroke(type){
|
||||
window.parent.postMessage({ type: type, points: stroke.slice() }, '*');
|
||||
}
|
||||
function allTargets(){
|
||||
if (selectableCache) return selectableCache;
|
||||
var nodes = document.body ? document.body.querySelectorAll('*') : [];
|
||||
var nodes = document.querySelectorAll('[data-od-id], [data-screen-label]');
|
||||
var items = [];
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var item = targetFrom(nodes[i]);
|
||||
if (item) items.push(item);
|
||||
if (items.length >= MAX_TARGETS) break;
|
||||
}
|
||||
selectableCache = items;
|
||||
return selectableCache;
|
||||
return items;
|
||||
}
|
||||
var postTargetsPending = false;
|
||||
function postTargets(){
|
||||
if (!enabled) return;
|
||||
selectableCache = null;
|
||||
if (!active()) return;
|
||||
window.parent.postMessage({ type: 'od:comment-targets', targets: allTargets() }, '*');
|
||||
}
|
||||
function schedulePostTargets(){
|
||||
if (!enabled || postTargetsPending) return;
|
||||
if (!active() || postTargetsPending) return;
|
||||
postTargetsPending = true;
|
||||
if (postTargetsTimer) window.clearTimeout(postTargetsTimer);
|
||||
postTargetsTimer = window.setTimeout(function(){
|
||||
|
|
@ -283,30 +429,126 @@ function injectCommentBridge(doc: string): string {
|
|||
});
|
||||
}, 120);
|
||||
}
|
||||
function relativePoint(ev){
|
||||
return { x: Math.round(ev.clientX), y: Math.round(ev.clientY) };
|
||||
}
|
||||
function postStroke(type){
|
||||
window.parent.postMessage({ type: type, points: stroke.slice() }, '*');
|
||||
}
|
||||
function closestTarget(event){
|
||||
var el = event.target && event.target.nodeType === 3 ? event.target.parentElement : event.target;
|
||||
var el = event.target;
|
||||
while (el && el !== document.documentElement) {
|
||||
if (isSelectableElement(el)) return el;
|
||||
if (el.getAttribute && (el.hasAttribute('data-od-id') || el.hasAttribute('data-screen-label'))) return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function selectorFor(el){
|
||||
var id = el.getAttribute('data-od-id') || el.getAttribute('data-screen-label');
|
||||
if (!id) return null;
|
||||
return el.hasAttribute('data-od-id') ? '[data-od-id="' + esc(id) + '"]' : '[data-screen-label="' + esc(id) + '"]';
|
||||
}
|
||||
function applyOverride(elementId, selector, prop, value){
|
||||
if (!elementId || !prop) return;
|
||||
if (!Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, prop)) return;
|
||||
var safeSelector = safeSelectorFor(elementId, selector);
|
||||
if (!safeSelector) return;
|
||||
var v = (value == null) ? '' : String(value).trim();
|
||||
if (v && UNSAFE_VALUE.test(v)) return;
|
||||
var entry = overrides[elementId];
|
||||
if (!entry) {
|
||||
entry = { selector: safeSelector, props: Object.create(null) };
|
||||
overrides[elementId] = entry;
|
||||
} else {
|
||||
entry.selector = safeSelector;
|
||||
}
|
||||
if (!v) delete entry.props[prop];
|
||||
else entry.props[prop] = v;
|
||||
if (Object.keys(entry.props).length === 0) delete overrides[elementId];
|
||||
rebuildStyleSheet();
|
||||
postOverrides();
|
||||
}
|
||||
function resetOverrides(elementId){
|
||||
if (elementId) delete overrides[elementId];
|
||||
else overrides = Object.create(null);
|
||||
rebuildStyleSheet();
|
||||
postOverrides();
|
||||
}
|
||||
window.addEventListener('message', function(ev){
|
||||
if (!ev.data || ev.data.type !== 'od:comment-mode') return;
|
||||
enabled = !!ev.data.enabled;
|
||||
mode = ev.data.mode === 'pod' ? 'pod' : 'picker';
|
||||
document.documentElement.toggleAttribute('data-od-comment-mode', enabled);
|
||||
document.documentElement.setAttribute('data-od-comment-mode-kind', mode);
|
||||
if (enabled) setTimeout(postTargets, 0);
|
||||
else hoveredId = null;
|
||||
if (!enabled || mode !== 'pod') {
|
||||
drawing = false;
|
||||
stroke = [];
|
||||
window.parent.postMessage({ type: 'od:pod-clear' }, '*');
|
||||
var data = ev && ev.data;
|
||||
if (!data || !data.type) return;
|
||||
if (data.type === 'od:comment-mode') {
|
||||
commentEnabled = !!data.enabled;
|
||||
mode = data.mode === 'pod' ? 'pod' : 'picker';
|
||||
document.documentElement.toggleAttribute('data-od-comment-mode', commentEnabled);
|
||||
document.documentElement.setAttribute('data-od-comment-mode-kind', mode);
|
||||
if (active()) setTimeout(postTargets, 0);
|
||||
else hoveredId = null;
|
||||
if (!commentEnabled || mode !== 'pod') {
|
||||
drawing = false;
|
||||
stroke = [];
|
||||
try { window.parent.postMessage({ type: 'od:pod-clear' }, '*'); } catch (_) {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.type === 'od:inspect-mode') {
|
||||
inspectEnabled = !!data.enabled;
|
||||
document.documentElement.toggleAttribute('data-od-inspect-mode', inspectEnabled);
|
||||
if (active()) setTimeout(postTargets, 0);
|
||||
else hoveredId = null;
|
||||
return;
|
||||
}
|
||||
if (data.type === 'od:inspect-set') {
|
||||
applyOverride(data.elementId, data.selector, data.prop, data.value);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'od:inspect-reset') {
|
||||
resetOverrides(data.elementId);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'od:inspect-extract') {
|
||||
postOverrides();
|
||||
return;
|
||||
}
|
||||
if (data.type === 'od:inspect-replay') {
|
||||
// Replace the in-memory map with the host's authoritative set so
|
||||
// unsaved edits survive a srcdoc rebuild (toggling inspect off/on,
|
||||
// switching to comment, any other reload reloads the iframe from
|
||||
// previewSource without the unsaved style block). Re-validate every
|
||||
// entry: a parent able to postMessage to this bridge is otherwise
|
||||
// trusted, but applying its payload through the same allow-list /
|
||||
// value sanitizer keeps the override sheet under the bridge's own
|
||||
// contract instead of whatever the parent sent.
|
||||
var raw = (data && typeof data.overrides === 'object' && data.overrides) ? data.overrides : {};
|
||||
overrides = Object.create(null);
|
||||
var ids = Object.keys(raw);
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var id = ids[i];
|
||||
var entry = raw[id];
|
||||
if (!entry || typeof entry.props !== 'object' || !entry.props) continue;
|
||||
var safeSelector = safeSelectorFor(id, entry.selector);
|
||||
if (!safeSelector) continue;
|
||||
var clean = Object.create(null);
|
||||
var pkeys = Object.keys(entry.props);
|
||||
for (var p = 0; p < pkeys.length; p++) {
|
||||
var name = String(pkeys[p]).toLowerCase();
|
||||
if (!Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, name)) continue;
|
||||
var rawValue = entry.props[pkeys[p]];
|
||||
if (rawValue == null) continue;
|
||||
var v = String(rawValue).trim();
|
||||
if (!v || UNSAFE_VALUE.test(v)) continue;
|
||||
clean[name] = v;
|
||||
}
|
||||
if (Object.keys(clean).length) overrides[id] = { selector: safeSelector, props: clean };
|
||||
}
|
||||
rebuildStyleSheet();
|
||||
postOverrides();
|
||||
return;
|
||||
}
|
||||
});
|
||||
function pickerActive(){ return inspectEnabled || (commentEnabled && mode === 'picker'); }
|
||||
document.addEventListener('mouseover', function(ev){
|
||||
if (!enabled || mode !== 'picker') return;
|
||||
if (!pickerActive()) return;
|
||||
var el = closestTarget(ev);
|
||||
if (!el) return;
|
||||
var payload = targetFrom(el);
|
||||
|
|
@ -315,7 +557,7 @@ function injectCommentBridge(doc: string): string {
|
|||
window.parent.postMessage(Object.assign({}, payload, { type: 'od:comment-hover' }), '*');
|
||||
}, true);
|
||||
document.addEventListener('mouseout', function(ev){
|
||||
if (!enabled || mode !== 'picker') return;
|
||||
if (!pickerActive()) return;
|
||||
var el = closestTarget(ev);
|
||||
if (!el) return;
|
||||
var next = ev.relatedTarget;
|
||||
|
|
@ -327,7 +569,7 @@ function injectCommentBridge(doc: string): string {
|
|||
window.parent.postMessage({ type: 'od:comment-leave' }, '*');
|
||||
}, true);
|
||||
document.addEventListener('click', function(ev){
|
||||
if (!enabled || mode !== 'picker') return;
|
||||
if (!pickerActive()) return;
|
||||
var el = closestTarget(ev);
|
||||
if (!el) return;
|
||||
ev.preventDefault();
|
||||
|
|
@ -335,8 +577,9 @@ function injectCommentBridge(doc: string): string {
|
|||
var payload = targetFrom(el);
|
||||
if (payload) window.parent.postMessage(payload, '*');
|
||||
}, true);
|
||||
// Pod drawing — only active in comment mode with the 'pod' tool.
|
||||
document.addEventListener('pointerdown', function(ev){
|
||||
if (!enabled || mode !== 'pod' || ev.button !== 0) return;
|
||||
if (!commentEnabled || mode !== 'pod' || ev.button !== 0) return;
|
||||
drawing = true;
|
||||
stroke = [relativePoint(ev)];
|
||||
ev.preventDefault();
|
||||
|
|
@ -368,11 +611,24 @@ function injectCommentBridge(doc: string): string {
|
|||
document.addEventListener('scroll', schedulePostTargets, true);
|
||||
var mo = new MutationObserver(schedulePostTargets);
|
||||
mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
||||
// Reflect the host-requested initial modes on the documentElement so
|
||||
// the cursor/hover styles match what the bridge picks up on click.
|
||||
if (commentEnabled) document.documentElement.toggleAttribute('data-od-comment-mode', true);
|
||||
if (inspectEnabled) document.documentElement.toggleAttribute('data-od-inspect-mode', true);
|
||||
document.documentElement.setAttribute('data-od-comment-mode-kind', mode);
|
||||
hydrateOverridesFromDom();
|
||||
// Acknowledge the hydrated overrides to the host as a preview signal so
|
||||
// diagnostic listeners (and tests) can observe that the bridge is in sync
|
||||
// with the persisted style sheet. The host no longer treats this message
|
||||
// as save input — it parses the artifact source itself — but emitting it
|
||||
// keeps the iframe → host channel symmetric across set/reset/extract.
|
||||
if (Object.keys(overrides).length) setTimeout(postOverrides, 0);
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postTargets);
|
||||
else setTimeout(postTargets, 0);
|
||||
})();</script>`;
|
||||
const style = `<style data-od-comment-bridge-style>
|
||||
const style = `<style data-od-selection-bridge-style>
|
||||
html[data-od-comment-mode] body * { cursor: crosshair !important; }
|
||||
html[data-od-inspect-mode] body * { cursor: crosshair !important; }
|
||||
html[data-od-comment-mode][data-od-comment-mode-kind="pod"] body * { cursor: cell !important; }
|
||||
</style>`;
|
||||
const withStyle = /<\/head>/i.test(doc)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ import {
|
|||
FileViewer,
|
||||
LiveArtifactRefreshHistoryPanel,
|
||||
SvgViewer,
|
||||
applyInspectOverridesToSource,
|
||||
parseInspectOverridesFromSource,
|
||||
serializeInspectOverrides,
|
||||
updateInspectOverride,
|
||||
} from '../../src/components/FileViewer';
|
||||
import type { InspectOverrideMap } from '../../src/components/FileViewer';
|
||||
import type { LiveArtifact, ProjectFile } from '../../src/types';
|
||||
|
||||
function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
|
||||
|
|
@ -261,6 +266,510 @@ describe('FileViewer SVG artifacts', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('applyInspectOverridesToSource', () => {
|
||||
const base = `<!doctype html><html><head><title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
|
||||
const css = `[data-od-id="hero"] { color: #ff0000 !important }`;
|
||||
|
||||
it('inserts the overrides block before </head>', () => {
|
||||
const next = applyInspectOverridesToSource(base, css);
|
||||
expect(next).toContain('<style data-od-inspect-overrides>');
|
||||
expect(next).toContain('color: #ff0000');
|
||||
expect(next.indexOf('<style data-od-inspect-overrides>')).toBeLessThan(next.indexOf('</head>'));
|
||||
});
|
||||
|
||||
it('replaces an existing overrides block instead of duplicating', () => {
|
||||
const once = applyInspectOverridesToSource(base, css);
|
||||
const twice = applyInspectOverridesToSource(once, `[data-od-id="hero"] { color: #00ff00 !important }`);
|
||||
const matches = twice.match(/<style data-od-inspect-overrides>/g) ?? [];
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(twice).toContain('color: #00ff00');
|
||||
expect(twice).not.toContain('color: #ff0000');
|
||||
});
|
||||
|
||||
it('strips the overrides block when called with empty css', () => {
|
||||
const once = applyInspectOverridesToSource(base, css);
|
||||
const stripped = applyInspectOverridesToSource(once, '');
|
||||
expect(stripped).not.toContain('data-od-inspect-overrides');
|
||||
});
|
||||
|
||||
it('handles fragments without an explicit <head>', () => {
|
||||
const next = applyInspectOverridesToSource('<main data-od-id="x">x</main>', css);
|
||||
expect(next).toContain('<style data-od-inspect-overrides>');
|
||||
expect(next.indexOf('<style data-od-inspect-overrides>')).toBeLessThan(next.indexOf('<main'));
|
||||
});
|
||||
|
||||
// Regression for nexu-io/open-design#362: if a source file has more than
|
||||
// one inspect override block (manual edit, or an earlier buggy save), the
|
||||
// splicer must drop them all before inserting the new block. A non-global
|
||||
// regex would only strip the first, so save-then-reload could resurrect an
|
||||
// override the user just cleared.
|
||||
it('removes every existing overrides block, not just the first', () => {
|
||||
const dup = `<!doctype html><html><head>` +
|
||||
`<style data-od-inspect-overrides>[data-od-id="hero"] { color: #ff0000 !important }</style>` +
|
||||
`<style data-od-inspect-overrides>[data-od-id="hero"] { color: #00ff00 !important }</style>` +
|
||||
`<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
|
||||
const replaced = applyInspectOverridesToSource(dup, `[data-od-id="hero"] { color: #0000ff !important }`);
|
||||
const matches = replaced.match(/<style data-od-inspect-overrides>/g) ?? [];
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(replaced).toContain('color: #0000ff');
|
||||
expect(replaced).not.toContain('color: #ff0000');
|
||||
expect(replaced).not.toContain('color: #00ff00');
|
||||
|
||||
const cleared = applyInspectOverridesToSource(dup, '');
|
||||
expect(cleared).not.toContain('data-od-inspect-overrides');
|
||||
});
|
||||
|
||||
// Regression for nexu-io/open-design#362: the splicer must be HTML-aware
|
||||
// when locating its own override block and the head insertion point.
|
||||
// Generated artifacts commonly carry inline scripts/styles that mention
|
||||
// `</head>` or `<style data-od-inspect-overrides>` as text, e.g. a
|
||||
// template literal that builds HTML at runtime or a CSS rule that
|
||||
// documents the override block. A regex-only splicer would happily
|
||||
// splice into the middle of the script body or strip the literal string,
|
||||
// corrupting user code on Save to source.
|
||||
it('ignores </head> literals inside inline <script> and <style>', () => {
|
||||
const sourceWithLiteral =
|
||||
`<!doctype html><html><head>` +
|
||||
// Script body contains a quoted "</head>" string that must NOT be
|
||||
// treated as the real head close.
|
||||
`<script>const tpl = "<head>\\n</head>";</script>` +
|
||||
`<style>/* sentinel: </head> appears in this CSS comment */</style>` +
|
||||
`<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
|
||||
const next = applyInspectOverridesToSource(sourceWithLiteral, css);
|
||||
// The override block must land exactly once, before the real </head>,
|
||||
// and after the inline <script> and <style> that contain `</head>`
|
||||
// text. Without HTML-aware scanning the regex would splice before the
|
||||
// first textual `</head>`, which sits inside the script body.
|
||||
const blockIdx = next.indexOf('<style data-od-inspect-overrides>');
|
||||
const realHeadEndIdx = next.indexOf('</head>', next.indexOf('<title>'));
|
||||
const scriptOpenIdx = next.indexOf('<script>');
|
||||
const scriptCloseIdx = next.indexOf('</script>');
|
||||
expect(blockIdx).toBeGreaterThan(-1);
|
||||
expect(realHeadEndIdx).toBeGreaterThan(-1);
|
||||
expect(scriptOpenIdx).toBeGreaterThan(-1);
|
||||
expect(scriptCloseIdx).toBeGreaterThan(-1);
|
||||
// Override block sits BEFORE the real </head>, AFTER the script body.
|
||||
expect(blockIdx).toBeLessThan(realHeadEndIdx);
|
||||
expect(blockIdx).toBeGreaterThan(scriptCloseIdx);
|
||||
// The script's `</head>` literal still survives in the output —
|
||||
// the splicer must not have hijacked it as the head insertion point.
|
||||
expect(next).toContain('const tpl = "<head>\\n</head>";');
|
||||
// The CSS comment's `</head>` token also survives untouched.
|
||||
expect(next).toContain('/* sentinel: </head> appears in this CSS comment */');
|
||||
// Only one override block in total.
|
||||
const blockMatches = next.match(/<style data-od-inspect-overrides>/g) ?? [];
|
||||
expect(blockMatches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores `<style data-od-inspect-overrides>` literals inside <script>', () => {
|
||||
// A sentinel string literal in an inline script that mentions the
|
||||
// override block by name. A regex-only splicer would strip the
|
||||
// literal as if it were a real block, mangling the script.
|
||||
const sourceWithLiteral =
|
||||
`<!doctype html><html><head>` +
|
||||
`<script>const banner = "<style data-od-inspect-overrides>color: red</style>";</script>` +
|
||||
`<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
|
||||
const next = applyInspectOverridesToSource(sourceWithLiteral, css);
|
||||
// The literal must survive verbatim inside the script body.
|
||||
expect(next).toContain('const banner = "<style data-od-inspect-overrides>color: red</style>";');
|
||||
// The output still gains exactly one real override block.
|
||||
const blockMatches = next.match(/<style data-od-inspect-overrides>\n\[data-od-id="hero"\]/g) ?? [];
|
||||
expect(blockMatches).toHaveLength(1);
|
||||
// Stripping with empty css must NOT touch the script literal.
|
||||
const stripped = applyInspectOverridesToSource(sourceWithLiteral, '');
|
||||
expect(stripped).toContain('const banner = "<style data-od-inspect-overrides>color: red</style>";');
|
||||
// The script-internal literal is the only mention of the marker after
|
||||
// stripping — the splicer must not have inserted or kept any real
|
||||
// override block.
|
||||
const allMatches = stripped.match(/data-od-inspect-overrides/g) ?? [];
|
||||
expect(allMatches).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Regression for nexu-io/open-design#362: the splicer must look at real
|
||||
// attribute names, not just substring-match the marker text against the
|
||||
// whole opening tag. A `\bdata-od-inspect-overrides\b` regex over the
|
||||
// full tag matches both a longer attribute name (`-note` suffix) and the
|
||||
// marker spelled inside another attribute's value, so a plain `<style>`
|
||||
// documenting the override block in a `title` tooltip or a sibling note
|
||||
// attribute would be mis-stripped on save and would have its inner CSS
|
||||
// mis-parsed as override rules on hydration.
|
||||
it('does not strip <style> blocks whose attribute name only PREFIXES the marker', () => {
|
||||
const css2 = `[data-od-id="hero"] { color: #00ffaa !important }`;
|
||||
const userBlock = `body { background: red !important }`;
|
||||
const sourceWithLongerName =
|
||||
`<!doctype html><html><head>` +
|
||||
// attribute is named data-od-inspect-overrides-note, NOT the marker.
|
||||
// The note shouldn't be treated as an Inspect-owned style block.
|
||||
`<style data-od-inspect-overrides-note="docs">${userBlock}</style>` +
|
||||
`<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
|
||||
const next = applyInspectOverridesToSource(sourceWithLongerName, css2);
|
||||
// The user's style with the longer attribute name must survive in the
|
||||
// output verbatim (with both the attribute and the body intact).
|
||||
expect(next).toContain('<style data-od-inspect-overrides-note="docs">');
|
||||
expect(next).toContain(userBlock);
|
||||
// Exactly one real override block lands before </head>.
|
||||
const blockMatches = next.match(/<style data-od-inspect-overrides>/g) ?? [];
|
||||
expect(blockMatches).toHaveLength(1);
|
||||
// Stripping with empty CSS still leaves the user's longer-name block
|
||||
// alone — there was no real override block to remove.
|
||||
const stripped = applyInspectOverridesToSource(sourceWithLongerName, '');
|
||||
expect(stripped).toContain('<style data-od-inspect-overrides-note="docs">');
|
||||
expect(stripped).toContain(userBlock);
|
||||
expect(stripped).not.toContain('<style data-od-inspect-overrides>');
|
||||
});
|
||||
|
||||
it('does not strip <style> blocks that only mention the marker inside an attribute value', () => {
|
||||
const css2 = `[data-od-id="hero"] { color: #00ffaa !important }`;
|
||||
const userBlock = `body { background: red !important }`;
|
||||
const sourceWithMarkerInValue =
|
||||
`<!doctype html><html><head>` +
|
||||
// The literal text data-od-inspect-overrides appears as an attribute
|
||||
// VALUE on a normal <style title="..."> — there is no real override
|
||||
// marker here, so the splicer must keep the block.
|
||||
`<style title="data-od-inspect-overrides">${userBlock}</style>` +
|
||||
`<title>X</title></head><body><main data-od-id="hero">Hi</main></body></html>`;
|
||||
const next = applyInspectOverridesToSource(sourceWithMarkerInValue, css2);
|
||||
expect(next).toContain('<style title="data-od-inspect-overrides">');
|
||||
expect(next).toContain(userBlock);
|
||||
const blockMatches = next.match(/<style data-od-inspect-overrides>/g) ?? [];
|
||||
expect(blockMatches).toHaveLength(1);
|
||||
const stripped = applyInspectOverridesToSource(sourceWithMarkerInValue, '');
|
||||
expect(stripped).toContain('<style title="data-od-inspect-overrides">');
|
||||
expect(stripped).toContain(userBlock);
|
||||
expect(stripped).not.toContain('<style data-od-inspect-overrides>');
|
||||
});
|
||||
|
||||
it('still strips a real <style data-od-inspect-overrides> block with assigned value', () => {
|
||||
// The marker is allowed both as a boolean attribute and with an
|
||||
// assigned value (`<style data-od-inspect-overrides="">`). The splicer
|
||||
// must treat both as the override block, not just the boolean shape.
|
||||
const sourceWithValuedMarker =
|
||||
`<!doctype html><html><head>` +
|
||||
`<style data-od-inspect-overrides="">` +
|
||||
`[data-od-id="hero"] { color: #ff0000 !important }` +
|
||||
`</style>` +
|
||||
`<title>X</title></head><body></body></html>`;
|
||||
const stripped = applyInspectOverridesToSource(sourceWithValuedMarker, '');
|
||||
expect(stripped).not.toContain('data-od-inspect-overrides');
|
||||
expect(stripped).not.toContain('color: #ff0000');
|
||||
});
|
||||
|
||||
it('ignores </head> inside <textarea> and <title> raw-text elements', () => {
|
||||
// <textarea> and <title> are escapable raw-text elements; their
|
||||
// contents are text, not markup, so a literal `</head>` inside them
|
||||
// must not be treated as a tag boundary.
|
||||
const sourceWithTextarea =
|
||||
`<!doctype html><html><head><title>Has </head> in title</title></head>` +
|
||||
`<body><textarea>literal </head> goes here</textarea>` +
|
||||
`<main data-od-id="hero">Hi</main></body></html>`;
|
||||
const next = applyInspectOverridesToSource(sourceWithTextarea, css);
|
||||
// Override block lands before the REAL </head>, which is after the
|
||||
// </title>'s close. The title-internal `</head>` must not be the
|
||||
// chosen insertion point.
|
||||
const blockIdx = next.indexOf('<style data-od-inspect-overrides>');
|
||||
const titleCloseIdx = next.indexOf('</title>');
|
||||
const realHeadCloseIdx = next.indexOf('</head>', titleCloseIdx);
|
||||
expect(blockIdx).toBeGreaterThan(titleCloseIdx);
|
||||
expect(blockIdx).toBeLessThan(realHeadCloseIdx);
|
||||
// Both literals survive untouched.
|
||||
expect(next).toContain('Has </head> in title');
|
||||
expect(next).toContain('literal </head> goes here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeInspectOverrides', () => {
|
||||
it('emits validated declarations for legitimate overrides', () => {
|
||||
const out = serializeInspectOverrides({
|
||||
hero: { selector: '[data-od-id="hero"]', props: { color: '#ff0000', 'font-size': '18px' } },
|
||||
});
|
||||
expect(out).toContain('[data-od-id="hero"]');
|
||||
expect(out).toContain('color: #ff0000 !important');
|
||||
expect(out).toContain('font-size: 18px !important');
|
||||
});
|
||||
|
||||
it('honours data-screen-label entries the bridge tagged that way', () => {
|
||||
const out = serializeInspectOverrides({
|
||||
hero: { selector: '[data-screen-label="hero"]', props: { color: '#0f0' } },
|
||||
});
|
||||
expect(out).toContain('[data-screen-label="hero"]');
|
||||
expect(out).not.toContain('[data-od-id="hero"]');
|
||||
});
|
||||
|
||||
// Regression for nexu-io/open-design#362: standard deck slides ship as
|
||||
// `<section data-screen-label="01 Cover">`. The bridge keys overrides by
|
||||
// the raw label and posts a CSS.escape'd selector, so the host must
|
||||
// accept whitespace/leading-digit ids and detect the selector kind by
|
||||
// prefix instead of full equality. Otherwise the override is dropped
|
||||
// outright (or silently rewritten to `[data-od-id="..."]`) and reload
|
||||
// erases the user's edit.
|
||||
it('preserves data-screen-label values with whitespace and leading digits', () => {
|
||||
const out = serializeInspectOverrides({
|
||||
'01 Cover': {
|
||||
selector: '[data-screen-label="\\30 1\\20 Cover"]',
|
||||
props: { color: '#ff0000', 'font-size': '20px' },
|
||||
},
|
||||
});
|
||||
expect(out).toContain('[data-screen-label="01 Cover"]');
|
||||
expect(out).not.toContain('[data-od-id="01 Cover"]');
|
||||
expect(out).toContain('color: #ff0000 !important');
|
||||
expect(out).toContain('font-size: 20px !important');
|
||||
});
|
||||
|
||||
it('rejects non-allow-listed properties', () => {
|
||||
const out = serializeInspectOverrides({
|
||||
hero: { selector: '[data-od-id="hero"]', props: { position: 'absolute', color: '#fff' } },
|
||||
});
|
||||
expect(out).not.toContain('position');
|
||||
expect(out).toContain('color: #fff !important');
|
||||
});
|
||||
|
||||
it('drops values that try to break out of a `prop: value` declaration', () => {
|
||||
const out = serializeInspectOverrides({
|
||||
hero: {
|
||||
selector: '[data-od-id="hero"]',
|
||||
// semicolon, brace, angle bracket, and newline are all rejected.
|
||||
props: {
|
||||
color: 'red; background: url(x)',
|
||||
'font-size': '16px } [body] { color: red',
|
||||
'font-family': 'Arial</style><script>alert(1)</script>',
|
||||
'line-height': '1\n.evil',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(out).toBe('');
|
||||
});
|
||||
|
||||
// The vulnerability we're regression-testing: artifact code rendered with
|
||||
// scripts enabled can call window.parent.postMessage({ type:
|
||||
// 'od:inspect-overrides', overrides, css: '</style><script>...</script>' })
|
||||
// — ev.source still matches iframe.contentWindow, so the host listener
|
||||
// accepts it. The fix is that the host re-derives CSS from the structured
|
||||
// `overrides` field under its own allow-list and ignores the inbound `css`
|
||||
// entirely. This test covers that the serializer never lets a forged
|
||||
// payload reach the persisted style block.
|
||||
it('refuses to surface a forged </style><script> payload', () => {
|
||||
const forged = {
|
||||
// Hostile selector string: re-derived from elementId, never trusted.
|
||||
hero: {
|
||||
selector: '} </style><script>alert(1)</script><style>{',
|
||||
props: { color: '#fff' },
|
||||
},
|
||||
// Hostile elementId: rejected outright by the safe-id check.
|
||||
'"></style><script>alert(2)</script>': {
|
||||
selector: '[data-od-id="x"]',
|
||||
props: { color: '#fff' },
|
||||
},
|
||||
// Hostile value: rejected by UNSAFE_VALUE.
|
||||
villain: {
|
||||
selector: '[data-od-id="villain"]',
|
||||
props: { color: '</style><script>alert(3)</script>' },
|
||||
},
|
||||
};
|
||||
const out = serializeInspectOverrides(forged);
|
||||
expect(out).not.toContain('</style>');
|
||||
expect(out).not.toContain('<script>');
|
||||
expect(out).not.toContain('alert(');
|
||||
// The legitimate-looking entry still serializes — but with a re-derived
|
||||
// selector, not the attacker-supplied one.
|
||||
expect(out).toContain('[data-od-id="hero"] { color: #fff !important }');
|
||||
expect(out).not.toContain('villain');
|
||||
|
||||
// And the spliced source must not contain executable markup either,
|
||||
// even when the forged body is concatenated into a <style> block.
|
||||
const spliced = applyInspectOverridesToSource(
|
||||
'<!doctype html><html><head></head><body></body></html>',
|
||||
out,
|
||||
);
|
||||
expect(spliced).not.toContain('</style><script>');
|
||||
expect(spliced).not.toContain('alert(');
|
||||
});
|
||||
|
||||
it('returns empty string for non-object payloads', () => {
|
||||
expect(serializeInspectOverrides(null)).toBe('');
|
||||
expect(serializeInspectOverrides(undefined)).toBe('');
|
||||
expect(serializeInspectOverrides('</style><script>alert(1)</script>')).toBe('');
|
||||
expect(serializeInspectOverrides(42)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for nexu-io/open-design#362: the host owns the inspect override
|
||||
// map authoritatively. Hydration parses the artifact source on load so an
|
||||
// initial Save-to-source preserves prior rules even when the user edits a
|
||||
// different element, and forging the iframe's od:inspect-overrides reply
|
||||
// cannot inject overrides — the host never ingests it.
|
||||
describe('parseInspectOverridesFromSource', () => {
|
||||
it('returns an empty map when the source has no override block', () => {
|
||||
expect(parseInspectOverridesFromSource('')).toEqual({});
|
||||
expect(parseInspectOverridesFromSource('<!doctype html><html><body>x</body></html>')).toEqual({});
|
||||
});
|
||||
|
||||
it('parses an existing override block into the host map', () => {
|
||||
const source =
|
||||
`<!doctype html><html><head>` +
|
||||
`<style data-od-inspect-overrides>` +
|
||||
`[data-od-id="hero"] { color: #ff0000 !important; font-size: 18px !important }` +
|
||||
`\n[data-screen-label="01 Cover"] { background-color: #000 !important }` +
|
||||
`</style></head><body></body></html>`;
|
||||
const map = parseInspectOverridesFromSource(source);
|
||||
expect(map.hero?.props).toEqual({ color: '#ff0000', 'font-size': '18px' });
|
||||
expect(map.hero?.selector).toBe('[data-od-id="hero"]');
|
||||
expect(map['01 Cover']?.props).toEqual({ 'background-color': '#000' });
|
||||
expect(map['01 Cover']?.selector).toBe('[data-screen-label="01 Cover"]');
|
||||
});
|
||||
|
||||
it('aggregates rules across multiple persisted blocks', () => {
|
||||
const source =
|
||||
`<style data-od-inspect-overrides>[data-od-id="a"] { color: #111 !important }</style>` +
|
||||
`<style data-od-inspect-overrides>[data-od-id="b"] { color: #222 !important }</style>`;
|
||||
const map = parseInspectOverridesFromSource(source);
|
||||
expect(Object.keys(map).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('drops disallowed properties and rules whose only declarations are unsafe', () => {
|
||||
const source =
|
||||
`<style data-od-inspect-overrides>` +
|
||||
`[data-od-id="hero"] { position: absolute !important; color: #fff !important }` +
|
||||
`[data-od-id="bad"] { background: red } ` +
|
||||
`</style>`;
|
||||
const map = parseInspectOverridesFromSource(source);
|
||||
expect(map.hero?.props).toEqual({ color: '#fff' });
|
||||
expect(map.bad).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses elementIds whose characters could break out of the attr value', () => {
|
||||
const hostile =
|
||||
`<style data-od-inspect-overrides>` +
|
||||
`[data-od-id="\"><script>alert(1)</script>"] { color: #fff !important }` +
|
||||
`</style>`;
|
||||
expect(parseInspectOverridesFromSource(hostile)).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores override-shaped text inside raw-text elements and HTML comments', () => {
|
||||
// A template literal in a <script>, a CSS comment in a sibling <style>, the
|
||||
// body of a <textarea> / <title>, and an HTML comment all contain text that
|
||||
// would match the override block regex. None of them are real persisted
|
||||
// overrides, so the host map must stay empty — otherwise useEffect would
|
||||
// seed phantom rules and a later Save-to-source would write CSS the user
|
||||
// never created.
|
||||
const phantomBlock =
|
||||
`<style data-od-inspect-overrides>` +
|
||||
`[data-od-id="hero"] { color: #ff0000 !important }` +
|
||||
`</style>`;
|
||||
const source =
|
||||
`<!doctype html><html><head>` +
|
||||
`<script>const tmpl = \`${phantomBlock}\`;</script>` +
|
||||
`<style>/* docs: ${phantomBlock} */</style>` +
|
||||
`<title>${phantomBlock}</title>` +
|
||||
`<!-- ${phantomBlock} -->` +
|
||||
`</head><body><textarea>${phantomBlock}</textarea></body></html>`;
|
||||
expect(parseInspectOverridesFromSource(source)).toEqual({});
|
||||
});
|
||||
|
||||
// Regression for nexu-io/open-design#362: hydration must require an
|
||||
// actual `data-od-inspect-overrides` attribute name, not a boundary-only
|
||||
// substring match against the whole opening tag. Otherwise a sibling
|
||||
// attribute name with `-note` suffix or a tooltip whose value contains
|
||||
// the marker text would seed phantom overrides into the host map and
|
||||
// a later Save-to-source would persist CSS the artifact never had.
|
||||
it('does not seed phantom overrides from a longer attribute name', () => {
|
||||
const source =
|
||||
`<!doctype html><html><head>` +
|
||||
`<style data-od-inspect-overrides-note="docs">` +
|
||||
`[data-od-id="hero"] { color: #ff0000 !important }` +
|
||||
`</style></head><body></body></html>`;
|
||||
expect(parseInspectOverridesFromSource(source)).toEqual({});
|
||||
});
|
||||
|
||||
it('does not seed phantom overrides when the marker text only appears in an attribute value', () => {
|
||||
const source =
|
||||
`<!doctype html><html><head>` +
|
||||
`<style title="data-od-inspect-overrides">` +
|
||||
`[data-od-id="hero"] { color: #ff0000 !important }` +
|
||||
`</style></head><body></body></html>`;
|
||||
expect(parseInspectOverridesFromSource(source)).toEqual({});
|
||||
});
|
||||
|
||||
it('still parses a real override block when raw-text literals also mention one', () => {
|
||||
const phantomBlock =
|
||||
`<style data-od-inspect-overrides>` +
|
||||
`[data-od-id="phantom"] { color: #ff0000 !important }` +
|
||||
`</style>`;
|
||||
const source =
|
||||
`<!doctype html><html><head>` +
|
||||
`<script>const tmpl = \`${phantomBlock}\`;</script>` +
|
||||
`<style data-od-inspect-overrides>` +
|
||||
`[data-od-id="hero"] { color: #00ff00 !important }` +
|
||||
`</style>` +
|
||||
`</head><body></body></html>`;
|
||||
const map = parseInspectOverridesFromSource(source);
|
||||
expect(Object.keys(map)).toEqual(['hero']);
|
||||
expect(map.hero?.props).toEqual({ color: '#00ff00' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateInspectOverride', () => {
|
||||
const base: InspectOverrideMap = {
|
||||
hero: { selector: '[data-od-id="hero"]', props: { color: '#ff0000' } },
|
||||
};
|
||||
|
||||
it('adds a new property to an existing entry', () => {
|
||||
const next = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'font-size', '18px');
|
||||
expect(next).not.toBe(base);
|
||||
expect(next.hero?.props).toEqual({ color: '#ff0000', 'font-size': '18px' });
|
||||
});
|
||||
|
||||
it('creates a new entry for a previously untouched element', () => {
|
||||
const next = updateInspectOverride(base, 'cta', '[data-od-id="cta"]', 'color', '#00ff00');
|
||||
expect(next.cta?.props).toEqual({ color: '#00ff00' });
|
||||
expect(next.hero?.props).toEqual({ color: '#ff0000' });
|
||||
});
|
||||
|
||||
it('clears a single property when given an empty value', () => {
|
||||
const seeded = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'font-size', '18px');
|
||||
const cleared = updateInspectOverride(seeded, 'hero', '[data-od-id="hero"]', 'font-size', '');
|
||||
expect(cleared.hero?.props).toEqual({ color: '#ff0000' });
|
||||
});
|
||||
|
||||
it('drops the entry once the last property is cleared', () => {
|
||||
const cleared = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'color', '');
|
||||
expect(cleared.hero).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the same map reference when the change is a no-op', () => {
|
||||
const same = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'color', '#ff0000');
|
||||
expect(same).toBe(base);
|
||||
const noClear = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'font-size', '');
|
||||
expect(noClear).toBe(base);
|
||||
});
|
||||
|
||||
it('rejects properties off the host allow-list', () => {
|
||||
const ignored = updateInspectOverride(base, 'hero', '[data-od-id="hero"]', 'position', 'absolute');
|
||||
expect(ignored).toBe(base);
|
||||
});
|
||||
|
||||
it('rejects values that could break out of `prop: value`', () => {
|
||||
const ignored = updateInspectOverride(
|
||||
base,
|
||||
'hero',
|
||||
'[data-od-id="hero"]',
|
||||
'color',
|
||||
'red; background: url(x)',
|
||||
);
|
||||
expect(ignored).toBe(base);
|
||||
});
|
||||
|
||||
it('rejects elementIds whose characters could break out of the attr value', () => {
|
||||
const ignored = updateInspectOverride(
|
||||
base,
|
||||
'"><script>alert(1)</script>',
|
||||
'[data-od-id="x"]',
|
||||
'color',
|
||||
'#fff',
|
||||
);
|
||||
expect(ignored).toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
function baseLiveArtifact(overrides: Partial<LiveArtifact> = {}): LiveArtifact {
|
||||
const artifact: LiveArtifact = {
|
||||
schemaVersion: 1,
|
||||
|
|
@ -344,5 +853,4 @@ describe('LiveArtifactRefreshHistoryPanel', () => {
|
|||
expect(markup).toContain('2 sources updated');
|
||||
expect(markup).toContain('3.8s');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ describe('shouldUrlLoadHtmlPreview', () => {
|
|||
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to srcDoc when inspect mode is active (selection bridge required)', () => {
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, inspectMode: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to srcDoc when the user opts in via forceInline', () => {
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, forceInline: true })).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -42,14 +42,17 @@ describe('buildSrcdoc', () => {
|
|||
expect(canSetActive).not.toContain('findActiveByVisibility');
|
||||
});
|
||||
|
||||
it('enables the comment bridge immediately when injected', () => {
|
||||
it('injects the selection bridge for comment mode', () => {
|
||||
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
|
||||
commentBridge: true,
|
||||
});
|
||||
|
||||
expect(srcdoc).toContain('data-od-comment-bridge');
|
||||
expect(srcdoc).toContain('var enabled = true;');
|
||||
expect(srcdoc).toContain("var mode = 'picker';");
|
||||
expect(srcdoc).toContain('data-od-selection-bridge');
|
||||
// The bridge boots with the requested mode already on so a click
|
||||
// immediately after srcdoc rebuild is not lost to the listener-install
|
||||
// race against the host's `od:*-mode` postMessage.
|
||||
expect(srcdoc).toContain('var commentEnabled = true;');
|
||||
expect(srcdoc).toContain('var inspectEnabled = false;');
|
||||
expect(srcdoc).toContain("type: 'od:comment-target'");
|
||||
expect(srcdoc).toContain("type: 'od:comment-hover'");
|
||||
expect(srcdoc).toContain("type: 'od:comment-leave'");
|
||||
|
|
@ -60,7 +63,116 @@ describe('buildSrcdoc', () => {
|
|||
expect(srcdoc).toContain("body * { cursor: crosshair !important; }");
|
||||
expect(srcdoc).toContain('MutationObserver(schedulePostTargets)');
|
||||
expect(srcdoc).toContain("document.addEventListener('scroll', schedulePostTargets, true);");
|
||||
expect(srcdoc).toContain('data-od-comment-bridge-style');
|
||||
expect(srcdoc).toContain('data-od-selection-bridge-style');
|
||||
});
|
||||
|
||||
it('injects the selection bridge for inspect mode and exposes override hooks', () => {
|
||||
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
|
||||
inspectBridge: true,
|
||||
});
|
||||
|
||||
expect(srcdoc).toContain('data-od-selection-bridge');
|
||||
expect(srcdoc).toContain('var commentEnabled = false;');
|
||||
expect(srcdoc).toContain('var inspectEnabled = true;');
|
||||
expect(srcdoc).toContain("type: 'od:inspect-overrides'");
|
||||
expect(srcdoc).toContain("data.type === 'od:inspect-mode'");
|
||||
expect(srcdoc).toContain("data.type === 'od:inspect-set'");
|
||||
expect(srcdoc).toContain("data.type === 'od:inspect-reset'");
|
||||
expect(srcdoc).toContain("data.type === 'od:inspect-extract'");
|
||||
expect(srcdoc).toContain("data-od-inspect-overrides");
|
||||
expect(srcdoc).toContain('html[data-od-inspect-mode]');
|
||||
});
|
||||
|
||||
it('hydrates inspect overrides from a persisted style block on bridge boot', () => {
|
||||
// Without hydration, the first od:inspect-set rebuilds the override
|
||||
// sheet from an empty in-memory map and silently drops every previously
|
||||
// saved rule for other elements — Save-to-source would then erase them
|
||||
// from the artifact too.
|
||||
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
|
||||
inspectBridge: true,
|
||||
});
|
||||
expect(srcdoc).toContain('function hydrateOverridesFromDom()');
|
||||
expect(srcdoc).toContain('hydrateOverridesFromDom();');
|
||||
expect(srcdoc).toContain("document.querySelector('style[data-od-inspect-overrides]')");
|
||||
// After hydration, the bridge must seed the host's overrides state so a
|
||||
// Save-to-source before the user has touched any control does not splice
|
||||
// an empty CSS body that erases the persisted style block.
|
||||
expect(srcdoc).toContain('if (Object.keys(overrides).length) setTimeout(postOverrides, 0);');
|
||||
});
|
||||
|
||||
it('reflects the requested initial bridge modes on the documentElement attributes', () => {
|
||||
const commentDoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
|
||||
commentBridge: true,
|
||||
});
|
||||
expect(commentDoc).toContain("document.documentElement.toggleAttribute('data-od-comment-mode', true)");
|
||||
|
||||
const inspectDoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
|
||||
inspectBridge: true,
|
||||
});
|
||||
expect(inspectDoc).toContain("document.documentElement.toggleAttribute('data-od-inspect-mode', true)");
|
||||
});
|
||||
|
||||
it('omits the selection bridge entirely when neither comment nor inspect mode is on', () => {
|
||||
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {});
|
||||
expect(srcdoc).not.toContain('data-od-selection-bridge');
|
||||
});
|
||||
|
||||
// Regression for nexu-io/open-design#362: the bridge must accept an
|
||||
// od:inspect-replay message that replaces its in-memory override map
|
||||
// with the host's authoritative set. Without this, toggling Inspect
|
||||
// off/on or switching to Comment mode reloads the iframe from
|
||||
// previewSource without the host's unsaved style block, leaving
|
||||
// preview and persisted state out of sync — saveInspectToSource()
|
||||
// could then commit CSS the user is no longer seeing.
|
||||
it('accepts od:inspect-replay to rehydrate from the host map after a srcdoc rebuild', () => {
|
||||
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
|
||||
inspectBridge: true,
|
||||
});
|
||||
expect(srcdoc).toContain("data.type === 'od:inspect-replay'");
|
||||
// Re-validates the inbound payload under the same allow-list and
|
||||
// value sanitizer used for od:inspect-set. A parent able to post to
|
||||
// this bridge is otherwise trusted, but applying its payload through
|
||||
// the bridge's own contract keeps the override sheet under known
|
||||
// rules instead of whatever the parent sent.
|
||||
expect(srcdoc).toContain('Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, name)');
|
||||
// The replay handler installs the host map atomically — clears the
|
||||
// previous in-memory map first, then re-applies validated entries
|
||||
// and rebuilds the sheet in a single pass so the user does not see
|
||||
// a flash of unstyled preview between the two postMessages a
|
||||
// per-prop replay would require.
|
||||
expect(srcdoc).toContain('overrides = Object.create(null);');
|
||||
});
|
||||
|
||||
it('hardens inspect overrides with a prop allow-list, value sanitizer, and trusted selector', () => {
|
||||
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
|
||||
inspectBridge: true,
|
||||
});
|
||||
|
||||
// Allow-list rejects anything off the InspectPanel surface — without
|
||||
// this a malicious parent could smuggle CSS via od:inspect-set.
|
||||
expect(srcdoc).toContain('var ALLOWED_PROPS');
|
||||
expect(srcdoc).toContain("'color': true");
|
||||
expect(srcdoc).toContain("'background-color': true");
|
||||
expect(srcdoc).toContain("'border-radius': true");
|
||||
expect(srcdoc).toContain("Object.prototype.hasOwnProperty.call(ALLOWED_PROPS, prop)");
|
||||
|
||||
// Value sanitizer drops any character that could close the declaration,
|
||||
// the rule, or the <style> element.
|
||||
expect(srcdoc).toContain('var UNSAFE_VALUE = /[;{}<>\\n\\r]/;');
|
||||
expect(srcdoc).toContain('UNSAFE_VALUE.test(v)');
|
||||
|
||||
// Selector is recomputed from elementId, not echoed back from the
|
||||
// inbound message — defends against a forged selector breaking out
|
||||
// of the override <style> block. The inbound selector is still
|
||||
// inspected to pick the attribute kind (data-od-id vs
|
||||
// data-screen-label) the user clicked, so an artifact that carries
|
||||
// both attributes on different nodes with the same id tunes the
|
||||
// node the host serializer keys off, not whichever attribute
|
||||
// happens to come first in safeSelectorFor's fallback order.
|
||||
expect(srcdoc).toContain('function safeSelectorFor(elementId, hint)');
|
||||
expect(srcdoc).toContain('var safeSelector = safeSelectorFor(elementId, selector)');
|
||||
expect(srcdoc).toContain("hint.indexOf('[data-od-id=') === 0");
|
||||
expect(srcdoc).toContain("hint.indexOf('[data-screen-label=') === 0");
|
||||
});
|
||||
|
||||
it('marks source-authored edit targets before runtime scripts can add nodes', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue