Compare commits
No commits in common. "1b94469a601338363a1350b1be41dd16e423aecb" and "c5d1880589e7deccb64697edd8902057ee4ab064" have entirely different histories.
1b94469a60
...
c5d1880589
2189
package-lock.json
generated
2189
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -7,19 +7,14 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"preact": "^10.19.0"
|
"preact": "^10.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/preact": "^3.2.4",
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"jsdom": "^25.0.0",
|
|
||||||
"typescript": "^5.6.0",
|
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
"vitest": "^4.1.4"
|
"typescript": "^5.6.0",
|
||||||
|
"@types/node": "^22.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ export type WidgetConfig = {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
position?: 'bottom-right' | 'bottom-left';
|
position?: 'bottom-right' | 'bottom-left';
|
||||||
shadow: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parseConfig(): WidgetConfig | null {
|
export function parseConfig(): WidgetConfig | null {
|
||||||
@ -23,11 +22,10 @@ export function parseConfig(): WidgetConfig | null {
|
|||||||
| 'bottom-right'
|
| 'bottom-right'
|
||||||
| 'bottom-left'
|
| 'bottom-left'
|
||||||
| null) ?? 'bottom-right';
|
| null) ?? 'bottom-right';
|
||||||
const shadow = script.getAttribute('data-shadow') === 'true';
|
|
||||||
|
|
||||||
if (!botId || !apiKey) return null;
|
if (!botId || !apiKey) return null;
|
||||||
|
|
||||||
return { botId, apiKey, serverUrl, position, shadow };
|
return { botId, apiKey, serverUrl, position };
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveServerFromSrc(src: string): string {
|
function deriveServerFromSrc(src: string): string {
|
||||||
|
|||||||
30
src/index.ts
30
src/index.ts
@ -1,19 +1,18 @@
|
|||||||
|
import { h, render } from 'preact';
|
||||||
|
import { Widget } from './ui/widget.js';
|
||||||
import { parseConfig } from './config.js';
|
import { parseConfig } from './config.js';
|
||||||
import { mountWidget, type MountMode } from './mount.js';
|
|
||||||
import cssText from './ui/theme.css?inline';
|
import cssText from './ui/theme.css?inline';
|
||||||
|
|
||||||
const GLOBAL_STYLE_ID = 'messenzy-styles';
|
// Inject styles into shadow-free <style> on first load
|
||||||
|
(function injectStyles() {
|
||||||
function maybeInjectGlobalStyles(mode: MountMode): void {
|
if (document.getElementById('messenzy-styles')) return;
|
||||||
if (mode === 'shadow') return;
|
|
||||||
if (document.getElementById(GLOBAL_STYLE_ID)) return;
|
|
||||||
const style = document.createElement('style');
|
const style = document.createElement('style');
|
||||||
style.id = GLOBAL_STYLE_ID;
|
style.id = 'messenzy-styles';
|
||||||
style.textContent = cssText;
|
style.textContent = cssText;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
})();
|
||||||
|
|
||||||
function boot(): void {
|
function boot() {
|
||||||
const config = parseConfig();
|
const config = parseConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@ -22,9 +21,16 @@ function boot(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode: MountMode = config.shadow ? 'shadow' : 'classic';
|
const root =
|
||||||
maybeInjectGlobalStyles(mode);
|
document.getElementById('messenzy-root') ??
|
||||||
mountWidget(config, cssText, mode);
|
(() => {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.id = 'messenzy-root';
|
||||||
|
document.body.appendChild(d);
|
||||||
|
return d;
|
||||||
|
})();
|
||||||
|
|
||||||
|
render(h(Widget, { config }), root);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
|
|||||||
90
src/mount.ts
90
src/mount.ts
@ -1,90 +0,0 @@
|
|||||||
import { h, render } from 'preact';
|
|
||||||
import { Widget } from './ui/widget.js';
|
|
||||||
import type { WidgetConfig } from './config.js';
|
|
||||||
|
|
||||||
export type MountMode = 'classic' | 'shadow';
|
|
||||||
|
|
||||||
export type MountHandle = {
|
|
||||||
root: HTMLElement;
|
|
||||||
shadowRoot?: ShadowRoot;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ROOT_ID = 'messenzy-root';
|
|
||||||
const SHADOW_INNER_ID = 'messenzy-shadow-inner';
|
|
||||||
const SHADOW_STYLE_ID = 'messenzy-shadow-style';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mount the widget into the DOM.
|
|
||||||
*
|
|
||||||
* - classic mode: renders Preact directly into the host div in the light DOM.
|
|
||||||
* The caller is responsible for ensuring CSS is in document.head.
|
|
||||||
* - shadow mode: attaches an open shadow root, injects CSS inside the shadow
|
|
||||||
* tree (constructable stylesheets when supported, <style> fallback otherwise),
|
|
||||||
* and renders Preact into a stable wrapper inside the shadow root.
|
|
||||||
*
|
|
||||||
* Both modes are idempotent: calling twice reuses the existing #messenzy-root
|
|
||||||
* (and its shadow root, in shadow mode) rather than recreating it.
|
|
||||||
*/
|
|
||||||
export function mountWidget(
|
|
||||||
config: WidgetConfig,
|
|
||||||
cssText: string,
|
|
||||||
mode: MountMode,
|
|
||||||
): MountHandle {
|
|
||||||
const root = resolveOrCreateRoot();
|
|
||||||
|
|
||||||
if (mode !== 'shadow') {
|
|
||||||
render(h(Widget, { config }), root);
|
|
||||||
return { root };
|
|
||||||
}
|
|
||||||
|
|
||||||
const shadow = root.shadowRoot ?? root.attachShadow({ mode: 'open' });
|
|
||||||
applyStyles(shadow, cssText);
|
|
||||||
|
|
||||||
// Render into a stable inner wrapper so Preact's render() does not clobber
|
|
||||||
// the sibling <style> node when it diffs children.
|
|
||||||
let inner = shadow.getElementById(SHADOW_INNER_ID) as HTMLElement | null;
|
|
||||||
if (!inner) {
|
|
||||||
inner = document.createElement('div');
|
|
||||||
inner.id = SHADOW_INNER_ID;
|
|
||||||
shadow.appendChild(inner);
|
|
||||||
}
|
|
||||||
render(h(Widget, { config }), inner);
|
|
||||||
return { root, shadowRoot: shadow };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject CSS into a shadow root.
|
|
||||||
*
|
|
||||||
* Prefers constructable stylesheets (`adoptedStyleSheets`) — faster, no DOM
|
|
||||||
* mutation, sheet shareable across instances. Falls back to a deduped <style>
|
|
||||||
* child when constructable sheets are unavailable (jsdom, Safari < 16.4).
|
|
||||||
*/
|
|
||||||
export function applyStyles(shadow: ShadowRoot, cssText: string): void {
|
|
||||||
if (supportsConstructableStylesheets()) {
|
|
||||||
const sheet = new CSSStyleSheet();
|
|
||||||
sheet.replaceSync(cssText);
|
|
||||||
shadow.adoptedStyleSheets = [sheet];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (shadow.getElementById(SHADOW_STYLE_ID)) return;
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.id = SHADOW_STYLE_ID;
|
|
||||||
style.textContent = cssText;
|
|
||||||
shadow.prepend(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveOrCreateRoot(): HTMLElement {
|
|
||||||
const existing = document.getElementById(ROOT_ID);
|
|
||||||
if (existing) return existing;
|
|
||||||
const root = document.createElement('div');
|
|
||||||
root.id = ROOT_ID;
|
|
||||||
document.body.appendChild(root);
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
function supportsConstructableStylesheets(): boolean {
|
|
||||||
return (
|
|
||||||
typeof CSSStyleSheet !== 'undefined' &&
|
|
||||||
'replaceSync' in CSSStyleSheet.prototype
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
// Global test setup for Vitest + jsdom.
|
|
||||||
// Add polyfills/stubs here as the test surface grows.
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { parseConfig } from '@/config';
|
|
||||||
|
|
||||||
function createScript(
|
|
||||||
attrs: Record<string, string>,
|
|
||||||
src = 'https://cdn.example.com/messenzy-widget.iife.js',
|
|
||||||
): HTMLScriptElement {
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = src;
|
|
||||||
for (const [key, value] of Object.entries(attrs)) {
|
|
||||||
script.setAttribute(key, value);
|
|
||||||
}
|
|
||||||
document.head.appendChild(script);
|
|
||||||
return script;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
document.head.innerHTML = '';
|
|
||||||
document.body.innerHTML = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('parseConfig', () => {
|
|
||||||
it('returns null when no script with data-bot-id is present', () => {
|
|
||||||
expect(parseConfig()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when data-api-key is missing', () => {
|
|
||||||
createScript({ 'data-bot-id': 'b1' });
|
|
||||||
expect(parseConfig()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when data-bot-id is missing', () => {
|
|
||||||
createScript({ 'data-api-key': 'k1' });
|
|
||||||
expect(parseConfig()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses required fields', () => {
|
|
||||||
createScript({ 'data-bot-id': 'b1', 'data-api-key': 'k1' });
|
|
||||||
const config = parseConfig();
|
|
||||||
expect(config).not.toBeNull();
|
|
||||||
expect(config?.botId).toBe('b1');
|
|
||||||
expect(config?.apiKey).toBe('k1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives serverUrl from script src when data-server-url absent', () => {
|
|
||||||
createScript(
|
|
||||||
{ 'data-bot-id': 'b1', 'data-api-key': 'k1' },
|
|
||||||
'https://widgets.example.com/path/messenzy.iife.js',
|
|
||||||
);
|
|
||||||
expect(parseConfig()?.serverUrl).toBe('https://widgets.example.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('respects explicit data-server-url override', () => {
|
|
||||||
createScript({
|
|
||||||
'data-bot-id': 'b1',
|
|
||||||
'data-api-key': 'k1',
|
|
||||||
'data-server-url': 'https://api.foo.test',
|
|
||||||
});
|
|
||||||
expect(parseConfig()?.serverUrl).toBe('https://api.foo.test');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults position to bottom-right', () => {
|
|
||||||
createScript({ 'data-bot-id': 'b1', 'data-api-key': 'k1' });
|
|
||||||
expect(parseConfig()?.position).toBe('bottom-right');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses position bottom-left', () => {
|
|
||||||
createScript({
|
|
||||||
'data-bot-id': 'b1',
|
|
||||||
'data-api-key': 'k1',
|
|
||||||
'data-position': 'bottom-left',
|
|
||||||
});
|
|
||||||
expect(parseConfig()?.position).toBe('bottom-left');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shadow attribute (PR B opt-in)', () => {
|
|
||||||
it('shadow=false when data-shadow absent', () => {
|
|
||||||
createScript({ 'data-bot-id': 'b1', 'data-api-key': 'k1' });
|
|
||||||
expect(parseConfig()?.shadow).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shadow=true when data-shadow="true"', () => {
|
|
||||||
createScript({
|
|
||||||
'data-bot-id': 'b1',
|
|
||||||
'data-api-key': 'k1',
|
|
||||||
'data-shadow': 'true',
|
|
||||||
});
|
|
||||||
expect(parseConfig()?.shadow).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shadow=false when data-shadow="false"', () => {
|
|
||||||
createScript({
|
|
||||||
'data-bot-id': 'b1',
|
|
||||||
'data-api-key': 'k1',
|
|
||||||
'data-shadow': 'false',
|
|
||||||
});
|
|
||||||
expect(parseConfig()?.shadow).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shadow=false when data-shadow="" (empty)', () => {
|
|
||||||
createScript({
|
|
||||||
'data-bot-id': 'b1',
|
|
||||||
'data-api-key': 'k1',
|
|
||||||
'data-shadow': '',
|
|
||||||
});
|
|
||||||
expect(parseConfig()?.shadow).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shadow=false for unrecognized values (e.g. "1", "yes")', () => {
|
|
||||||
createScript({
|
|
||||||
'data-bot-id': 'b1',
|
|
||||||
'data-api-key': 'k1',
|
|
||||||
'data-shadow': '1',
|
|
||||||
});
|
|
||||||
expect(parseConfig()?.shadow).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
||||||
|
|
||||||
// Stub the Widget component so mountWidget does not boot the WebSocket
|
|
||||||
// transport (jsdom has no WebSocket implementation). We only verify mount
|
|
||||||
// mechanics here — Widget rendering is a separate concern.
|
|
||||||
vi.mock('@/ui/widget', () => ({
|
|
||||||
Widget: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { mountWidget, applyStyles } from '@/mount';
|
|
||||||
import type { WidgetConfig } from '@/config';
|
|
||||||
|
|
||||||
const baseConfig: WidgetConfig = {
|
|
||||||
botId: 'test-bot',
|
|
||||||
apiKey: 'test-key',
|
|
||||||
serverUrl: 'https://test.local',
|
|
||||||
position: 'bottom-right',
|
|
||||||
shadow: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEST_CSS = '.messenzy-widget { color: tomato; }';
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
document.head.innerHTML = '';
|
|
||||||
document.body.innerHTML = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mountWidget — classic mode', () => {
|
|
||||||
it('creates #messenzy-root in document.body when absent', () => {
|
|
||||||
mountWidget(baseConfig, TEST_CSS, 'classic');
|
|
||||||
const root = document.getElementById('messenzy-root');
|
|
||||||
expect(root).not.toBeNull();
|
|
||||||
expect(root?.parentElement).toBe(document.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reuses an existing #messenzy-root', () => {
|
|
||||||
const existing = document.createElement('div');
|
|
||||||
existing.id = 'messenzy-root';
|
|
||||||
document.body.appendChild(existing);
|
|
||||||
const handle = mountWidget(baseConfig, TEST_CSS, 'classic');
|
|
||||||
expect(handle.root).toBe(existing);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a handle without shadowRoot', () => {
|
|
||||||
const handle = mountWidget(baseConfig, TEST_CSS, 'classic');
|
|
||||||
expect(handle.shadowRoot).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not attach a shadow root to the host', () => {
|
|
||||||
mountWidget(baseConfig, TEST_CSS, 'classic');
|
|
||||||
const root = document.getElementById('messenzy-root');
|
|
||||||
expect(root?.shadowRoot).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mountWidget — shadow mode', () => {
|
|
||||||
it('creates the host and attaches an open shadow root', () => {
|
|
||||||
const handle = mountWidget(baseConfig, TEST_CSS, 'shadow');
|
|
||||||
const root = document.getElementById('messenzy-root');
|
|
||||||
expect(root).not.toBeNull();
|
|
||||||
expect(root?.shadowRoot).not.toBeNull();
|
|
||||||
expect(handle.shadowRoot).toBe(root?.shadowRoot);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reuses an already-attached shadow root (HMR/idempotency)', () => {
|
|
||||||
const existing = document.createElement('div');
|
|
||||||
existing.id = 'messenzy-root';
|
|
||||||
const preAttached = existing.attachShadow({ mode: 'open' });
|
|
||||||
document.body.appendChild(existing);
|
|
||||||
const handle = mountWidget(baseConfig, TEST_CSS, 'shadow');
|
|
||||||
expect(handle.shadowRoot).toBe(preAttached);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('injects styles into the shadow root, not document.head', () => {
|
|
||||||
mountWidget(baseConfig, TEST_CSS, 'shadow');
|
|
||||||
expect(document.getElementById('messenzy-styles')).toBeNull();
|
|
||||||
const shadow = document.getElementById('messenzy-root')?.shadowRoot;
|
|
||||||
const styleNode = shadow?.querySelector('style');
|
|
||||||
expect(styleNode).not.toBeNull();
|
|
||||||
expect(styleNode?.textContent).toContain('tomato');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the <style> intact when Preact renders (uses inner wrapper)', () => {
|
|
||||||
mountWidget(baseConfig, TEST_CSS, 'shadow');
|
|
||||||
const shadow = document.getElementById('messenzy-root')?.shadowRoot;
|
|
||||||
// The shadow root must contain both the style node AND a separate
|
|
||||||
// wrapper for the Preact render target — otherwise render() would
|
|
||||||
// clobber the <style>.
|
|
||||||
expect(shadow?.querySelector('style')).not.toBeNull();
|
|
||||||
expect(shadow?.children.length).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('applyStyles', () => {
|
|
||||||
let shadow: ShadowRoot;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
const host = document.createElement('div');
|
|
||||||
document.body.appendChild(host);
|
|
||||||
shadow = host.attachShadow({ mode: 'open' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('appends a <style> node into the shadow root (jsdom fallback path)', () => {
|
|
||||||
applyStyles(shadow, TEST_CSS);
|
|
||||||
const style = shadow.querySelector('style');
|
|
||||||
expect(style).not.toBeNull();
|
|
||||||
expect(style?.textContent).toBe(TEST_CSS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is idempotent — repeated calls keep one <style>', () => {
|
|
||||||
applyStyles(shadow, TEST_CSS);
|
|
||||||
applyStyles(shadow, TEST_CSS);
|
|
||||||
expect(shadow.querySelectorAll('style')).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The constructable-stylesheets path (`adoptedStyleSheets`) is exercised
|
|
||||||
// in real browsers with full CSSOM support. jsdom does not implement it,
|
|
||||||
// so the unit suite verifies only the <style>-fallback branch. Manual
|
|
||||||
// smoke-test in a real browser confirms the modern path.
|
|
||||||
});
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
|
|
||||||
describe('Vitest infrastructure', () => {
|
|
||||||
it('runs assertions', () => {
|
|
||||||
expect(1 + 1).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes the jsdom document', () => {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.id = 'sanity';
|
|
||||||
document.body.appendChild(div);
|
|
||||||
expect(document.getElementById('sanity')).toBe(div);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports Shadow DOM API in jsdom', () => {
|
|
||||||
const host = document.createElement('div');
|
|
||||||
document.body.appendChild(host);
|
|
||||||
const shadow = host.attachShadow({ mode: 'open' });
|
|
||||||
expect(host.shadowRoot).toBe(shadow);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
react: 'preact/compat',
|
|
||||||
'react-dom': 'preact/compat',
|
|
||||||
'react-dom/test-utils': 'preact/test-utils',
|
|
||||||
'react/jsx-runtime': 'preact/jsx-runtime',
|
|
||||||
'@': resolve(__dirname, './src'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true,
|
|
||||||
setupFiles: ['./tests/setup.ts'],
|
|
||||||
include: ['tests/unit/**/*.test.ts'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user