Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# CHANGELOG.md

## v0.47.0 (unreleased)
- Loading embedded card content no longer adds duplicate form submission or file validation handlers to existing fields. Fragment initialization also includes the element announcing the fragment.
- **Mac users:** the downloadable `sqlpage-macos.tgz` is now code-signed with a Developer ID certificate and notarized by Apple. macOS may still ask you to confirm opening it the first time. The binary can still load third-party ODBC database drivers.
- **Mac users:** the downloadable `sqlpage-macos.tgz` now runs natively on Apple silicon (M-series Macs) and no longer runs on Intel Macs. Homebrew remains the recommended and easiest installation method. On an Intel Mac, [install Homebrew](https://brew.sh/) if needed, then run `brew install sqlpage` (or `brew update` followed by `brew upgrade sqlpage` if you already installed it with Homebrew). Open Terminal in your existing website folder and run `sqlpage` instead of `./sqlpage.bin`; keep your SQL files, database, and `sqlpage` configuration folder in place. Intel installations may build from source and take longer; see the [macOS installation guide](https://sql-page.com/your-first-sql-website/?os=macos#download) for setup and older macOS requirements.
- Chart data points can now include a `link`. Clicking a point or its tooltip value opens that URL; a text x value in the tooltip title links to it too. The tooltip remains open while the pointer moves onto the link. ApexCharts was updated to [v7.6.0](https://github.com/apexcharts/apexcharts.js/releases/tag/v7.6.0) to support this.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ INSERT INTO parameter(component, name, description_md, type, top_level, optional
('embed', 'A url whose contents will be fetched and injected into the body of this card.
This can be used to inject arbitrary html content, but is especially useful for injecting
the output of other sql files rendered by SQLPage. For the latter case you can pass the
`?_sqlpage_embed` query parameter, which will skip the shell layout', 'TEXT', FALSE, TRUE),
`?_sqlpage_embed` query parameter, which will skip the shell layout.
Components inside the fetched content initialize automatically. Custom scripts that insert
SQLPage fragments should dispatch a bubbling `fragment-loaded` event on the element containing
the new content. Initialization includes that element and its descendants; dispatching the
event on `document` initializes pending components throughout the page. Repeated events
preserve existing form and widget handlers', 'TEXT', FALSE, TRUE),
('embed_mode', 'Set to ''iframe'' to embed the target (specified through embed property) in an iframe.
Unless this is explicitly set, the embed target is fetched and injected within the parent page. If embed_mode is set to iframe,
You can also set height and width parameters to configure the appearance and the sandbox and allow parameters to configure
Expand Down
8 changes: 3 additions & 5 deletions frontend/src/apexcharts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type Series,
xaxis_type_for,
} from "./chart_series.ts";
import { add_init_fn } from "./init.ts";
import { add_init_fn, type InitRoot, select_all } from "./init.ts";

type DataPoint = {
name: string;
Expand Down Expand Up @@ -51,10 +51,8 @@ const numberLabel = (value: string | number) =>
value == null ? "" : value.toLocaleString?.() || String(value);

const sqlpage_chart = (() => {
function sqlpage_chart() {
const charts = document.querySelectorAll<HTMLElement>(
"[data-pre-init=chart]",
);
function sqlpage_chart(root: InitRoot) {
const charts = select_all<HTMLElement>(root, "[data-pre-init=chart]");
for (const c of charts) {
try {
build_sqlpage_chart(c);
Expand Down
40 changes: 36 additions & 4 deletions frontend/src/init.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
export function add_init_fn(f: () => void) {
document.addEventListener("DOMContentLoaded", f);
document.addEventListener("fragment-loaded", f);
if (document.readyState !== "loading") setTimeout(f, 0);
export type InitRoot = Element | Document;

// The script element carries roots across independently bundled initializers.
export type InitScript = HTMLScriptElement & {
sqlpage_init_roots?: Set<InitRoot>;
};

/** Select matching descendants, including the fragment root itself. */
export function select_all<T extends Element>(
root: InitRoot,
selector: string,
): T[] {
const descendants = [...root.querySelectorAll<T>(selector)];
if (root instanceof Element && root.matches(selector)) {
descendants.unshift(root as T);
}
return descendants;
}

export function add_init_fn(f: (root: InitRoot) => void) {
const script = document.currentScript as InitScript | null;
const pending_roots = script?.sqlpage_init_roots;
const initialize = () => {
if (script) delete script.sqlpage_init_roots;
for (const root of pending_roots ?? [document]) {
if (root instanceof Document || root.isConnected) f(root);
}
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initialize, { once: true });
} else {
setTimeout(initialize, 0);
Comment on lines +29 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Initialize components before DOMContentLoaded callbacks run

On normal pages sqlpage.js is deferred, so it executes while document.readyState is already interactive; this branch postpones every initializer to a timer, which runs after DOMContentLoaded. Custom javascript scripts included by shell.handlebars are also deferred and can therefore receive DOMContentLoaded while forms, tables, Bootstrap widgets, and lazy bundles are still uninitialized. Previously SQLPage registered its DOMContentLoaded listeners before those custom scripts, so integrations that access initialized components from that event now race or fail; retain a one-shot initialization during DOMContentLoaded rather than deferring it past the event.

Useful? React with 👍 / 👎.

}
document.addEventListener("fragment-loaded", ({ target }) => {
if (target instanceof Element || target instanceof Document) f(target);
});
}
116 changes: 72 additions & 44 deletions frontend/src/sqlpage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { bootstrap as bundled_bootstrap } from "@tabler/core";
import type * as Leaflet from "leaflet";
import { add_init_fn } from "./init.ts";
import {
add_init_fn,
type InitRoot,
type InitScript,
select_all,
} from "./init.ts";

// A page may load its own Bootstrap; prefer it over the bundled copy.
const page_bootstrap = () => window.bootstrap ?? bundled_bootstrap;
Expand All @@ -14,8 +19,8 @@ type ModalWidget = InstanceType<typeof bundled_bootstrap.Modal>;

const nonce = (document.currentScript as HTMLScriptElement).nonce;

function sqlpage_card() {
const cards = document.querySelectorAll<HTMLElement>("[data-pre-init=card]");
function sqlpage_card(root: InitRoot) {
const cards = select_all<HTMLElement>(root, "[data-pre-init=card]");
for (const c of cards) {
c.removeAttribute("data-pre-init");
if (!c.dataset.embed) continue;
Expand Down Expand Up @@ -173,10 +178,8 @@ function setup_sort_behavior(
});
}

function sqlpage_table() {
const tables = document.querySelectorAll<HTMLElement>(
"[data-pre-init=table]",
);
function sqlpage_table(root: InitRoot) {
const tables = select_all<HTMLElement>(root, "[data-pre-init=table]");
for (const r of tables) {
r.removeAttribute("data-pre-init");
try {
Expand All @@ -198,9 +201,14 @@ type MarkerStyle = Leaflet.MarkerOptions &

let is_leaflet_injected = false;
let is_leaflet_loaded = false;
const pending_maps = new Set<HTMLElement>();

function sqlpage_map() {
const first_map = document.querySelector("[data-pre-init=map]");
function sqlpage_map(root: InitRoot) {
const maps = select_all<HTMLElement>(root, "[data-pre-init=map]");
const first_map = maps[0];
if (!is_leaflet_loaded) {
for (const map of maps) pending_maps.add(map);
}
const leaflet_base_url = "https://cdn.jsdelivr.net/npm/leaflet@1.9.4";
if (first_map && !is_leaflet_injected) {
// Add the leaflet js and css to the page
Expand All @@ -217,12 +225,19 @@ function sqlpage_map() {
"sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
leaflet_js.crossOrigin = "anonymous";
leaflet_js.nonce = nonce;
leaflet_js.onload = onLeafletLoad;
// Preserve each announced map, including maps arriving during loading.
leaflet_js.onload = () => {
is_leaflet_loaded = true;
for (const map of pending_maps) {
if (map.isConnected) onLeafletLoad(map);
}
pending_maps.clear();
};
document.head.appendChild(leaflet_js);
is_leaflet_injected = true;
}
if (first_map && is_leaflet_loaded) {
onLeafletLoad();
onLeafletLoad(root);
}
function parseCoords(
coords: string | undefined,
Expand All @@ -237,9 +252,9 @@ function sqlpage_map() {
}
return [parsed[0], parsed[1]];
}
function onLeafletLoad() {
function onLeafletLoad(map_root: InitRoot) {
is_leaflet_loaded = true;
const maps = document.querySelectorAll<HTMLElement>("[data-pre-init=map]");
const maps = select_all<HTMLElement>(map_root, "[data-pre-init=map]");
for (const m of maps) {
const tile_source = m.dataset.tile_source;
const maxZoom = Number(m.dataset.max_zoom);
Expand Down Expand Up @@ -344,11 +359,17 @@ function sqlpage_map() {
}
}

function sqlpage_form() {
const file_inputs = document.querySelectorAll<HTMLInputElement>(
const initialized_file_inputs = new WeakSet<HTMLInputElement>();
const initialized_auto_submit_forms = new WeakSet<HTMLFormElement>();

function sqlpage_form(root: InitRoot) {
const file_inputs = select_all<HTMLInputElement>(
root,
"input[type=file][data-max-size]",
);
for (const input of file_inputs) {
if (initialized_file_inputs.has(input)) continue;
initialized_file_inputs.add(input);
const max_size = Number(input.dataset.maxSize);
input.addEventListener("change", () => {
input.classList.remove("is-invalid");
Expand All @@ -364,10 +385,13 @@ function sqlpage_form() {
});
}

const auto_submit_forms = document.querySelectorAll<HTMLFormElement>(
const auto_submit_forms = select_all<HTMLFormElement>(
root,
"form[data-auto-submit]",
);
for (const form of auto_submit_forms) {
if (initialized_auto_submit_forms.has(form)) continue;
initialized_auto_submit_forms.add(form);
form.addEventListener("change", () => form.submit());
}
}
Expand All @@ -378,17 +402,22 @@ function get_tabler_color(name: string) {
);
}

function load_scripts() {
const addjs = document.querySelectorAll<HTMLElement>("[data-sqlpage-js]");
const existing_scripts = new Set(
[...document.querySelectorAll("script")].map((s) => s.src),
function load_scripts(root: InitRoot) {
const addjs = select_all<HTMLElement>(root, "[data-sqlpage-js]");
const existing_scripts = new Map(
[...document.querySelectorAll<InitScript>("script")].map((s) => [s.src, s]),
);
for (const el of addjs) {
if (!el.dataset.sqlpageJs) continue;
const js = new URL(el.dataset.sqlpageJs, window.location.href).href;
if (existing_scripts.has(js)) continue;
existing_scripts.add(js);
const script = document.createElement("script");
const existing = existing_scripts.get(js);
if (existing) {
existing.sqlpage_init_roots?.add(el);
continue;
}
const script: InitScript = document.createElement("script");
script.sqlpage_init_roots = new Set([el]);
existing_scripts.set(js, script);
script.src = js;
document.head.appendChild(script);
}
Expand Down Expand Up @@ -429,13 +458,11 @@ function restore_focus_after_toast(toast: HTMLElement, container: HTMLElement) {
main.focus({ preventScroll: true });
}

function sqlpage_toast() {
function sqlpage_toast(root: InitRoot) {
const Toast = page_bootstrap().Toast;

const initialized_toasts: HTMLElement[] = [];
const toasts = document.querySelectorAll<HTMLElement>(
'[data-pre-init="toast"]',
);
const toasts = select_all<HTMLElement>(root, '[data-pre-init="toast"]');
for (const toast of toasts) {
const source_container = toast.parentElement;
if (!source_container) continue;
Expand Down Expand Up @@ -479,7 +506,7 @@ function sqlpage_toast() {
open_toasts_for_hash(initialized_toasts);
}

function sqlpage_modal() {
function sqlpage_modal(root: InitRoot) {
// Bootstrap modals use position: fixed and are documented to live as
// direct children of <body>
// (https://getbootstrap.com/docs/5.3/components/modal/#how-it-works).
Expand All @@ -488,8 +515,11 @@ function sqlpage_modal() {
// .page instead of the viewport. The modal then scrolls with the page
// content and ends up behind its own backdrop, so its buttons cannot be
// clicked. Moving modals to <body> keeps them viewport-fixed.
for (const modal of document.querySelectorAll("body .page .modal")) {
for (const modal of select_all<HTMLElement>(root, "body .page .modal")) {
document.body.appendChild(modal);
Comment thread
lovasoa marked this conversation as resolved.
// The modal leaves the original root; announce its subtree separately so
// every initializer, including later-loaded bundles, can still see it.
modal.dispatchEvent(new CustomEvent("fragment-loaded", { bubbles: true }));
}
}

Expand All @@ -506,34 +536,32 @@ window.addEventListener("hashchange", () =>
),
);

function init_bootstrap_components(fragment: Element | Document) {
function init_bootstrap_components(root: InitRoot) {
const bootstrap = page_bootstrap();
for (const el of fragment.querySelectorAll<HTMLElement>(
for (const el of select_all<HTMLElement>(
root,
'[data-bs-toggle="tooltip"]',
)) {
new bootstrap.Tooltip(el);
bootstrap.Tooltip.getOrCreateInstance(el);
}
for (const el of fragment.querySelectorAll<HTMLElement>(
for (const el of select_all<HTMLElement>(
root,
'[data-bs-toggle="popover"]',
)) {
new bootstrap.Popover(el);
bootstrap.Popover.getOrCreateInstance(el);
}
for (const el of fragment.querySelectorAll<HTMLElement>(
for (const el of select_all<HTMLElement>(
root,
'[data-bs-toggle="dropdown"]',
)) {
new bootstrap.Dropdown(el);
bootstrap.Dropdown.getOrCreateInstance(el);
}
for (const el of fragment.querySelectorAll<HTMLElement>(
'[data-bs-ride="carousel"]',
)) {
new bootstrap.Carousel(el);
for (const el of select_all<HTMLElement>(root, '[data-bs-ride="carousel"]')) {
bootstrap.Carousel.getOrCreateInstance(el);
}
}

document.addEventListener("fragment-loaded", ({ target }) => {
if (target instanceof Element || target instanceof Document)
init_bootstrap_components(target);
});
add_init_fn(init_bootstrap_components);

function open_modal_for_hash() {
const hash = window.location.hash.substring(1);
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/tomselect.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import TomSelect from "tom-select/popular";
import { add_init_fn } from "./init.ts";
import { add_init_fn, type InitRoot, select_all } from "./init.ts";

function sqlpage_select_dropdown() {
const selects = document.querySelectorAll<HTMLSelectElement>(
function sqlpage_select_dropdown(root: InitRoot) {
const selects = select_all<HTMLSelectElement>(
root,
"[data-pre-init=select-dropdown]",
);
for (const s of selects) {
Expand Down
Loading
Loading