Skip to content

Commit 71609f2

Browse files
authored
feat(typescript) :: typecheck browser JavaScript in CI (#1373)
1 parent bfd6d33 commit 71609f2

12 files changed

Lines changed: 601 additions & 94 deletions

package-lock.json

Lines changed: 386 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"name": "sqlpage",
33
"version": "1.0.0",
44
"scripts": {
5-
"test": "biome check . && node --test \"tests/js/**/*.spec.ts\"",
5+
"test": "biome check . && npm run typecheck && node --test \"tests/js/**/*.spec.ts\"",
6+
"typecheck": "tsc && tsc -p tests/js && tsc -p tests/end-to-end",
67
"format": "biome format --write .",
78
"fix": "biome check --fix --unsafe ."
89
},
@@ -12,7 +13,9 @@
1213
},
1314
"license": "MIT",
1415
"devDependencies": {
15-
"@biomejs/biome": "^2.5.4"
16+
"@biomejs/biome": "^2.5.4",
17+
"@types/node": "^26.2.0",
18+
"typescript": "^7.0.2"
1619
},
1720
"workspaces": [
1821
"tests/end-to-end"

sqlpage/apexcharts.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
sqlpage_chart = (() => {
44
function sqlpage_chart() {
5-
for (const c of document.querySelectorAll("[data-pre-init=chart]")) {
5+
/** @type {NodeListOf<HTMLElement>} */
6+
const charts = document.querySelectorAll("[data-pre-init=chart]");
7+
for (const c of charts) {
68
try {
79
build_sqlpage_chart(c);
810
} catch (e) {
@@ -69,7 +71,7 @@ sqlpage_chart = (() => {
6971
const with_lowest_x = unread
7072
.filter((xs) => xs.length > 0)
7173
.reduce((a, b) => (b[0] < a[0] ? b : a));
72-
const x = with_lowest_x.shift();
74+
const x = /** @type {XValue} */ (with_lowest_x.shift());
7375
merged.set(x_key(x), x);
7476
}
7577
return [...merged.values()];
@@ -120,7 +122,9 @@ sqlpage_chart = (() => {
120122
function build_sqlpage_chart(c) {
121123
const [data_element] = c.getElementsByTagName("data");
122124
const data = JSON.parse(data_element.textContent);
123-
const chartContainer = c.querySelector(".chart");
125+
const chartContainer = /** @type {HTMLElement} */ (
126+
c.querySelector(".chart")
127+
);
124128
chartContainer.innerHTML = "";
125129
const is_timeseries = !!data.time;
126130
const chart_type =

sqlpage/globals.d.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Names the browser bundle relies on at runtime rather than through an import:
2+
// libraries build.rs inlines ahead of our own code, libraries loaded on demand,
3+
// and the objects SQLPage's scripts hang off the window for each other.
4+
5+
/**
6+
* A library this project ships no type definitions for. Saying `unknown`
7+
* instead would only move the guesswork to a cast at every call site.
8+
*/
9+
// biome-ignore lint/suspicious/noExplicitAny: that is what an untyped library is
10+
type Untyped = any;
11+
12+
/** Leaflet, loaded from a CDN by sqlpage_map when a page holds a map. */
13+
declare const L: Untyped;
14+
15+
/** ApexCharts, inlined ahead of apexcharts.js by build.rs. */
16+
declare const ApexCharts: Untyped;
17+
18+
/** Tom Select, inlined ahead of tomselect.js by build.rs. */
19+
declare const TomSelect: Untyped;
20+
21+
/** apexcharts.js publishes its initialiser under this name. */
22+
declare var sqlpage_chart: () => void;
23+
24+
/**
25+
* Tabler's bundled Bootstrap, inlined ahead of sqlpage.js by build.rs. Its
26+
* widgets are untyped: naming a few of them here would only claim more than
27+
* this file knows.
28+
*/
29+
interface TablerBootstrap {
30+
[widget: string]: Untyped;
31+
}
32+
33+
interface Window {
34+
/** Every chart rendered on the page, in the order they were built. */
35+
charts?: unknown[];
36+
tabler: { bootstrap: TablerBootstrap };
37+
bootstrap?: TablerBootstrap;
38+
}

sqlpage/sqlpage.js

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
/* !include https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js */
2-
const nonce = document.currentScript.nonce;
2+
const nonce = /** @type {HTMLScriptElement} */ (document.currentScript).nonce;
33

44
function sqlpage_card() {
5-
for (const c of document.querySelectorAll("[data-pre-init=card]")) {
5+
/** @type {NodeListOf<HTMLElement>} */
6+
const cards = document.querySelectorAll("[data-pre-init=card]");
7+
for (const c of cards) {
68
c.removeAttribute("data-pre-init");
9+
if (!c.dataset.embed) continue;
710
const url = new URL(c.dataset.embed, window.location.href);
811
url.searchParams.set("_sqlpage_embed", "1");
912
fetch(url)
1013
.then((res) => res.text())
1114
.then((html) => {
1215
const body = c.querySelector(".card-content");
13-
body.innerHTML = html;
16+
if (body) body.innerHTML = html;
1417
const spinner = c.querySelector(".card-loading-placeholder");
15-
if (spinner) {
16-
spinner.parentNode.removeChild(spinner);
17-
}
18+
spinner?.remove();
1819
const fragLoadedEvt = new CustomEvent("fragment-loaded", {
1920
bubbles: true,
2021
});
@@ -28,14 +29,18 @@ function setup_table(root_el) {
2829
/** @type {HTMLInputElement | null} */
2930
const search_input = root_el.querySelector("input.search");
3031
const table_el = root_el.querySelector("table");
31-
const sort_buttons = [...table_el.querySelectorAll("button.sort[data-sort]")];
32+
if (!table_el) return;
33+
/** @type {NodeListOf<HTMLElement>} */
34+
const sort_button_els = table_el.querySelectorAll("button.sort[data-sort]");
35+
const sort_buttons = [...sort_button_els];
3236
const item_parent = table_el.querySelector("tbody");
3337
const has_sort = sort_buttons.length > 0;
3438

3539
if (search_input || has_sort) {
3640
const items = table_parse_data(table_el, sort_buttons);
3741
if (search_input) setup_table_search_behavior(search_input, items);
38-
if (has_sort) setup_sort_behavior(sort_buttons, items, item_parent);
42+
if (has_sort && item_parent)
43+
setup_sort_behavior(sort_buttons, items, item_parent);
3944
}
4045

4146
// Change number format AFTER parsing and storing the sort keys
@@ -44,7 +49,7 @@ function setup_table(root_el) {
4449

4550
/**
4651
* @param {HTMLInputElement} search_input
47-
* @param {Array<{el: HTMLElement, sort_keys: Array<{num: number, str: string}>}>} items
52+
* @param {TableRow[]} items
4853
*/
4954
function setup_table_search_behavior(search_input, items) {
5055
function onSearch() {
@@ -66,12 +71,15 @@ function setup_table_search_behavior(search_input, items) {
6671

6772
/**@param {HTMLElement} table_el */
6873
function apply_number_formatting(table_el) {
74+
/** @type {NodeListOf<HTMLElement>} */
6975
const header_els = table_el.querySelectorAll("thead > tr > th");
7076
const col_types = [...header_els].map((el) => el.dataset.column_type);
7177
const col_rawnums = [...header_els].map((el) => !!el.dataset.raw_number);
7278
const col_money = [...header_els].map((el) => !!el.dataset.money);
7379
const number_format_locale = table_el.dataset.number_format_locale;
74-
const number_format_digits = table_el.dataset.number_format_digits;
80+
const number_format_digits = table_el.dataset.number_format_digits
81+
? Number(table_el.dataset.number_format_digits)
82+
: undefined;
7583
const currency = table_el.dataset.currency;
7684

7785
for (const tr_el of table_el.querySelectorAll("tbody tr, tfoot tr")) {
@@ -95,20 +103,25 @@ function apply_number_formatting(table_el) {
95103
}
96104
}
97105

106+
/** @typedef { {el: HTMLElement, sort_keys: {num: number, str: string}[]} } TableRow */
107+
98108
/** Prepare the table rows for sorting.
99109
* @param {HTMLElement} table_el
100110
* @param {HTMLElement[]} sort_buttons
111+
* @returns {TableRow[]}
101112
*/
102113
function table_parse_data(table_el, sort_buttons) {
103114
const is_num = [...sort_buttons].map(
104-
(btn_el) => btn_el.parentElement.dataset.column_type === "number",
115+
(btn_el) => btn_el.parentElement?.dataset.column_type === "number",
105116
);
106-
return [...table_el.querySelectorAll("tbody tr")].map((tr_el) => {
117+
/** @type {NodeListOf<HTMLElement>} */
118+
const row_els = table_el.querySelectorAll("tbody tr");
119+
return [...row_els].map((tr_el) => {
107120
const cells = tr_el.getElementsByTagName("td");
108121
return {
109122
el: tr_el,
110123
sort_keys: sort_buttons.map((_btn_el, idx) => {
111-
const str = cells[idx]?.textContent;
124+
const str = cells[idx]?.textContent ?? "";
112125
const num = is_num[idx] ? Number.parseFloat(str) : Number.NaN;
113126
return { num, str };
114127
}),
@@ -119,7 +132,7 @@ function table_parse_data(table_el, sort_buttons) {
119132
/**
120133
* Adds event listeners to the sort buttons to sort the table rows.
121134
* @param {HTMLElement[]} sort_buttons
122-
* @param {HTMLElement[]} items
135+
* @param {TableRow[]} items
123136
* @param {HTMLElement} item_parent
124137
*/
125138
function setup_sort_behavior(sort_buttons, items, item_parent) {
@@ -147,7 +160,9 @@ function setup_sort_behavior(sort_buttons, items, item_parent) {
147160
}
148161

149162
function sqlpage_table() {
150-
for (const r of document.querySelectorAll("[data-pre-init=table]")) {
163+
/** @type {NodeListOf<HTMLElement>} */
164+
const tables = document.querySelectorAll("[data-pre-init=table]");
165+
for (const r of tables) {
151166
r.removeAttribute("data-pre-init");
152167
setup_table(r);
153168
}
@@ -198,13 +213,14 @@ function sqlpage_map() {
198213
}
199214
function onLeafletLoad() {
200215
is_leaflet_loaded = true;
216+
/** @type {NodeListOf<HTMLElement>} */
201217
const maps = document.querySelectorAll("[data-pre-init=map]");
202218
for (const m of maps) {
203219
const tile_source = m.dataset.tile_source;
204-
const maxZoom = +m.dataset.max_zoom;
220+
const maxZoom = Number(m.dataset.max_zoom);
205221
const attribution = m.dataset.attribution;
206222
const map = L.map(m, { attributionControl: !!attribution });
207-
const zoom = m.dataset.zoom;
223+
const zoom = Number(m.dataset.zoom);
208224
const center = parseCoords(m.dataset.center);
209225
if (tile_source)
210226
L.tileLayer(tile_source, { attribution, maxZoom }).addTo(map);
@@ -213,14 +229,14 @@ function sqlpage_map() {
213229
setTimeout(addMarker, 0, marker_elem, map);
214230
}
215231
setTimeout(() => {
216-
if (center) map.setView(center, +zoom);
232+
if (center) map.setView(center, zoom);
217233
else {
218234
const markerBounds = (m) =>
219235
m.getLatLng ? m.getLatLng() : m.getBounds();
220236
const bounds = map._sqlpage_markers.map(markerBounds);
221237
if (bounds.length > 0) map.fitBounds(bounds);
222-
else map.setView([51.505, 10], +zoom);
223-
if (zoom != null) map.setZoom(+zoom);
238+
else map.setView([51.505, 10], zoom);
239+
if (!Number.isNaN(zoom)) map.setZoom(zoom);
224240
}
225241
}, 100);
226242
m.removeAttribute("data-pre-init");
@@ -282,15 +298,16 @@ function sqlpage_map() {
282298
}
283299

284300
function sqlpage_form() {
301+
/** @type {NodeListOf<HTMLInputElement>} */
285302
const file_inputs = document.querySelectorAll(
286303
"input[type=file][data-max-size]",
287304
);
288305
for (const input of file_inputs) {
289-
const max_size = +input.dataset.maxSize;
290-
input.addEventListener("change", function () {
306+
const max_size = Number(input.dataset.maxSize);
307+
input.addEventListener("change", () => {
291308
input.classList.remove("is-invalid");
292309
input.setCustomValidity("");
293-
for (const { size } of this.files) {
310+
for (const { size } of input.files ?? []) {
294311
if (size > max_size) {
295312
input.classList.add("is-invalid");
296313
return input.setCustomValidity(
@@ -301,6 +318,7 @@ function sqlpage_form() {
301318
});
302319
}
303320

321+
/** @type {NodeListOf<HTMLFormElement>} */
304322
const auto_submit_forms = document.querySelectorAll("form[data-auto-submit]");
305323
for (const form of auto_submit_forms) {
306324
form.addEventListener("change", () => form.submit());
@@ -314,11 +332,13 @@ function get_tabler_color(name) {
314332
}
315333

316334
function load_scripts() {
335+
/** @type {NodeListOf<HTMLElement>} */
317336
const addjs = document.querySelectorAll("[data-sqlpage-js]");
318337
const existing_scripts = new Set(
319338
[...document.querySelectorAll("script")].map((s) => s.src),
320339
);
321340
for (const el of addjs) {
341+
if (!el.dataset.sqlpageJs) continue;
322342
const js = new URL(el.dataset.sqlpageJs, window.location.href).href;
323343
if (existing_scripts.has(js)) continue;
324344
existing_scripts.add(js);
@@ -375,11 +395,16 @@ function sqlpage_toast() {
375395
if (!Toast) return;
376396

377397
const initialized_toasts = [];
378-
for (const toast of document.querySelectorAll('[data-pre-init="toast"]')) {
398+
/** @type {NodeListOf<HTMLElement>} */
399+
const toasts = document.querySelectorAll('[data-pre-init="toast"]');
400+
for (const toast of toasts) {
379401
const source_container = toast.parentElement;
402+
if (!source_container) continue;
380403
const position = source_container.dataset.sqlpageToastPosition;
381-
let container = document.querySelector(
382-
`.toast-container[data-sqlpage-toast-position="${position}"]:not([data-pre-init])`,
404+
let container = /** @type {HTMLElement | null} */ (
405+
document.querySelector(
406+
`.toast-container[data-sqlpage-toast-position="${position}"]:not([data-pre-init])`,
407+
)
383408
);
384409
if (!container) {
385410
container = source_container;

sqlpage/tomselect.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/* !include https://cdn.jsdelivr.net/npm/tom-select@2.6.1/dist/js/tom-select.popular.min.js */
22

33
function sqlpage_select_dropdown() {
4-
for (const s of document.querySelectorAll(
5-
"[data-pre-init=select-dropdown]",
6-
)) {
4+
/** @type {NodeListOf<HTMLSelectElement>} */
5+
const selects = document.querySelectorAll("[data-pre-init=select-dropdown]");
6+
for (const s of selects) {
77
try {
88
sqlpage_select_dropdown_individual(s);
99
} catch (e) {

tests/end-to-end/globals.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// What the browser tests reach for on the page: widgets that the scripts under
2+
// test attach to elements at runtime, and libraries the page loads itself.
3+
4+
interface TomSelectInstance {
5+
getValue(): string | string[];
6+
setTextboxValue(value: string): void;
7+
focus(): void;
8+
options: Record<string, { label?: string } | undefined>;
9+
}
10+
11+
interface HTMLElement {
12+
/** Attached by sqlpage_select_dropdown to every select it takes over. */
13+
tomselect?: TomSelectInstance;
14+
}
15+
16+
interface Window {
17+
/** Tabler's bundled Bootstrap, inlined ahead of sqlpage.js by build.rs. */
18+
tabler?: {
19+
bootstrap: { Toast: { getInstance(element: Element): unknown } };
20+
};
21+
}

0 commit comments

Comments
 (0)