pondpilot-widget
Version:
Transform static SQL code blocks into interactive snippets powered by DuckDB WASM
1,377 lines (1,182 loc) • 41.2 kB
JavaScript
/**
* PondPilot Widget v1.1.0
* Transform static SQL code blocks into interactive snippets
*/
(function () {
"use strict";
// Inline sql-highlight library (minified) v4.2.0
// Source: https://github.com/scriptcoded/sql-highlight (MIT License)
// Note: This is an inlined version for zero-dependency distribution
const sqlHighlight = (function () {
const keywords = [
"SELECT",
"FROM",
"WHERE",
"AND",
"OR",
"NOT",
"IN",
"EXISTS",
"BETWEEN",
"LIKE",
"IS",
"NULL",
"ORDER",
"BY",
"GROUP",
"HAVING",
"UNION",
"ALL",
"LIMIT",
"OFFSET",
"FETCH",
"FIRST",
"NEXT",
"ONLY",
"ROWS",
"INSERT",
"INTO",
"VALUES",
"UPDATE",
"SET",
"DELETE",
"CREATE",
"ALTER",
"DROP",
"TABLE",
"INDEX",
"VIEW",
"TRIGGER",
"FUNCTION",
"PROCEDURE",
"DATABASE",
"SCHEMA",
"PRIMARY",
"KEY",
"FOREIGN",
"REFERENCES",
"UNIQUE",
"CHECK",
"DEFAULT",
"CONSTRAINT",
"CASCADE",
"RESTRICT",
"IF",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"JOIN",
"INNER",
"LEFT",
"RIGHT",
"FULL",
"OUTER",
"CROSS",
"ON",
"AS",
"DISTINCT",
"WITH",
"RECURSIVE",
"CAST",
"CONVERT",
"COALESCE",
"NULLIF",
"GREATEST",
"LEAST",
"COUNT",
"SUM",
"AVG",
"MIN",
"MAX",
"ROUND",
"FLOOR",
"CEIL",
"ABS",
"SIGN",
"MOD",
"SQRT",
"POWER",
"EXP",
"LOG",
"LN",
"CONCAT",
"LENGTH",
"SUBSTRING",
"REPLACE",
"TRIM",
"UPPER",
"LOWER",
"CURRENT_DATE",
"CURRENT_TIME",
"CURRENT_TIMESTAMP",
"EXTRACT",
"DATE_ADD",
"DATE_SUB",
"DATEDIFF",
"NOW",
"CURDATE",
"CURTIME",
];
function getSegments(sqlString) {
const segments = [];
const len = sqlString.length;
let i = 0;
while (i < len) {
// Skip whitespace
if (/\s/.test(sqlString[i])) {
let j = i;
while (j < len && /\s/.test(sqlString[j])) j++;
segments.push({ name: "whitespace", content: sqlString.slice(i, j) });
i = j;
continue;
}
// Comments
if (sqlString[i] === "-" && sqlString[i + 1] === "-") {
let j = i + 2;
while (j < len && sqlString[j] !== "\n") j++;
segments.push({ name: "comment", content: sqlString.slice(i, j) });
i = j;
continue;
}
// Multi-line comments
if (sqlString[i] === "/" && sqlString[i + 1] === "*") {
let j = i + 2;
while (j < len - 1 && !(sqlString[j] === "*" && sqlString[j + 1] === "/")) j++;
if (j < len - 1) j += 2;
segments.push({ name: "comment", content: sqlString.slice(i, j) });
i = j;
continue;
}
// Strings
if (sqlString[i] === "'" || sqlString[i] === '"') {
const quote = sqlString[i];
let j = i + 1;
while (j < len && sqlString[j] !== quote) {
if (sqlString[j] === "\\") j++;
j++;
}
if (j < len) j++;
segments.push({ name: "string", content: sqlString.slice(i, j) });
i = j;
continue;
}
// Numbers
if (/\d/.test(sqlString[i])) {
let j = i;
while (j < len && /[\d.]/.test(sqlString[j])) j++;
segments.push({ name: "number", content: sqlString.slice(i, j) });
i = j;
continue;
}
// Identifiers and keywords
if (/[a-zA-Z_]/.test(sqlString[i])) {
let j = i;
while (j < len && /[a-zA-Z0-9_]/.test(sqlString[j])) j++;
const word = sqlString.slice(i, j);
const upperWord = word.toUpperCase();
if (keywords.includes(upperWord)) {
segments.push({ name: "keyword", content: word });
} else {
segments.push({ name: "identifier", content: word });
}
i = j;
continue;
}
// Special characters
if (/[(),.;=<>!+\-*/]/.test(sqlString[i])) {
segments.push({ name: "special", content: sqlString[i] });
i++;
continue;
}
// Backticks (MySQL style identifiers)
if (sqlString[i] === "`") {
let j = i + 1;
while (j < len && sqlString[j] !== "`") j++;
if (j < len) j++;
segments.push({ name: "identifier", content: sqlString.slice(i, j) });
i = j;
continue;
}
// Default
segments.push({ name: "other", content: sqlString[i] });
i++;
}
return segments;
}
function highlight(sqlString, options = {}) {
const segments = getSegments(sqlString);
if (options.html) {
return segments
.map((segment) => {
const className = "sql-hl-" + segment.name;
const escaped = segment.content.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
return segment.name === "whitespace" ? escaped : `<span class="${className}">${escaped}</span>`;
})
.join("");
}
return sqlString; // Plain text for now
}
return { highlight, getSegments };
})();
// Constants
const CONSTANTS = {
DEBOUNCE_DELAY: 150, // ms
LARGE_SQL_THRESHOLD: 500, // characters
MAX_OUTPUT_HEIGHT: 300, // px
MIN_LOADING_DURATION: 200, // ms - minimum time to show loading state
PROGRESS_STEPS: {
MODULE_LOADING: 10,
FETCHING_BUNDLES: 20,
SELECTING_BUNDLE: 30,
CREATING_WORKER: 40,
INITIALIZING_DB: 60,
LOADING_MODULE: 80,
CREATING_CONNECTION: 90,
COMPLETE: 100,
},
};
// Widget configuration
const config = {
selector: "pre.pondpilot-snippet, .pondpilot-snippet pre",
baseUrl: window.PONDPILOT_BASE_URL || "https://app.pondpilot.io",
theme: "light",
autoInit: true,
duckdbVersion: "1.29.1-dev68.0",
duckdbCDN: "https://cdn.jsdelivr.net/npm/@duckdb/duckdb-wasm",
// duckdbIntegrity: { main: "sha384-...", worker: "sha384-..." }
};
// Shared DuckDB instance
let sharedDuckDB = null;
let duckDBInitPromise = null;
let duckDBModule = null;
// Minimal widget styles
const styles = `
.pondpilot-widget {
position: relative;
margin: 1em 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8f9fa;
border-radius: 6px;
overflow: hidden;
}
.pondpilot-widget.dark {
background: #1a1b26;
}
/* Button container for better layout control */
.pondpilot-button-container {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex;
gap: 4px;
pointer-events: none;
}
.pondpilot-button-container > * {
pointer-events: auto;
}
/* Minimal floating run button */
.pondpilot-run-button {
position: static;
padding: 4px 12px;
background: rgba(59, 130, 246, 0.9);
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
backdrop-filter: blur(8px);
white-space: nowrap;
}
.pondpilot-run-button:hover {
background: rgba(37, 99, 235, 0.95);
transform: translateY(-1px);
}
.pondpilot-run-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Reset button in editor */
.pondpilot-reset-button {
position: static;
padding: 4px 8px;
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
border: none;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
opacity: 0;
visibility: hidden;
white-space: nowrap;
}
.pondpilot-widget:hover .pondpilot-reset-button.show,
.pondpilot-reset-button.show:hover {
opacity: 1;
visibility: visible;
}
.pondpilot-reset-button:hover {
background: rgba(107, 114, 128, 0.2);
color: #374151;
}
.pondpilot-widget.dark .pondpilot-reset-button:hover {
background: rgba(255, 255, 255, 0.1);
color: #e5e7eb;
}
/* Clean editor */
.pondpilot-editor {
position: relative;
background: transparent;
min-height: 60px;
}
.pondpilot-editor pre {
margin: 0;
padding: 16px;
padding-right: 90px;
background: transparent;
border: none;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: #24292e;
white-space: pre-wrap;
word-wrap: break-word;
tab-size: 2;
}
/* Responsive button spacing for smaller screens */
@media (max-width: 480px) {
.pondpilot-editor pre {
padding-right: 16px;
padding-top: 48px;
}
.pondpilot-button-container {
top: 8px;
right: 8px;
left: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
}
.pondpilot-widget.dark .pondpilot-editor pre {
color: #e1e4e8;
}
.pondpilot-editor[contenteditable="true"] {
outline: none;
}
.pondpilot-editor[contenteditable="true"]:focus-within {
background: rgba(59, 130, 246, 0.05);
}
/* Subtle output */
.pondpilot-output {
background: rgba(0, 0, 0, 0.02);
border-top: 1px solid rgba(0, 0, 0, 0.06);
max-height: ${CONSTANTS.MAX_OUTPUT_HEIGHT}px;
overflow: auto;
display: none;
font-size: 12px;
}
.pondpilot-widget.dark .pondpilot-output {
background: rgba(255, 255, 255, 0.02);
border-top-color: rgba(255, 255, 255, 0.06);
}
.pondpilot-output.show {
display: block;
}
.pondpilot-output-content {
padding: 16px;
}
/* Clean tables */
.pondpilot-output table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.pondpilot-output th,
.pondpilot-output td {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.pondpilot-widget.dark .pondpilot-output th,
.pondpilot-widget.dark .pondpilot-output td {
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.pondpilot-output th {
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
}
.pondpilot-widget.dark .pondpilot-output th {
color: #9ca3af;
}
.pondpilot-output td {
color: #1f2937;
}
.pondpilot-widget.dark .pondpilot-output td {
color: #e5e7eb;
}
/* Minimal error */
.pondpilot-error {
color: #dc2626;
padding: 16px;
font-size: 12px;
font-family: monospace;
}
/* Simple loading */
.pondpilot-loading {
text-align: center;
padding: 24px;
color: #6b7280;
font-size: 12px;
}
/* Loading progress */
.pondpilot-progress {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 24px;
}
.pondpilot-progress-bar {
width: 200px;
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
}
.pondpilot-widget.dark .pondpilot-progress-bar {
background: rgba(255, 255, 255, 0.1);
}
.pondpilot-progress-fill {
height: 100%;
background: #3b82f6;
border-radius: 2px;
transition: width 0.3s ease;
}
.pondpilot-progress-text {
font-size: 12px;
color: #6b7280;
}
/* Results footer */
.pondpilot-results-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.pondpilot-widget.dark .pondpilot-results-footer {
border-top-color: rgba(255, 255, 255, 0.06);
}
.pondpilot-results-info {
font-size: 11px;
color: #6b7280;
display: flex;
align-items: center;
gap: 6px;
}
/* Duck logo watermark */
.pondpilot-duck {
position: absolute;
bottom: 8px;
right: 8px;
width: 20px;
height: 16px;
opacity: 0.1;
transition: opacity 0.2s;
cursor: pointer;
z-index: 5;
color: inherit;
display: block;
}
.pondpilot-widget:hover .pondpilot-duck {
opacity: 0.2;
}
.pondpilot-duck:hover {
opacity: 0.3;
transform: scale(1.1);
}
/* Subtle branding */
.pondpilot-powered {
position: absolute;
bottom: 4px;
right: 32px;
font-size: 10px;
color: #9ca3af;
opacity: 0;
transition: opacity 0.2s;
}
.pondpilot-widget:hover .pondpilot-powered {
opacity: 1;
}
.pondpilot-powered a {
color: inherit;
text-decoration: none;
}
.pondpilot-powered a:hover {
color: #3b82f6;
}
/* SQL syntax highlighting */
.sql-hl-keyword {
color: #0969da;
font-weight: 600;
}
.pondpilot-widget.dark .sql-hl-keyword {
color: #7ee787;
}
.sql-hl-string {
color: #032f62;
}
.pondpilot-widget.dark .sql-hl-string {
color: #a5d6ff;
}
.sql-hl-number {
color: #0550ae;
}
.pondpilot-widget.dark .sql-hl-number {
color: #79c0ff;
}
.sql-hl-comment {
color: #6e7781;
font-style: italic;
}
.pondpilot-widget.dark .sql-hl-comment {
color: #8b949e;
}
.sql-hl-special {
color: #cf222e;
}
.pondpilot-widget.dark .sql-hl-special {
color: #ff7b72;
}
.sql-hl-identifier {
color: #953800;
}
.pondpilot-widget.dark .sql-hl-identifier {
color: #ffa657;
}
`;
// Utility functions
function escapeHtml(unsafe) {
return unsafe.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Widget class
class PondPilotWidget {
constructor(element, options = {}) {
this.element = element;
this.options = { ...config, ...options };
this.originalCode = this.extractCode();
this.init();
}
extractCode() {
const pre = this.element.tagName === "PRE" ? this.element : this.element.querySelector("pre");
const code = pre.querySelector("code") || pre;
return code.textContent.trim();
}
init() {
// Create widget container
this.widget = document.createElement("div");
this.widget.className = `pondpilot-widget ${this.options.theme}`;
// Create editor
this.editor = this.createEditor();
this.widget.appendChild(this.editor);
// Create button container with run and reset buttons
this.buttonContainer = this.createButtonContainer();
this.widget.appendChild(this.buttonContainer);
// Create output area
this.output = this.createOutput();
this.widget.appendChild(this.output);
// Create subtle powered by link
if (this.options.showPoweredBy !== false) {
this.poweredBy = this.createPoweredBy();
this.widget.appendChild(this.poweredBy);
// Add duck watermark only when branding is shown
this.duck = this.createDuckLogo();
this.widget.appendChild(this.duck);
}
// Replace original element
this.element.parentNode.replaceChild(this.widget, this.element);
// DuckDB will be loaded on first interaction
this.duckdbReady = false;
}
createButtonContainer() {
const container = document.createElement("div");
container.className = "pondpilot-button-container";
// Create reset button first (appears left in flex)
this.resetButton = this.createResetButton();
container.appendChild(this.resetButton);
// Create run button (appears right in flex)
this.runButton = this.createRunButton();
container.appendChild(this.runButton);
return container;
}
createRunButton() {
const button = document.createElement("button");
button.className = "pondpilot-run-button";
button.textContent = "Run";
button.setAttribute("aria-label", "Run SQL query");
button.onclick = () => this.run();
return button;
}
createResetButton() {
const button = document.createElement("button");
button.className = "pondpilot-reset-button";
button.textContent = "Reset";
button.setAttribute("aria-label", "Reset to original SQL");
button.onclick = () => this.reset();
return button;
}
getCursorOffset(element, range) {
const preRange = range.cloneRange();
preRange.selectNodeContents(element);
preRange.setEnd(range.endContainer, range.endOffset);
return preRange.toString().length;
}
setCursorOffset(element, offset) {
const textNodes = this.getTextNodes(element);
let currentOffset = 0;
for (const node of textNodes) {
const nodeLength = node.textContent.length;
if (currentOffset + nodeLength >= offset) {
const range = document.createRange();
range.setStart(node, offset - currentOffset);
range.collapse(true);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
break;
}
currentOffset += nodeLength;
}
}
getTextNodes(element) {
const textNodes = [];
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
return textNodes;
}
createEditor() {
const editor = document.createElement("div");
editor.className = "pondpilot-editor";
const pre = document.createElement("pre");
pre.innerHTML = sqlHighlight.highlight(this.originalCode, { html: true });
editor.appendChild(pre);
// Initialize current code
this.currentCode = this.originalCode;
// Make the pre element editable
if (this.options.editable !== false) {
pre.contentEditable = true;
pre.spellcheck = false;
pre.setAttribute("role", "textbox");
pre.setAttribute("aria-label", "SQL editor");
pre.setAttribute("aria-multiline", "true");
// Create debounced highlight function
const highlightDebounced = debounce((text, cursorOffset) => {
// Re-highlight
pre.innerHTML = sqlHighlight.highlight(text, { html: true });
// Restore cursor position
this.setCursorOffset(pre, cursorOffset);
// Update reset button visibility
if (text !== this.originalCode) {
this.resetButton.classList.add("show");
} else {
this.resetButton.classList.remove("show");
}
}, CONSTANTS.DEBOUNCE_DELAY); // Debounce for smooth typing
// Track changes and re-highlight
pre.addEventListener("input", () => {
const text = pre.textContent;
this.currentCode = text;
// Preserve cursor position
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const cursorOffset = this.getCursorOffset(pre, range);
// For small text, highlight immediately, for large text debounce
if (text.length < CONSTANTS.LARGE_SQL_THRESHOLD) {
// Re-highlight immediately for small SQL
pre.innerHTML = sqlHighlight.highlight(text, { html: true });
this.setCursorOffset(pre, cursorOffset);
// Update reset button visibility
if (text !== this.originalCode) {
this.resetButton.classList.add("show");
} else {
this.resetButton.classList.remove("show");
}
} else {
// Debounce for large SQL
highlightDebounced(text, cursorOffset);
}
});
}
// Add keyboard shortcut (Ctrl/Cmd + Enter to run)
editor.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
this.run();
}
});
return editor;
}
createOutput() {
const output = document.createElement("div");
output.className = "pondpilot-output";
const content = document.createElement("div");
content.className = "pondpilot-output-content";
output.appendChild(content);
return output;
}
createPoweredBy() {
const powered = document.createElement("div");
powered.className = "pondpilot-powered";
// Create link element programmatically to prevent XSS
const link = document.createElement("a");
// Sanitize baseUrl - only allow http/https URLs
let safeBaseUrl = this.options.baseUrl;
try {
const url = new URL(safeBaseUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
safeBaseUrl = config.baseUrl; // Fall back to default
}
} catch (e) {
safeBaseUrl = config.baseUrl; // Fall back to default if invalid URL
}
link.href = safeBaseUrl;
link.target = "_blank";
link.rel = "noopener";
link.textContent = "PondPilot";
powered.appendChild(link);
return powered;
}
createDuckLogo() {
const duck = document.createElement("a");
// Reuse the same URL sanitization logic
let safeBaseUrl = this.options.baseUrl;
try {
const url = new URL(safeBaseUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
safeBaseUrl = config.baseUrl;
}
} catch (e) {
safeBaseUrl = config.baseUrl;
}
duck.href = safeBaseUrl;
duck.target = "_blank";
duck.rel = "noopener";
duck.className = "pondpilot-duck";
duck.title = "Open in PondPilot";
duck.innerHTML = `<svg viewBox="0 0 51 42" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 42C6.04416 42 3.25905e-07 35.9558 0 28.5C-3.25905e-07 21.0442 6.04415 15 13.5 15H25.5C32.9558 15 39 21.0442 39 28.5C39 35.9558 32.9558 42 25.5 42H13.5Z" fill-opacity="0.32"/>
<path d="M31.5 27C24.0442 27 18 20.9558 18 13.5C18 6.04416 24.0442 3.25905e-07 31.5 0C38.9558 -3.25905e-07 45 6.04416 45 13.5C45 20.9558 38.9558 27 31.5 27Z"/>
<path d="M43.5 15C44.3284 15 45 14.3284 45 13.5C45 12.6716 44.3284 12 43.5 12C42.6716 12 42 12.6716 42 13.5C42 14.3284 42.6716 15 43.5 15Z"/>
<path d="M31.5 15C32.3284 15 33 14.3284 33 13.5C33 12.6716 32.3284 12 31.5 12C30.6716 12 30 12.6716 30 13.5C30 14.3284 30.6716 15 31.5 15Z"/>
<path d="M37.5 24C35.0147 24 33 21.9853 33 19.5C33 17.0147 35.0147 15 37.5 15L46.5 15C48.9853 15 51 17.0147 51 19.5C51 21.9853 48.9853 24 46.5 24H37.5Z"/>
<path d="M30.8908 28.971C30.7628 30.9568 29.94 32.9063 28.4223 34.424C25.1074 37.7388 19.733 37.7388 16.4181 34.424L10.418 28.4238L16.4179 22.4239C19.7327 19.1091 25.1072 19.1091 28.422 22.4239C30.2181 24.22 31.0411 26.6208 30.8908 28.971Z" fill-opacity="0.32"/>
</svg>`;
return duck;
}
async processRelativeParquetPaths(sql) {
// Regular expression to match file paths in FROM clauses
// Matches: FROM 'path.parquet', FROM "path.parquet", or FROM path.parquet
const fromPattern = /FROM\s+['"]?([^'";\s]+\.parquet)['"]?/gi;
// Keep track of registered files to avoid duplicate registrations
if (!this.registeredFiles) {
this.registeredFiles = new Set();
}
let processedSql = sql;
const matches = [...sql.matchAll(fromPattern)];
for (const match of matches) {
const filePath = match[1];
// Check if it's already an HTTP URL
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
continue;
}
// Check if this is a relative path (not already registered)
if (!this.registeredFiles.has(filePath)) {
// Resolve the relative path to an absolute URL
const absoluteUrl = this.resolveRelativePath(filePath);
try {
// Register the file with DuckDB
await sharedDuckDB.registerFileURL(
filePath,
absoluteUrl,
duckDBModule.DuckDBDataProtocol.HTTP,
false
);
this.registeredFiles.add(filePath);
} catch (error) {
console.error(`Failed to register file ${filePath}:`, error);
// Continue with other files even if one fails
}
}
}
return processedSql;
}
resolveRelativePath(relativePath) {
// Get the current page URL
const currentUrl = window.location.href;
// Create a URL object to properly resolve the relative path
try {
// If the current page is a file:// URL, we need to handle it differently
if (currentUrl.startsWith('file://')) {
// For file:// URLs, we'll assume the parquet file is served via HTTP on the same host
// This is a common setup for local development
const host = window.location.hostname || 'localhost';
const port = window.location.port || '8080';
const baseUrl = `http://${host}:${port}/`;
return new URL(relativePath, baseUrl).href;
} else {
// For HTTP(S) URLs, resolve relative to the current page
return new URL(relativePath, currentUrl).href;
}
} catch (error) {
// If URL construction fails, try a simpler approach
// Remove leading slashes for consistency
const cleanPath = relativePath.replace(/^\/+/, '');
// If we're on localhost, assume files are served from the same server
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
const protocol = window.location.protocol === 'file:' ? 'http:' : window.location.protocol;
const port = window.location.port || '8080';
return `${protocol}//${window.location.hostname}:${port}/${cleanPath}`;
}
// For other hosts, resolve relative to the current directory
const basePath = currentUrl.substring(0, currentUrl.lastIndexOf('/') + 1);
return basePath + cleanPath;
}
}
async initDuckDB() {
try {
this.runButton.textContent = "Loading...";
this.runButton.disabled = true;
this.widget.setAttribute("aria-busy", "true");
// Show loading progress
this.showProgress("Initializing DuckDB...", 0);
// Use shared DuckDB instance if available
if (!duckDBInitPromise) {
duckDBInitPromise = this.createSharedDuckDB((progress, message) => {
this.showProgress(message, progress);
});
}
await duckDBInitPromise;
// Create a connection to the shared database
this.showProgress("Creating connection...", CONSTANTS.PROGRESS_STEPS.CREATING_CONNECTION);
this.conn = await sharedDuckDB.connect();
this.duckdbReady = true;
this.runButton.textContent = "Run";
this.runButton.disabled = false;
// Hide progress
this.output.classList.remove("show");
this.widget.setAttribute("aria-busy", "false");
} catch (error) {
console.error("Failed to initialize DuckDB:", error);
this.runButton.textContent = "Error";
this.showError("Failed to initialize DuckDB: " + error.message);
this.widget.setAttribute("aria-busy", "false");
}
}
showProgress(message, percent) {
const outputContent = this.output.querySelector(".pondpilot-output-content");
const safePercent = Math.min(100, Math.max(0, percent));
outputContent.innerHTML = `
<div class="pondpilot-progress" role="status" aria-live="polite">
<div class="pondpilot-progress-text">${escapeHtml(message)}</div>
<div class="pondpilot-progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${safePercent}" aria-label="Loading progress">
<div class="pondpilot-progress-fill" style="width: ${safePercent}%"></div>
</div>
</div>
`;
this.output.classList.add("show");
}
async createSharedDuckDB(progressCallback) {
try {
// Track which widget initiated the loading
this.progressCallback = progressCallback || (() => {});
// Dynamically import DuckDB WASM
this.progressCallback(CONSTANTS.PROGRESS_STEPS.MODULE_LOADING, "Loading DuckDB module...");
const duckdbUrl = `${config.duckdbCDN}@${config.duckdbVersion}/+esm`;
duckDBModule = await import(duckdbUrl);
const duckdb = duckDBModule;
// Get the bundles from jsDelivr
this.progressCallback(CONSTANTS.PROGRESS_STEPS.FETCHING_BUNDLES, "Fetching bundles...");
const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
// Select the best bundle for the browser
this.progressCallback(CONSTANTS.PROGRESS_STEPS.SELECTING_BUNDLE, "Selecting best bundle...");
const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
// Create the worker - try direct URL first for CSP compatibility
this.progressCallback(CONSTANTS.PROGRESS_STEPS.CREATING_WORKER, "Creating worker...");
let worker;
try {
// First try direct worker URL (CSP-friendly)
worker = new Worker(bundle.mainWorker);
} catch (e) {
// Fallback to blob URL if direct loading fails
try {
const worker_url = URL.createObjectURL(new Blob([`importScripts("${bundle.mainWorker}");`], { type: "text/javascript" }));
worker = new Worker(worker_url);
} catch (blobError) {
throw new Error(
"Failed to create worker. This may be due to Content Security Policy restrictions. Please ensure your site allows worker-src 'self' blob: or use a CSP-compatible hosting setup.",
);
}
}
const logger = new duckdb.ConsoleLogger(duckdb.LogLevel.WARNING);
// Initialize the shared database
this.progressCallback(CONSTANTS.PROGRESS_STEPS.INITIALIZING_DB, "Initializing database...");
sharedDuckDB = new duckdb.AsyncDuckDB(logger, worker);
this.progressCallback(CONSTANTS.PROGRESS_STEPS.LOADING_MODULE, "Loading PondPilot module...");
await sharedDuckDB.instantiate(bundle.mainModule, bundle.pthreadWorker);
return sharedDuckDB;
} catch (error) {
throw error;
}
}
async run() {
const code = this.currentCode || this.editor.querySelector("pre").textContent.trim();
if (!code) return;
// Initialize DuckDB on first run
if (!this.duckdbReady) {
await this.initDuckDB();
if (!this.duckdbReady) {
return; // Error was already shown by initDuckDB
}
}
this.runButton.disabled = true;
this.runButton.textContent = "Running...";
this.output.classList.add("show");
this.widget.setAttribute("aria-busy", "true");
const outputContent = this.output.querySelector(".pondpilot-output-content");
outputContent.innerHTML = '<div class="pondpilot-loading">Running query...</div>';
// Track loading start time for minimum duration
const loadingStartTime = performance.now();
try {
// Process the SQL to handle relative parquet paths
const processedCode = await this.processRelativeParquetPaths(code);
const queryStartTime = performance.now();
const result = await this.conn.query(processedCode);
const elapsed = Math.round(performance.now() - queryStartTime);
const table = result.toArray();
const data = table.map((row) => row.toJSON());
// Ensure minimum loading duration for smooth UX
const loadingElapsed = performance.now() - loadingStartTime;
const remainingTime = Math.max(0, CONSTANTS.MIN_LOADING_DURATION - loadingElapsed);
if (remainingTime > 0) {
await new Promise(resolve => setTimeout(resolve, remainingTime));
}
this.displayResults(data, elapsed);
this.runButton.textContent = "Run";
} catch (error) {
// Ensure minimum loading duration even for errors
const loadingElapsed = performance.now() - loadingStartTime;
const remainingTime = Math.max(0, CONSTANTS.MIN_LOADING_DURATION - loadingElapsed);
if (remainingTime > 0) {
await new Promise(resolve => setTimeout(resolve, remainingTime));
}
this.showError(error.message);
this.runButton.textContent = "Run";
} finally {
this.runButton.disabled = false;
this.widget.setAttribute("aria-busy", "false");
}
}
displayResults(data, elapsed) {
const outputContent = this.output.querySelector(".pondpilot-output-content");
if (data.length === 0) {
outputContent.innerHTML = '<div style="text-align: center; color: #6b7280;">No results</div>';
return;
}
// Create table
const table = document.createElement("table");
// Header
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
Object.keys(data[0]).forEach((key) => {
const th = document.createElement("th");
th.textContent = key;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
// Body
const tbody = document.createElement("tbody");
data.forEach((row) => {
const tr = document.createElement("tr");
Object.values(row).forEach((value) => {
const td = document.createElement("td");
td.textContent = value === null ? "null" : String(value);
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
outputContent.innerHTML = "";
outputContent.appendChild(table);
// Add footer with info
const footer = document.createElement("div");
footer.className = "pondpilot-results-footer";
// Row count and timing
const info = document.createElement("div");
info.className = "pondpilot-results-info";
info.textContent = `${data.length} rows • ${elapsed}ms`;
footer.appendChild(info);
outputContent.appendChild(footer);
// Show reset button
this.resetButton.classList.add("show");
}
reset() {
this.editor.querySelector("pre").innerHTML = sqlHighlight.highlight(this.originalCode, { html: true });
this.output.classList.remove("show");
this.runButton.textContent = "Run";
this.resetButton.classList.remove("show");
this.currentCode = this.originalCode;
}
showError(message) {
const outputContent = this.output.querySelector(".pondpilot-output-content");
// Improve common error messages
let improvedMessage = message;
let suggestion = "";
if (message.includes("no such table") || message.includes("does not exist")) {
suggestion = "Tip: Make sure to CREATE TABLE before querying it.";
} else if (message.includes("syntax error") || message.includes("Parser Error")) {
suggestion = "Tip: Check your SQL syntax. Common issues: missing semicolon, typos in keywords.";
} else if (message.includes("no such column")) {
suggestion = "Tip: Check column names for typos and ensure they exist in the table.";
} else if (message.includes("SharedArrayBuffer") || message.includes("COOP") || message.includes("COEP")) {
improvedMessage = "Browser security error";
suggestion =
"Your browser requires special headers for DuckDB WASM. Try using Chrome or Firefox, or contact your site administrator.";
} else if (message.includes("Out of Memory")) {
improvedMessage = "Memory limit exceeded";
suggestion = "Tip: Try using LIMIT to reduce result size, or process data in smaller chunks.";
} else if (message.includes("No files found that match the pattern")) {
improvedMessage = "File not found";
suggestion = "Tip: For relative paths, ensure the file is accessible via HTTP from the same server. Try using a full URL like 'http://localhost:8080/file.parquet'.";
} else if (message.includes("HTTP") && message.includes("404")) {
improvedMessage = "File not found (404)";
suggestion = "Tip: The file could not be loaded from the resolved URL. Check that the file exists and is served by your web server.";
}
outputContent.innerHTML = `
<div class="pondpilot-error" role="alert" aria-live="assertive">
<div>${escapeHtml(improvedMessage)}</div>
${suggestion ? `<div style="margin-top: 8px; opacity: 0.8; font-size: 11px;">${escapeHtml(suggestion)}</div>` : ""}
</div>
`;
this.output.classList.add("show");
// Show reset button on error
this.resetButton.classList.add("show");
}
async cleanup() {
// Close the connection when widget is destroyed
if (this.conn) {
await this.conn.close();
this.conn = null;
}
// Remove event listeners
if (this.highlightDebounced) {
this.highlightDebounced = null;
}
}
destroy() {
// Call async cleanup
this.cleanup().catch(console.error);
// Remove from instances
widgetInstances.delete(this.widget);
// Clear references
this.widget = null;
this.editor = null;
this.output = null;
this.runButton = null;
this.resetButton = null;
}
}
// Track all widget instances for cleanup
const widgetInstances = new WeakMap();
// Initialize widgets
function init() {
// Add styles
if (!document.getElementById("pondpilot-widget-styles")) {
const styleSheet = document.createElement("style");
styleSheet.id = "pondpilot-widget-styles";
styleSheet.textContent = styles;
document.head.appendChild(styleSheet);
}
// Find and initialize widgets
const elements = document.querySelectorAll(config.selector);
elements.forEach((element) => {
if (!element.dataset.pondpilotWidget) {
element.dataset.pondpilotWidget = "true";
const widget = new PondPilotWidget(element);
widgetInstances.set(widget.widget, widget);
}
});
}
// Auto-initialize on DOM ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Set up mutation observer to clean up removed widgets
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.removedNodes.forEach((node) => {
// Check if the removed node or its descendants contain widgets
if (node.nodeType === Node.ELEMENT_NODE) {
const widgets = node.classList?.contains("pondpilot-widget") ? [node] : node.querySelectorAll?.(".pondpilot-widget") || [];
widgets.forEach((widgetElement) => {
const widget = widgetInstances.get(widgetElement);
if (widget) {
widget.destroy();
}
});
}
});
});
});
// Start observing once DOM is ready
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
} else {
document.addEventListener("DOMContentLoaded", () => {
observer.observe(document.body, { childList: true, subtree: true });
});
}
// Expose API
window.PondPilot = {
init,
Widget: PondPilotWidget,
config,
destroy: () => {
// Clean up all widgets
document.querySelectorAll(".pondpilot-widget").forEach((element) => {
const widget = widgetInstances.get(element);
if (widget) {
widget.destroy();
}
});
// Stop observing
observer.disconnect();
},
};
})();