lightview
Version:
A reactive UI library with features of Bau, Juris, and HTMX plus safe LLM UI generation
433 lines (391 loc) • 17.5 kB
JavaScript
// Unique ID counter for iframe identification
var examplifyIdCounter = globalThis.examplifyIdCounter || 0;
globalThis.examplifyIdCounter = examplifyIdCounter;
globalThis.examplify = function examplify(target, options = {}) {
const { scripts, styles, modules, html, at, location = 'beforeBegin', type, height, minHeight = 100, maxHeight = Infinity, allowSameOrigin = false, useOrigin = null, language = 'js', autoRun = false, controls: showControls = true } = options;
const originalContent = target.textContent;
const autoResize = !height; // Auto-resize if no explicit height is provided
const iframeId = `examplify-${++examplifyIdCounter}`;
// State
let isRunning = false;
let sandboxReady = false;
let pendingContent = null;
// 2. Create controls above the target
const controls = document.createElement('div');
const editable = target.getAttribute('contenteditable') == 'true';
controls.className = 'examplify-controls';
if (!showControls) controls.style.display = 'none';
// Controls HTML
controls.innerHTML = `
<button class="examplify-btn examplify-run" title="Run">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
<span>Run</span>
</button>
<button class="examplify-btn examplify-copy" title="Copy">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
<span>Copy</span>
</button>
${editable ? `<button class="examplify-btn examplify-reset" title="Reset" style="display: none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
</svg>
<span>Reset</span>
</button>` : ''}
`;
// 3. Create iframe after target (initially empty)
let iframe = document.createElement('iframe');
iframe.className = 'examplify-iframe';
iframe.style.opacity = '1';
iframe.style.background = '#f9fafb'; // Light gray placeholder
iframe.style.border = '1px solid #e5e7eb';
iframe.style.transition = 'opacity 0.2s ease-in, height 0.2s ease-out';
const sandboxFlags = ['allow-scripts', 'allow-modals', 'allow-forms', 'allow-popups'];
if (allowSameOrigin) sandboxFlags.push('allow-same-origin');
iframe.sandbox = sandboxFlags.join(' ');
if (height) iframe.style.height = height;
if (minHeight) iframe.style.minHeight = typeof minHeight === 'number' ? `${minHeight}px` : minHeight;
if (maxHeight && maxHeight !== Infinity) iframe.style.maxHeight = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight;
// Set initial placeholder height if not specified, to show it exists
if (!height && !iframe.style.minHeight) {
iframe.style.height = '100px';
}
// If using an external origin, set src immediately to avoid sandbox warnings
if (useOrigin) {
iframe.src = useOrigin + '/docs/assets/js/examplify-sandbox.html?id=' + iframeId;
}
// Insert elements
const insertionPoint = (at || target);
insertionPoint.insertAdjacentElement(location, iframe);
if (target.parentElement && target.parentElement.tagName === 'PRE') {
target.parentElement.insertAdjacentElement('beforebegin', controls);
target.parentElement.classList.add('examplify-parent');
target.style.outline = 'none';
target.style.border = 'none';
} else {
target.insertAdjacentElement('beforebegin', controls);
}
// Styles
// We add styles only if not present, to avoid duplication if called multiple times
if (!document.getElementById('examplify-styles')) {
const style = document.createElement('style');
style.id = 'examplify-styles';
style.textContent = `
.examplify-controls {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.examplify-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border: 1px solid
border-radius: 4px;
background:
cursor: pointer;
transition: all 0.2s;
color:
}
.examplify-btn:hover {
background:
border-color:
}
.examplify-iframe {
width: 100%;
border: 1px solid
border-radius: 6px;
margin-top: 0.5rem;
background: white;
}
.examplify-parent {
transition: outline 0.2s;
border-radius: 4px;
}
.examplify-parent:hover {
outline: 1px solid
}
`;
document.head.appendChild(style);
}
// Helper: Placeholder Content
function getPlaceholderContent() {
return `<!DOCTYPE html>
<html>
<head>
<style>
body {
margin:0; padding:0; height:100vh;
display:flex; align-items:center; justify-content:center;
background:
cursor:pointer; user-select:none;
font-family:system-ui,-apple-system,sans-serif;
transition: color 0.2s;
}
body:hover { color:
.content { display:flex; align-items:center; gap:0.5rem; font-size:0.875rem; font-weight: 500; }
</style>
</head>
<body onclick="parent.postMessage({type:'examplify-run-click', id:'${iframeId}'}, '*')">
<div class="content">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
<span>Click to run</span>
</div>
</body>
</html>`;
}
// Helper: Generate Iframe Content
function getIframeContent(codeContent) {
const currentTheme = document.documentElement.getAttribute('data-theme');
const themeAttr = currentTheme ? ` data-theme="${currentTheme}"` : '';
const path = globalThis.location.pathname;
const baseDir = path.substring(0, path.lastIndexOf('/') + 1);
const baseTag = useOrigin ? `<base href="${useOrigin}${baseDir}">` : '';
const autoResizeScript = autoResize ? `
<script>
const frameId = '${iframeId}';
function sendHeight() {
const height = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
parent.postMessage({ type: 'examplify-resize', id: frameId, height: height }, '*');
}
globalThis.addEventListener('load', () => {
sendHeight();
setTimeout(sendHeight, 300);
setTimeout(sendHeight, 1000);
});
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => sendHeight());
resizeObserver.observe(document.body);
}
const mutationObserver = new MutationObserver(() => {
setTimeout(sendHeight, 50);
});
mutationObserver.observe(document.body, { childList: true, subtree: true, attributes: true });
</script>
` : '';
return `<!DOCTYPE html>
<html${themeAttr}>
<head>
${baseTag}
<style>
/* Hide body until stylesheets are loaded to prevent FOUC */
body {
font-family: system-ui, -apple-system, sans-serif;
padding: 1rem;
margin: 0;
opacity: 0;
transition: opacity 0.15s ease-in;
}
body.styles-ready { opacity: 1; }
</style>
${styles ? styles.map(href => `<link rel="stylesheet" href="${href}">`).join('\n') : ''}
<script>
// Synchronously create the stylesheet-ready promise before any modules execute
globalThis.__stylesheetsReady = (function() {
return new Promise(resolve => {
// Use requestAnimationFrame to ensure DOM is ready for querying
requestAnimationFrame(() => {
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
if (links.length === 0) {
resolve();
return;
}
let loaded = 0;
const checkDone = () => {
loaded++;
if (loaded >= links.length) resolve();
};
links.forEach(link => {
if (link.sheet) {
checkDone();
} else {
link.addEventListener('load', checkDone);
link.addEventListener('error', checkDone);
}
});
// Fallback timeout
setTimeout(resolve, 2000);
});
});
})();
<\/script>
${modules ? modules.map(src => `<script type="module" src="${src}"></script>`).join('\n') : ''}
${scripts ? scripts.map(src => `<script src="${src}"></script>`).join('\n') : ''}
<script type="module">
// Wait for stylesheets before initializing Lightview components
await globalThis.__stylesheetsReady;
if (globalThis.LightviewX) {
await globalThis.LightviewX.initComponents({ shadowDefault: true });
}
</script>
</head>
<body>
${language === 'html' ? codeContent : (html ? html : '<div id="example"></div>')}
${language === 'html' ? '' : `<script ${type ? `type="${type}"` : ''}>
const render = (content) => {
const target = document.querySelector('#example');
target.innerHTML = '';
if (typeof content === 'string') {
target.innerHTML = content;
} else if (content && content.domEl) {
target.insertAdjacentElement('afterbegin', content.domEl);
} else if (content instanceof Node) {
target.insertAdjacentElement('afterbegin', content);
}
};
${type === 'module' ? codeContent : `
// Wait for stylesheets before running example code
globalThis.__stylesheetsReady.then(async () => {
${codeContent}
});
`}
</script>`}
${autoResizeScript}
<script>
// Reveal body and signal ready only after stylesheets are loaded
globalThis.__stylesheetsReady.then(() => {
document.body.classList.add('styles-ready');
setTimeout(() => {
parent.postMessage({ type: 'examplify-ready', id: '${iframeId}' }, '*');
}, 50);
});
// Listen for theme changes from parent
globalThis.addEventListener('message', (event) => {
if (event.data && event.data.type === 'theme-change' && event.data.theme) {
document.documentElement.setAttribute('data-theme', event.data.theme);
if (globalThis.LightviewX && typeof globalThis.LightviewX.setTheme === 'function') {
globalThis.LightviewX.setTheme(event.data.theme);
}
}
});
</script>
</body>
</html>`;
}
// Helper: Run
function run() {
const content = getIframeContent(target.textContent);
iframe.style.background = '#fff';
if (useOrigin) {
if (sandboxReady) {
iframe.contentWindow.postMessage({ type: 'examplify-load-content', content: content, id: iframeId }, '*');
} else {
pendingContent = content;
// iframe.src is already set in the initialization phase
}
} else {
iframe.srcdoc = content;
}
isRunning = true;
}
// Initialize: auto-run or show placeholder
if (autoRun) {
run();
} else if (!useOrigin) {
// Only use srcdoc for local placeholders when not using a separate origin
iframe.srcdoc = getPlaceholderContent();
}
// Note: if useOrigin is set, the sandbox page itself will handle being blank or showing placeholder
// until we post the content to it.
// Event Listeners
const runBtn = controls.querySelector('.examplify-run');
runBtn.addEventListener('click', run);
const copyBtn = controls.querySelector('.examplify-copy');
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(target.textContent);
// Feedback
const originalHtml = copyBtn.innerHTML;
copyBtn.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span>Copied!</span>
`;
setTimeout(() => copyBtn.innerHTML = originalHtml, 2000);
});
if (editable) {
const resetBtn = controls.querySelector('.examplify-reset');
// Reset action
resetBtn.addEventListener('click', () => {
target.textContent = originalContent;
resetBtn.style.display = 'none';
// Force reset even if running to reflect text change if any,
// but actually we want to reset to original content AND re-run
// OR go back to placeholder?
// Usually reset means "restore code to initial state".
// If the user modified code, we reset text.
// If they want to run the original code, they should click run?
// Or should we auto-run? Use Case: "Reset" usually implies "Fix my broken code".
// So re-running the original code makes sense.
if (isRunning) run();
});
// Watch for changes
target.addEventListener('input', () => {
if (target.textContent !== originalContent) {
resetBtn.style.display = 'inline-flex';
} else {
resetBtn.style.display = 'none';
}
});
}
// Global Message Listener (for resizing and run click)
globalThis.addEventListener('message', (event) => {
if (!event.data || event.data.id !== iframeId) return;
if (event.data.type === 'examplify-resize' && autoResize) {
const h = event.data.height + 2; // buffer
iframe.style.height = Math.max(minHeight, Math.min(h, maxHeight)) + 'px';
}
if (event.data.type === 'examplify-run-click') {
run();
}
if (event.data.type === 'examplify-sandbox-ready' && useOrigin) {
sandboxReady = true;
if (pendingContent) {
iframe.contentWindow.postMessage({ type: 'examplify-load-content', content: pendingContent, id: iframeId }, '*');
pendingContent = null;
}
}
});
return { controls, iframe, target, run };
}
// Global Theme Observer (Host side)
if (typeof document !== 'undefined') {
let themeObserver = null;
const initThemeObserver = () => {
if (themeObserver) return;
themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'data-theme') {
const newTheme = document.documentElement.getAttribute('data-theme');
// Broadcast to all examplify iframes
document.querySelectorAll('.examplify-iframe').forEach(iframe => {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage({ type: 'theme-change', theme: newTheme }, '*');
}
});
}
});
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
};
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initThemeObserver);
} else {
initThemeObserver();
}
}