From b7d568d4e77310bf2196ec50f424b366ea6f5f04 Mon Sep 17 00:00:00 2001 From: 81reap Date: Sat, 15 Aug 2026 11:39:40 -0400 Subject: [PATCH] feat(frontend) :: upgrade to typescript --- .dockerignore | 1 + .github/actions/build-frontend/action.yml | 15 + .github/workflows/ci.yml | 7 +- .github/workflows/release.yml | 7 +- .gitignore | 1 + AGENTS.md | 7 +- CHANGELOG.md | 1 + CONTRIBUTING.md | 5 +- Cargo.lock | 2 - Cargo.toml | 5 +- Dockerfile | 9 +- build.rs | 178 ++---- frontend/build.mjs | 63 ++ frontend/src/apexcharts.ts | 272 ++++++++ frontend/src/chart_series.ts | 72 +++ {sqlpage => frontend/src}/favicon.svg | 0 frontend/src/init.ts | 5 + {sqlpage => frontend/src}/sqlpage.css | 6 +- sqlpage/sqlpage.js => frontend/src/sqlpage.ts | 216 +++---- .../tomselect.js => frontend/src/tomselect.ts | 31 +- frontend/types/globals.d.ts | 17 + lambda.Dockerfile | 8 + package-lock.json | 598 ++++++++++++++++++ package.json | 8 + scripts/predownload-offline-deps.sh | 32 +- sqlpage/apexcharts.js | 336 ---------- sqlpage/globals.d.ts | 38 -- sqlpage/tabler-icons.svg | 1 - tests/end-to-end/chart-component.spec.ts | 5 +- tests/end-to-end/globals.d.ts | 9 +- tests/end-to-end/map-component.spec.ts | 8 +- tests/end-to-end/official-site.spec.ts | 13 +- tests/js/chart_series.spec.ts | 26 +- tsconfig.json | 5 +- 34 files changed, 1270 insertions(+), 737 deletions(-) create mode 100644 .github/actions/build-frontend/action.yml create mode 100644 frontend/build.mjs create mode 100644 frontend/src/apexcharts.ts create mode 100644 frontend/src/chart_series.ts rename {sqlpage => frontend/src}/favicon.svg (100%) create mode 100644 frontend/src/init.ts rename {sqlpage => frontend/src}/sqlpage.css (93%) rename sqlpage/sqlpage.js => frontend/src/sqlpage.ts (71%) rename sqlpage/tomselect.js => frontend/src/tomselect.ts (79%) create mode 100644 frontend/types/globals.d.ts delete mode 100644 sqlpage/apexcharts.js delete mode 100644 sqlpage/globals.d.ts delete mode 100644 sqlpage/tabler-icons.svg diff --git a/.dockerignore b/.dockerignore index 5d17eabed..5fe7e853a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,3 +13,4 @@ examples/ mssql/ tests/ .idea/ +frontend/dist diff --git a/.github/actions/build-frontend/action.yml b/.github/actions/build-frontend/action.yml new file mode 100644 index 000000000..b339633a9 --- /dev/null +++ b/.github/actions/build-frontend/action.yml @@ -0,0 +1,15 @@ +name: Build frontend assets +description: > + Installs the npm toolchain and builds the browser bundles that the Rust build + embeds. Every job that compiles SQLPage needs this first. +runs: + using: composite + steps: + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + - run: npm ci + shell: bash + - run: npm run build + shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3977c512..8680e5f77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,11 +35,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: 'npm' - - run: npm ci + - uses: ./.github/actions/build-frontend - run: npm test - name: Set up cargo cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 @@ -161,6 +157,7 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v6 + - uses: ./.github/actions/build-frontend - name: Set up cargo cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 487e8bb9f..ca9fc9e8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,7 @@ jobs: features: "odbc-static" steps: - uses: actions/checkout@v6 + - uses: ./.github/actions/build-frontend - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -87,6 +88,7 @@ jobs: container: quay.io/pypa/manylinux_2_28_x86_64 steps: - uses: actions/checkout@v6 + - uses: ./.github/actions/build-frontend - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -155,15 +157,18 @@ jobs: if: startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@v6 + - uses: ./.github/actions/build-frontend - name: Set up cargo cache uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 env: NODE_OPTIONS: --no-deprecation - run: sudo apt-get update && sudo apt-get install -y unixodbc-dev + # The crate has to carry the frontend built above, and those files are + # deliberately untracked: that is all --allow-dirty stands for here. - name: Publish to crates.io run: | set +e - cargo publish --token ${{ secrets.CRATES_IO_TOKEN }} 2>&1 | tee cargo-publish.log + cargo publish --allow-dirty --token ${{ secrets.CRATES_IO_TOKEN }} 2>&1 | tee cargo-publish.log status=${PIPESTATUS[0]} set -e if [ "$status" -ne 0 ]; then diff --git a/.gitignore b/.gitignore index 994459944..e2b9ef211 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ sqlpage/sqlpage.db tests_uploads/ /test-results/ /playwright-report/ +/frontend/dist/ diff --git a/AGENTS.md b/AGENTS.md index 33796920d..de5758c4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ JavaScript where needed. retries, and optional `on_connect.sql`/`on_reset.sql` hooks. Database-specific SQL should be isolated or covered by the relevant database tests. - **Rendering and components** (`src/render.rs`, `src/templates.rs`, `src/dynamic_component.rs`, - `src/template_helpers.rs`, `sqlpage/templates/`, `sqlpage/sqlpage.css`, `sqlpage/sqlpage.js`). Built-in components live in `sqlpage/templates/*.handlebars` and cover shells, text, + `src/template_helpers.rs`, `sqlpage/templates/`, `frontend/src/`). Built-in components live in `sqlpage/templates/*.handlebars` and cover shells, text, tables, lists, cards, charts, forms, navigation, modals, downloads, maps, and more. Query columns map to component properties; nested/dynamic components and `sqlpage.run_sql` support composition and lazy loading. Custom Handlebars components can be placed in the configured `sqlpage/templates` directory. Raw HTML and @@ -97,9 +97,10 @@ pattern for the relevant area. Mandatory formatting (rust): `cargo fmt --all` Mandatory linting: `cargo clippy --all-targets --all-features -- -D warnings` -### When working on css or js +### When working on css or typescript Frontend formatting: `npm run format` Mandatory frontend validation: `npm test` +Rebuild the bundles before any cargo command that has to serve the change: `npm run build` More about testing: see [github actions](./.github/workflows/ci.yml). Contributor setup and validation: see [CONTRIBUTING.md](./CONTRIBUTING.md). Module overview: see [src/lib.rs](./src/lib.rs); architecture diagram: [docs/architecture-detailed.png](./docs/architecture-detailed.png). @@ -142,4 +143,4 @@ official documentation website sql tables: - [Configuration](./configuration.md): see [AppConfig](./src/app_config.rs) - Routing: file-based in `src/webserver/routing.rs`. Missing paths use the nearest ancestor `404.sql`; without one, HTML uses `src/default_404.sql` and other formats receive a plain-text 404. - Follow patterns from similar modules before introducing new abstractions. -- frontend: see [css](./sqlpage/sqlpage.css) and [js](./sqlpage/sqlpage.js) +- frontend: TypeScript and CSS in [frontend/src](./frontend/src), bundled by [frontend/build.mjs](./frontend/build.mjs) into `frontend/dist`, which `build.rs` embeds. Third-party libraries come from npm. diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a5e5eaa..655ef7a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - `column` charts now display vertical bars instead of nothing at all. - `stacked` is now ignored on chart types that cannot stack, instead of displaying an empty chart. - Screen readers now announce the title of the modal component instead of an unnamed dialog. + - The frontend is now TypeScript, bundled with esbuild from npm packages instead of being assembled from CDN downloads at build time. Building SQLPage now requires Node.js: run `npm ci && npm run build` before `cargo build`. ## v0.45 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2d865286..7289c7c93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Thank you for your interest in contributing to SQLPage! This document will guide ## Development Setup 1. Install Rust and Cargo (latest stable version): https://www.rust-lang.org/tools/install -2. If you contribute to the frontend, install Node.js too for frontend tooling: https://nodejs.org/en/download/ +2. Install Node.js: https://nodejs.org/en/download/ . It builds the browser assets that the server embeds. 3. Clone the repository ```bash @@ -19,9 +19,10 @@ The first time you build the project, dependencies will be downloaded, so you will need internet access, and the build may take a while. -Run the following command from the root of the repository to build the project in development mode: +Run the following commands from the root of the repository to build the project in development mode: ```bash +npm ci && npm run build cargo build ``` diff --git a/Cargo.lock b/Cargo.lock index b83112ab7..e26afb3ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,7 +114,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" dependencies = [ - "actix-macros", "futures-core", "tokio", ] @@ -4462,7 +4461,6 @@ version = "0.45.0" dependencies = [ "actix-http", "actix-multipart", - "actix-rt", "actix-service", "actix-web", "actix-web-httpauth", diff --git a/Cargo.toml b/Cargo.toml index 3119c089a..f7fd16027 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ license = "MIT" homepage = "https://sql-page.com/" repository = "https://github.com/sqlpage/SQLPage" documentation = "https://docs.rs/sqlpage" -include = ["/src", "/README.md", "/build.rs", "/sqlpage"] +include = ["/src", "/README.md", "/build.rs", "/sqlpage", "/frontend/dist"] [profile.superoptimized] inherits = "release" @@ -130,7 +130,4 @@ actix-http = "3" tokio = { version = "1", features = ["rt", "time", "test-util"] } [build-dependencies] -awc = { version = "3", features = ["rustls-0_23-webpki-roots"] } -rustls = "0.23" -actix-rt = "2.8" libflate = "2" diff --git a/Dockerfile b/Dockerfile index 7684eab7e..6622f4ef3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,10 @@ +FROM --platform=$BUILDPLATFORM node:24-slim AS frontend +WORKDIR /usr/src/sqlpage +COPY package.json package-lock.json tsconfig.json ./ +RUN npm ci --workspaces=false +COPY frontend/ frontend/ +RUN npm run build + FROM --platform=$BUILDPLATFORM rust:1.95-slim AS builder WORKDIR /usr/src/sqlpage @@ -13,7 +20,7 @@ RUN /usr/local/bin/setup-cross-compilation.sh "$TARGETARCH" "$BUILDARCH" COPY .cargo/ .cargo/ COPY Cargo.toml Cargo.lock build.rs ./ -COPY sqlpage/ sqlpage/ +COPY --from=frontend /usr/src/sqlpage/frontend/dist frontend/dist RUN /usr/local/bin/build-dependencies.sh COPY . . diff --git a/build.rs b/build.rs index 6f9f4a963..8d12a8b51 100644 --- a/build.rs +++ b/build.rs @@ -1,58 +1,57 @@ -use actix_rt::spawn; -use actix_rt::time::sleep; use libflate::gzip; use std::collections::hash_map::DefaultHasher; use std::fs::File; use std::hash::Hasher; use std::io::Read; -use std::io::{BufRead, BufReader, Write}; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::rc::Rc; -use std::time::Duration; -#[actix_rt::main] -async fn main() { - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .unwrap(); +/// Assets the server embeds and serves, built by `npm run build`. +const SERVED_ASSETS: [&str; 5] = [ + "sqlpage.js", + "apexcharts.js", + "tomselect.js", + "sqlpage.css", + "favicon.svg", +]; +const ICON_SPRITE: &str = "tabler-sprite.svg"; + +fn main() { println!("cargo:rerun-if-changed=build.rs"); - let c = Rc::new(make_client()); + let dist = Path::new("frontend").join("dist"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); - for h in [ - spawn(download_deps(c.clone(), "sqlpage.js")), - spawn(download_deps(c.clone(), "sqlpage.css")), - spawn(download_tabler_icons( - c.clone(), - "https://cdn.jsdelivr.net/npm/@tabler/icons-sprite@3.44.0/dist/tabler-sprite.svg", - )), - spawn(download_deps(c.clone(), "apexcharts.js")), - spawn(download_deps(c.clone(), "tomselect.js")), - spawn(download_deps(c.clone(), "favicon.svg")), - ] { - h.await.unwrap(); + for filename in SERVED_ASSETS { + compress_asset(&read_built_asset(&dist, filename), &out_dir, filename); } + write_icon_map(&read_built_asset(&dist, ICON_SPRITE), &out_dir); + set_odbc_rpath(); } -fn make_client() -> awc::Client { - awc::ClientBuilder::new() - .timeout(Duration::from_secs(10)) - .no_default_headers() - .finish() +fn read_built_asset(dist: &Path, filename: &str) -> Vec { + let path = dist.join(filename); + println!("cargo:rerun-if-changed={}", path.display()); + std::fs::read(&path).unwrap_or_else(|e| { + panic!( + "Unable to read the frontend asset '{}': {e}\n\ + The frontend is built by its own toolchain. Run: \n\ + npm ci && npm run build", + path.display() + ) + }) } -/// Creates a file with inlined remote files included -async fn download_deps(client: Rc, filename: &str) { - let path_in = format!("sqlpage/{filename}"); - let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); - let path_out: PathBuf = out_dir.join(filename); - // Generate outfile by reading infile and interpreting all comments - // like "/* !include https://... */" as a request to include the contents of - // the URL in the generated file. - println!("cargo:rerun-if-changed={path_in}"); - let original = File::open(path_in).unwrap(); - process_input_file(&client, &path_out, original).await; +/// Serves each asset pre-compressed, under a name that changes with its contents. +fn compress_asset(contents: &[u8], out_dir: &Path, filename: &str) { + let path_out = out_dir.join(filename); + let mut outfile = gzip::Encoder::new(File::create(&path_out).unwrap()).unwrap(); + outfile.write_all(contents).unwrap(); + outfile + .finish() + .as_result() + .expect("Unable to write compressed frontend asset"); std::fs::write( format!("{}.filename.txt", path_out.display()), hashed_filename(&path_out), @@ -60,84 +59,6 @@ async fn download_deps(client: Rc, filename: &str) { .unwrap(); } -async fn process_input_file(client: &awc::Client, path_out: &Path, original: File) { - let mut outfile = gzip::Encoder::new(File::create(path_out).unwrap()).unwrap(); - for l in BufReader::new(original).lines() { - let line = l.unwrap(); - if line.starts_with("/* !include https://") { - let url = line - .trim_start_matches("/* !include ") - .trim_end_matches(" */"); - if std::env::var("DOCS_RS").is_err() { - copy_url_to_opened_file(client, url, &mut outfile).await; - } else { - return; - } - outfile.write_all(b"\n").unwrap(); - } else { - writeln!(outfile, "{line}").unwrap(); - } - } - outfile - .finish() - .as_result() - .expect("Unable to write compressed frontend asset"); -} - -async fn copy_url_to_opened_file( - client: &awc::Client, - url: &str, - outfile: &mut impl std::io::Write, -) { - // If the file has been downloaded manually, use it - let cached_file_path = make_url_path(url); - if !cached_file_path.exists() { - download_url_to_path(client, url, &cached_file_path).await; - println!("cargo:rerun-if-changed={}", cached_file_path.display()); - } - copy_cached_to_opened_file(&cached_file_path, outfile); -} - -fn copy_cached_to_opened_file(source: &Path, outfile: &mut impl std::io::Write) { - let reader = std::fs::File::open(source).unwrap(); - let mut buf = std::io::BufReader::new(reader); - // Not async, but performance should not really matter here - std::io::copy(&mut buf, outfile).unwrap(); -} - -async fn download_url_to_path(client: &awc::Client, url: &str, path: &Path) { - let mut attempt = 1; - let max_attempts = 2; - - loop { - match client.get(url).send().await { - Ok(mut resp) => { - if resp.status() != 200 { - panic!("Received {} status code from {}", resp.status(), url); - } - let bytes = resp.body().limit(128 * 1024 * 1024).await.unwrap(); - std::fs::write(path, &bytes) - .expect("Failed to write external frontend dependency to local file"); - break; - } - Err(err) => { - if attempt >= max_attempts { - let path = make_url_path(url); - panic!( - "We need to download external frontend dependencies to build the static frontend. \n\ - Could not download static asset after {max_attempts} attempts. You can manually download the file with: \n\ - curl {url:?} > {path:?} \n\ - {err}" - ); - } - sleep(Duration::from_secs(1)).await; - eprintln!("Retrying download of {url} after {err}."); - attempt += 1; - } - } - } -} - // Given a filename, creates a new unique filename based on the file contents fn hashed_filename(path: &Path) -> String { let mut file = File::open(path).unwrap(); @@ -161,27 +82,10 @@ fn hashed_filename(path: &Path) -> String { ) } -fn make_url_path(url: &str) -> PathBuf { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let sqlpage_artefacts = Path::new(&manifest_dir) - .join("target") - .join("sqlpage_artefacts"); - std::fs::create_dir_all(&sqlpage_artefacts).unwrap(); - let filename = url.replace( - |c: char| !c.is_ascii_alphanumeric() && !['.', '-'].contains(&c), - "_", - ); - sqlpage_artefacts.join(filename) -} - -async fn download_tabler_icons(client: Rc, sprite_url: &str) { - let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); - let icon_map_path = out_dir.join("icons.rs"); - let mut sprite_content = Vec::with_capacity(3 * 1024 * 1024); - copy_url_to_opened_file(&client, sprite_url, &mut sprite_content).await; - let mut file = File::create(icon_map_path).unwrap(); +fn write_icon_map(sprite_content: &[u8], out_dir: &Path) { + let mut file = File::create(out_dir.join("icons.rs")).unwrap(); file.write_all(b"[").unwrap(); - extract_icons_from_sprite(&sprite_content, |name, content| { + extract_icons_from_sprite(sprite_content, |name, content| { writeln!(file, "({name:?}, r#\"{content}\"#),").unwrap(); }); file.write_all(b"]").unwrap(); diff --git a/frontend/build.mjs b/frontend/build.mjs new file mode 100644 index 000000000..9d49fa810 --- /dev/null +++ b/frontend/build.mjs @@ -0,0 +1,63 @@ +import { copyFile, mkdir, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import * as esbuild from "esbuild"; + +const require = createRequire(import.meta.url); +const frontend = dirname(fileURLToPath(import.meta.url)); +const dist = join(frontend, "dist"); + +const BUNDLES = { + "sqlpage.js": "src/sqlpage.ts", + "apexcharts.js": "src/apexcharts.ts", + "tomselect.js": "src/tomselect.ts", + "sqlpage.css": "src/sqlpage.css", +}; + +const COPIES = { + "favicon.svg": join(frontend, "src/favicon.svg"), + "tabler-sprite.svg": require.resolve( + "@tabler/icons-sprite/dist/tabler-sprite.svg", + ), +}; + +function bundled_package(input_path) { + const inside_node_modules = input_path.split("node_modules/"); + if (inside_node_modules.length < 2) return undefined; + const [scope, name] = inside_node_modules.at(-1).split("/"); + return scope.startsWith("@") ? `${scope}/${name}` : scope; +} + +function credits(metafile) { + const bundled = Object.keys(metafile.inputs).map(bundled_package); + const lines = [...new Set(bundled.filter(Boolean))].sort().map((name) => { + const { version, license, homepage } = require(`${name}/package.json`); + return ` * ${[`${name} ${version}`, license, homepage].filter(Boolean).join(" - ")}`; + }); + if (lines.length === 0) return ""; + return `/*!\n * SQLPage bundles the following third-party code:\n${lines.join("\n")}\n */\n`; +} + +async function build(name, entry) { + const { outputFiles, metafile } = await esbuild.build({ + entryPoints: [join(frontend, entry)], + outfile: join(dist, name), + bundle: true, + minify: true, + charset: "utf8", + legalComments: "inline", + target: "es2022", + metafile: true, + write: false, + }); + await writeFile(join(dist, name), credits(metafile) + outputFiles[0].text); +} + +await mkdir(dist, { recursive: true }); +await Promise.all([ + ...Object.entries(BUNDLES).map(([name, entry]) => build(name, entry)), + ...Object.entries(COPIES).map(([name, from]) => + copyFile(from, join(dist, name)), + ), +]); diff --git a/frontend/src/apexcharts.ts b/frontend/src/apexcharts.ts new file mode 100644 index 000000000..38280e725 --- /dev/null +++ b/frontend/src/apexcharts.ts @@ -0,0 +1,272 @@ +import ApexCharts, { type ApexOptions } from "apexcharts"; +import { + align_series_for, + type ChartSeries, + x_is_text, +} from "./chart_series.ts"; +import { add_init_fn } from "./init.ts"; + +const tblrColors: [name: string, dark: string, light: string][] = [ + ["blue", "#1c7ed6", "#339af0"], + ["red", "#f03e3e", "#ff6b6b"], + ["green", "#37b24d", "#51cf66"], + ["pink", "#d6336c", "#f06595"], + ["purple", "#ae3ec9", "#cc5de8"], + ["orange", "#f76707", "#ff922b"], + ["cyan", "#1098ad", "#22b8cf"], + ["teal", "#0ca678", "#20c997"], + ["yellow", "#f59f00", "#fcc419"], + ["indigo", "#4263eb", "#5c7cfa"], + ["lime", "#74b816", "#94d82d"], + ["azure", "#339af0", "#339af0"], + ["gray", "#495057", "#adb5bd"], + ["black", "#000000", "#000000"], + ["white", "#ffffff", "#f8f9fa"], +]; + +const colorNames: Record = Object.fromEntries( + tblrColors.flatMap(([name, dark, light]) => [ + [name, dark], + [`${name}-lt`, light], + ]), +); + +const isDarkTheme = document.body?.dataset?.bsTheme === "dark"; + +type ChartType = NonNullable["type"]>; + +/** ApexCharts has no z axis; our bubble tooltip reads its title back off the config. */ +type SqlpageChartOptions = ApexOptions & { + zaxis: { title: { text?: string } }; +}; + +const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; +const APEXCHARTS_TYPE_ALIASES: Record = { column: "bar" }; + +function sqlpage_chart() { + const charts = document.querySelectorAll( + "[data-pre-init=chart]", + ); + for (const c of charts) { + try { + build_sqlpage_chart(c); + } catch (e) { + console.error(e); + } + } +} + +function build_sqlpage_chart(c: HTMLElement) { + const [data_element] = c.getElementsByTagName("data"); + const data = JSON.parse(data_element.textContent ?? ""); + const chartContainer = c.querySelector(".chart"); + if (!chartContainer) return; + chartContainer.innerHTML = ""; + const is_timeseries = !!data.time; + const chart_type: ChartType = + APEXCHARTS_TYPE_ALIASES[data.type] || data.type || "line"; + const is_stacked = + !!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type); + const series_map: Record = {}; + for (const [name, old_x, old_y, z] of data.points) { + series_map[name] = series_map[name] || { name, data: [] }; + let x = old_x; + let y = old_y; + if (is_timeseries) { + if (typeof x === "number") x = new Date(x * 1000); + else if (chart_type === "rangeBar" && Array.isArray(y)) + y = y.map((y) => new Date(y).getTime()); + else x = new Date(x); + } + series_map[name].data.push({ x, y, z }); + } + if (data.xmin == null) data.xmin = undefined; + if (data.xmax == null) data.xmax = undefined; + if (data.ymin == null) data.ymin = undefined; + if (data.ymax == null) data.ymax = undefined; + + const colors = [ + ...data.colors.filter((c: string) => c).map((c: string) => colorNames[c]), + ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? dark : light)), + ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? light : dark)), + ]; + + const named_series = Object.values(series_map); + const categories = x_is_text(named_series); + const is_pie = chart_type === "pie"; + const series = is_pie + ? data.points.map(([_name, _x, y]: [string, unknown, string]) => + Number.parseFloat(y), + ) + : named_series.length > 1 + ? align_series_for(named_series, chart_type, is_stacked) + : named_series; + + const options: SqlpageChartOptions = { + ...(is_pie && { + labels: data.points.map(([name, x]: [string, unknown]) => x || name), + }), + chart: { + type: chart_type, + fontFamily: "inherit", + background: "transparent", + parentHeightOffset: 0, + height: chartContainer.style.height, + stacked: is_stacked, + toolbar: { + show: !!data.toolbar, + }, + animations: { + enabled: false, + }, + zoom: { + enabled: false, + }, + }, + theme: { + mode: isDarkTheme ? "dark" : "light", + palette: "palette4", + }, + legend: { + show: data.show_legend === null || !!data.show_legend, + }, + dataLabels: { + enabled: !!data.labels, + dropShadow: { + enabled: true, + color: "var(--tblr-primary-bg-subtle)", + }, + formatter: + chart_type === "rangeBar" + ? (_val: unknown, { seriesIndex, w }: Untyped) => + w.config.series[seriesIndex].name + : is_pie + ? (value: number, { seriesIndex, w }: Untyped) => + `${w.config.labels[seriesIndex]}: ${value.toFixed()}%` + : (value: Untyped) => value?.toLocaleString?.() || value, + }, + fill: { + type: chart_type === "area" ? "gradient" : "solid", + }, + stroke: { + width: + { + area: 3, + line: 2, + }[chart_type] || 0, + lineCap: "round", + curve: "smooth", + }, + xaxis: { + tooltip: { + enabled: false, + }, + min: data.xmin, + max: data.xmax, + title: { + text: data.xtitle || undefined, + }, + type: is_timeseries ? "datetime" : categories ? "category" : undefined, + labels: { + datetimeUTC: false, + }, + // ApexCharts counts intervals here rather than ticks + tickAmount: data.xticks || undefined, + }, + yaxis: { + logarithmic: !!data.logarithmic, + min: data.ymin, + max: data.ymax, + stepSize: data.ystep, + tickAmount: data.yticks, + title: { + text: data.ytitle || undefined, + }, + }, + zaxis: { + title: { + text: data.ztitle || undefined, + }, + }, + markers: { + size: data.marker || 0, + strokeWidth: 0, + hover: { + sizeOffset: 5, + }, + }, + tooltip: { + fillSeriesColor: false, + custom: + chart_type === "bubble" || chart_type === "scatter" + ? bubbleTooltip + : undefined, + y: { + formatter: (value: number | null) => { + if (value == null) return ""; + if (is_timeseries && chart_type === "rangeBar") { + const d = new Date(value); + if (d.getHours() === 0 && d.getMinutes() === 0) + return d.toLocaleDateString(); + return d.toLocaleString(); + } + const str_val = value.toLocaleString(); + if (str_val.length > 10 && Number.isNaN(value)) + return value.toFixed(2); + return str_val; + }, + }, + }, + plotOptions: { + bar: { + horizontal: !!data.horizontal || chart_type === "rangeBar", + borderRadius: 5, + }, + bubble: { minBubbleRadius: 5 }, + }, + colors, + series, + }; + const chart = new ApexCharts(chartContainer, options); + chart.render(); + if (window.charts) window.charts.push(chart); + else window.charts = [chart]; + c.removeAttribute("data-pre-init"); +} + +function bubbleTooltip({ seriesIndex, dataPointIndex, w }: Untyped) { + const { name, data } = w.config.series[seriesIndex]; + const point = data[dataPointIndex]; + + const tooltip = document.createElement("div"); + tooltip.className = "apexcharts-tooltip-text"; + tooltip.style.fontFamily = "inherit"; + + const seriesName = document.createElement("div"); + seriesName.className = "apexcharts-tooltip-y-group"; + seriesName.style.fontWeight = "bold"; + seriesName.innerText = name; + tooltip.appendChild(seriesName); + + for (const axis of ["x", "y", "z"]) { + const value = point[axis]; + if (value == null) continue; + const axisValue = document.createElement("div"); + axisValue.className = "apexcharts-tooltip-y-group"; + let axis_conf = w.config[`${axis}axis`]; + if (axis_conf.length) axis_conf = axis_conf[0]; + const title = axis_conf.title.text || axis; + const labelSpan = document.createElement("span"); + labelSpan.className = "apexcharts-tooltip-text-y-label"; + labelSpan.innerText = `${title}: `; + axisValue.appendChild(labelSpan); + const valueSpan = document.createElement("span"); + valueSpan.className = "apexcharts-tooltip-text-y-value"; + valueSpan.innerText = value; + axisValue.appendChild(valueSpan); + tooltip.appendChild(axisValue); + } + return tooltip.outerHTML; +} + +add_init_fn(sqlpage_chart); diff --git a/frontend/src/chart_series.ts b/frontend/src/chart_series.ts new file mode 100644 index 000000000..67cf969e5 --- /dev/null +++ b/frontend/src/chart_series.ts @@ -0,0 +1,72 @@ +export type XValue = number | string | Date; +export type YValue = number | string | null | (number | string)[]; +export type ChartPoint = { x: XValue; y: YValue; z?: number }; +export type ChartSeries = { name: string; data: ChartPoint[] }; + +/** Equal x values share a key. */ +const x_key = (x: XValue) => (x instanceof Date ? x.getTime() : x); + +export const x_is_text = (series: ChartSeries[]) => + typeof series[0]?.data[0]?.x === "string"; + +const STACKED_SERIES_SKIPPING_AN_X_ADDS_NOTHING = 0; + +const Y_WHEN_A_SERIES_SKIPS_A_LABEL: Record = { + bar: 0, + line: null, + area: null, + scatter: null, + bubble: null, + heatmap: null, +}; + +/** + * Every x the series hold, in their own order where they agree and in + * ascending order where they diverge. + */ +export function merged_x_values(series: ChartSeries[]): XValue[] { + const unread = series.map(({ data }) => data.map(({ x }) => x)); + const merged = new Map(); + while (unread.some((xs) => xs.length > 0)) { + const with_lowest_x = unread + .filter((xs) => xs.length > 0) + .reduce((a, b) => (b[0] < a[0] ? b : a)); + const [x] = with_lowest_x.splice(0, 1); + merged.set(x_key(x), x); + } + return [...merged.values()]; +} + +/** + * ApexCharts pairs points across series by index rather than by x, so a series + * that skips an x lands on the wrong one. Give every series the same amount of + * x values, worth `y_when_missing` where a series has no value. + */ +export function align_series( + series: ChartSeries[], + y_when_missing: 0 | null, +): ChartSeries[] { + const all_x = merged_x_values(series); + return series.map(({ name, data }) => { + const by_x = new Map(data.map((point) => [x_key(point.x), point])); + return { + name, + data: all_x.map((x) => { + const point = by_x.get(x_key(x)); + return { ...point, x, y: point?.y ?? y_when_missing }; + }), + }; + }); +} + +export function align_series_for( + series: ChartSeries[], + chart_type: string, + is_stacked: boolean, +): ChartSeries[] { + if (is_stacked) + return align_series(series, STACKED_SERIES_SKIPPING_AN_X_ADDS_NOTHING); + if (x_is_text(series) && chart_type in Y_WHEN_A_SERIES_SKIPS_A_LABEL) + return align_series(series, Y_WHEN_A_SERIES_SKIPS_A_LABEL[chart_type]); + return series; +} diff --git a/sqlpage/favicon.svg b/frontend/src/favicon.svg similarity index 100% rename from sqlpage/favicon.svg rename to frontend/src/favicon.svg diff --git a/frontend/src/init.ts b/frontend/src/init.ts new file mode 100644 index 000000000..4c3452219 --- /dev/null +++ b/frontend/src/init.ts @@ -0,0 +1,5 @@ +export function add_init_fn(f: () => void) { + document.addEventListener("DOMContentLoaded", f); + document.addEventListener("fragment-loaded", f); + if (document.readyState !== "loading") setTimeout(f, 0); +} diff --git a/sqlpage/sqlpage.css b/frontend/src/sqlpage.css similarity index 93% rename from sqlpage/sqlpage.css rename to frontend/src/sqlpage.css index a6e0b769a..86f4174be 100644 --- a/sqlpage/sqlpage.css +++ b/frontend/src/sqlpage.css @@ -1,6 +1,6 @@ -/* !include https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.min.css */ -/* !include https://cdn.jsdelivr.net/npm/tom-select@2.6.1/dist/css/tom-select.bootstrap5.css */ -/* !include https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler-vendors.min.css */ +@import "@tabler/core/dist/css/tabler.css"; +@import "tom-select/dist/css/tom-select.bootstrap5.css"; +@import "@tabler/core/dist/css/tabler-vendors.css"; .navbar { /* https://github.com/sqlpage/SQLPage/issues/822 */ diff --git a/sqlpage/sqlpage.js b/frontend/src/sqlpage.ts similarity index 71% rename from sqlpage/sqlpage.js rename to frontend/src/sqlpage.ts index e32667b88..b04f849a2 100644 --- a/sqlpage/sqlpage.js +++ b/frontend/src/sqlpage.ts @@ -1,9 +1,19 @@ -/* !include https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js */ -const nonce = /** @type {HTMLScriptElement} */ (document.currentScript).nonce; +import { + Carousel, + Dropdown, + Modal, + Popover, + Toast, + Tooltip, +} from "@tabler/core"; +import { add_init_fn } from "./init.ts"; + +const nonce = (document.currentScript as HTMLScriptElement).nonce; + +type TableRow = { el: HTMLElement; sort_keys: { num: number; str: string }[] }; function sqlpage_card() { - /** @type {NodeListOf} */ - const cards = document.querySelectorAll("[data-pre-init=card]"); + const cards = document.querySelectorAll("[data-pre-init=card]"); for (const c of cards) { c.removeAttribute("data-pre-init"); if (!c.dataset.embed) continue; @@ -24,15 +34,13 @@ function sqlpage_card() { } } -/** @param {HTMLElement} root_el */ -function setup_table(root_el) { - /** @type {HTMLInputElement | null} */ - const search_input = root_el.querySelector("input.search"); +function setup_table(root_el: HTMLElement) { + const search_input = root_el.querySelector("input.search"); const table_el = root_el.querySelector("table"); if (!table_el) return; - /** @type {NodeListOf} */ - const sort_button_els = table_el.querySelectorAll("button.sort[data-sort]"); - const sort_buttons = [...sort_button_els]; + const sort_buttons = [ + ...table_el.querySelectorAll("button.sort[data-sort]"), + ]; const item_parent = table_el.querySelector("tbody"); const has_sort = sort_buttons.length > 0; @@ -47,20 +55,18 @@ function setup_table(root_el) { apply_number_formatting(table_el); } -/** - * @param {HTMLInputElement} search_input - * @param {TableRow[]} items - */ -function setup_table_search_behavior(search_input, items) { +function setup_table_search_behavior( + search_input: HTMLInputElement, + items: TableRow[], +) { function onSearch() { const lower_search = search_input.value .toLowerCase() .split(/\s+/) .filter((s) => s); for (const item of items) { - const show = lower_search.every((s) => - item.el.textContent.toLowerCase().includes(s), - ); + const text = item.el.textContent?.toLowerCase() ?? ""; + const show = lower_search.every((s) => text.includes(s)); item.el.style.display = show ? "" : "none"; } } @@ -69,13 +75,13 @@ function setup_table_search_behavior(search_input, items) { onSearch(); } -/**@param {HTMLElement} table_el */ -function apply_number_formatting(table_el) { - /** @type {NodeListOf} */ - const header_els = table_el.querySelectorAll("thead > tr > th"); - const col_types = [...header_els].map((el) => el.dataset.column_type); - const col_rawnums = [...header_els].map((el) => !!el.dataset.raw_number); - const col_money = [...header_els].map((el) => !!el.dataset.money); +function apply_number_formatting(table_el: HTMLElement) { + const header_els = [ + ...table_el.querySelectorAll("thead > tr > th"), + ]; + const col_types = header_els.map((el) => el.dataset.column_type); + const col_rawnums = header_els.map((el) => !!el.dataset.raw_number); + const col_money = header_els.map((el) => !!el.dataset.money); const number_format_locale = table_el.dataset.number_format_locale; const number_format_digits = table_el.dataset.number_format_digits ? Number(table_el.dataset.number_format_digits) @@ -103,19 +109,15 @@ function apply_number_formatting(table_el) { } } -/** @typedef { {el: HTMLElement, sort_keys: {num: number, str: string}[]} } TableRow */ - -/** Prepare the table rows for sorting. - * @param {HTMLElement} table_el - * @param {HTMLElement[]} sort_buttons - * @returns {TableRow[]} - */ -function table_parse_data(table_el, sort_buttons) { - const is_num = [...sort_buttons].map( +/** Prepare the table rows for sorting. */ +function table_parse_data( + table_el: HTMLElement, + sort_buttons: HTMLElement[], +): TableRow[] { + const is_num = sort_buttons.map( (btn_el) => btn_el.parentElement?.dataset.column_type === "number", ); - /** @type {NodeListOf} */ - const row_els = table_el.querySelectorAll("tbody tr"); + const row_els = table_el.querySelectorAll("tbody tr"); return [...row_els].map((tr_el) => { const cells = tr_el.getElementsByTagName("td"); return { @@ -129,13 +131,11 @@ function table_parse_data(table_el, sort_buttons) { }); } -/** - * Adds event listeners to the sort buttons to sort the table rows. - * @param {HTMLElement[]} sort_buttons - * @param {TableRow[]} items - * @param {HTMLElement} item_parent - */ -function setup_sort_behavior(sort_buttons, items, item_parent) { +function setup_sort_behavior( + sort_buttons: HTMLElement[], + items: TableRow[], + item_parent: HTMLElement, +) { sort_buttons.forEach((button, button_index) => { button.addEventListener("click", function sort_items() { const sort_desc = button.classList.contains("asc"); @@ -160,8 +160,9 @@ function setup_sort_behavior(sort_buttons, items, item_parent) { } function sqlpage_table() { - /** @type {NodeListOf} */ - const tables = document.querySelectorAll("[data-pre-init=table]"); + const tables = document.querySelectorAll( + "[data-pre-init=table]", + ); for (const r of tables) { r.removeAttribute("data-pre-init"); setup_table(r); @@ -196,11 +197,9 @@ function sqlpage_map() { if (first_map && is_leaflet_loaded) { onLeafletLoad(); } - /** - * @param {string|undefined} coords - * @returns {[number, number] | undefined} - */ - function parseCoords(coords) { + function parseCoords( + coords: string | undefined, + ): [number, number] | undefined { if (!coords) return undefined; const parsed = coords.split(",", 2).map((c) => Number.parseFloat(c)); if (parsed.length !== 2 || !parsed.every(Number.isFinite)) { @@ -213,8 +212,7 @@ function sqlpage_map() { } function onLeafletLoad() { is_leaflet_loaded = true; - /** @type {NodeListOf} */ - const maps = document.querySelectorAll("[data-pre-init=map]"); + const maps = document.querySelectorAll("[data-pre-init=map]"); for (const m of maps) { const tile_source = m.dataset.tile_source; const maxZoom = Number(m.dataset.max_zoom); @@ -231,8 +229,8 @@ function sqlpage_map() { setTimeout(() => { if (center) map.setView(center, zoom); else { - const markerBounds = (m) => - m.getLatLng ? m.getLatLng() : m.getBounds(); + const markerBounds = (marker: Untyped) => + marker.getLatLng ? marker.getLatLng() : marker.getBounds(); const bounds = map._sqlpage_markers.map(markerBounds); if (bounds.length > 0) map.fitBounds(bounds); else map.setView([51.505, 10], zoom); @@ -244,11 +242,11 @@ function sqlpage_map() { } } - function addMarker(marker_elem, map) { + function addMarker(marker_elem: HTMLElement, map: Untyped) { const { dataset } = marker_elem; - const options = { - color: marker_elem.dataset.color, - title: marker_elem.getElementsByTagName("h3")[0].textContent.trim(), + const options: Untyped = { + color: dataset.color, + title: marker_elem.getElementsByTagName("h3")[0]?.textContent?.trim(), }; const marker = dataset.coords ? createMarker(marker_elem, options) @@ -256,21 +254,26 @@ function sqlpage_map() { if (!marker) return; marker.addTo(map); map._sqlpage_markers.push(marker); - if (marker_elem.textContent.trim()) marker.bindPopup(marker_elem); - else if (marker_elem.dataset.link) { + const link = dataset.link; + if (marker_elem.textContent?.trim()) marker.bindPopup(marker_elem); + else if (link) { marker.on("click", () => { - window.location.href = marker_elem.dataset.link; + window.location.href = link; }); } } - function createMarker(marker_elem, options) { + function createMarker(marker_elem: HTMLElement, options: Untyped) { const coords = parseCoords(marker_elem.dataset.coords); if (!coords) return undefined; const icon_obj = marker_elem.getElementsByClassName("mapicon")[0]; if (icon_obj) { const size = 1.5 * - +(options.size || icon_obj.firstChild?.getAttribute("width") || 24); + +( + options.size || + icon_obj.firstElementChild?.getAttribute("width") || + 24 + ); options.icon = L.divIcon({ html: icon_obj, className: `border-0 bg-${options.color || "primary"} bg-gradient text-white rounded-circle shadow d-flex justify-content-center align-items-center`, @@ -280,16 +283,16 @@ function sqlpage_map() { } return L.marker(coords, options); } - function createGeoJSONMarker(marker_elem, options) { - const geojson = JSON.parse(marker_elem.dataset.geojson); + function createGeoJSONMarker(marker_elem: HTMLElement, options: Untyped) { + const geojson = JSON.parse(marker_elem.dataset.geojson ?? ""); if (options.color) { options.color = get_tabler_color(options.color) || options.color; } - function style({ properties }) { + function style({ properties }: Untyped) { if (typeof properties !== "object") return options; return { ...options, ...properties }; } - function pointToLayer(feature, latlng) { + function pointToLayer(feature: Untyped, latlng: Untyped) { marker_elem.dataset.coords = `${latlng.lat},${latlng.lng}`; return createMarker(marker_elem, { ...options, ...feature.properties }); } @@ -298,8 +301,7 @@ function sqlpage_map() { } function sqlpage_form() { - /** @type {NodeListOf} */ - const file_inputs = document.querySelectorAll( + const file_inputs = document.querySelectorAll( "input[type=file][data-max-size]", ); for (const input of file_inputs) { @@ -318,22 +320,22 @@ function sqlpage_form() { }); } - /** @type {NodeListOf} */ - const auto_submit_forms = document.querySelectorAll("form[data-auto-submit]"); + const auto_submit_forms = document.querySelectorAll( + "form[data-auto-submit]", + ); for (const form of auto_submit_forms) { form.addEventListener("change", () => form.submit()); } } -function get_tabler_color(name) { +function get_tabler_color(name: string) { return getComputedStyle(document.documentElement).getPropertyValue( `--tblr-${name}`, ); } function load_scripts() { - /** @type {NodeListOf} */ - const addjs = document.querySelectorAll("[data-sqlpage-js]"); + const addjs = document.querySelectorAll("[data-sqlpage-js]"); const existing_scripts = new Set( [...document.querySelectorAll("script")].map((s) => s.src), ); @@ -348,14 +350,9 @@ function load_scripts() { } } -function add_init_fn(f) { - document.addEventListener("DOMContentLoaded", f); - document.addEventListener("fragment-loaded", f); - if (document.readyState !== "loading") setTimeout(f, 0); -} - -function normalize_hash(hash) { +function normalize_hash(hash: string | undefined) { const normalized = hash?.replace(/^#/, ""); + if (!normalized) return undefined; try { return decodeURIComponent(normalized); } catch { @@ -363,9 +360,7 @@ function normalize_hash(hash) { } } -function open_toasts_for_hash(toasts) { - const Toast = (window.bootstrap || window.tabler?.bootstrap)?.Toast; - if (!Toast) return; +function open_toasts_for_hash(toasts: Iterable) { const hash = normalize_hash(window.location.hash); if (!hash) return; for (const toast of toasts) { @@ -375,9 +370,9 @@ function open_toasts_for_hash(toasts) { } } -function restore_focus_after_toast(toast, container) { +function restore_focus_after_toast(toast: HTMLElement, container: HTMLElement) { if (!toast.contains(document.activeElement)) return; - const next_close = container.querySelector( + const next_close = container.querySelector( '.toast.show [data-bs-dismiss="toast"]', ); if (next_close) { @@ -391,28 +386,24 @@ function restore_focus_after_toast(toast, container) { } function sqlpage_toast() { - const Toast = (window.bootstrap || window.tabler?.bootstrap)?.Toast; - if (!Toast) return; - - const initialized_toasts = []; - /** @type {NodeListOf} */ - const toasts = document.querySelectorAll('[data-pre-init="toast"]'); + const initialized_toasts: HTMLElement[] = []; + const toasts = document.querySelectorAll( + '[data-pre-init="toast"]', + ); for (const toast of toasts) { const source_container = toast.parentElement; if (!source_container) continue; const position = source_container.dataset.sqlpageToastPosition; - let container = /** @type {HTMLElement | null} */ ( - document.querySelector( - `.toast-container[data-sqlpage-toast-position="${position}"]:not([data-pre-init])`, - ) + const shared_container = document.querySelector( + `.toast-container[data-sqlpage-toast-position="${position}"]:not([data-pre-init])`, ); - if (!container) { - container = source_container; - container.removeAttribute("data-pre-init"); - document.body.appendChild(container); - } else { + const container = shared_container ?? source_container; + if (shared_container) { container.appendChild(toast); source_container.remove(); + } else { + container.removeAttribute("data-pre-init"); + document.body.appendChild(container); } toast.removeAttribute("data-pre-init"); @@ -449,23 +440,24 @@ add_init_fn(sqlpage_form); add_init_fn(load_scripts); add_init_fn(sqlpage_toast); window.addEventListener("hashchange", () => - open_toasts_for_hash(document.querySelectorAll("[data-toast-trigger]")), + open_toasts_for_hash( + document.querySelectorAll("[data-toast-trigger]"), + ), ); -function init_bootstrap_components(event) { - const bootstrap = window.bootstrap || window.tabler.bootstrap; - const fragment = event.target; +function init_bootstrap_components(event: Event) { + const fragment = event.target as HTMLElement; for (const el of fragment.querySelectorAll('[data-bs-toggle="tooltip"]')) { - new bootstrap.Tooltip(el); + new Tooltip(el); } for (const el of fragment.querySelectorAll('[data-bs-toggle="popover"]')) { - new bootstrap.Popover(el); + new Popover(el); } for (const el of fragment.querySelectorAll('[data-bs-toggle="dropdown"]')) { - new bootstrap.Dropdown(el); + new Dropdown(el); } for (const el of fragment.querySelectorAll('[data-bs-ride="carousel"]')) { - new bootstrap.Carousel(el); + new Carousel(el); } } @@ -476,9 +468,7 @@ function open_modal_for_hash() { if (!hash) return; const modal = document.getElementById(hash); if (!modal?.classList.contains("modal")) return; - const bootstrap_modal = - window.tabler.bootstrap.Modal.getOrCreateInstance(modal); - bootstrap_modal.show(); + Modal.getOrCreateInstance(modal).show(); modal.addEventListener("hidden.bs.modal", () => { window.history.replaceState(null, "", "#"); }); diff --git a/sqlpage/tomselect.js b/frontend/src/tomselect.ts similarity index 79% rename from sqlpage/tomselect.js rename to frontend/src/tomselect.ts index 9460bf3e6..c70c9c49d 100644 --- a/sqlpage/tomselect.js +++ b/frontend/src/tomselect.ts @@ -1,8 +1,12 @@ -/* !include https://cdn.jsdelivr.net/npm/tom-select@2.6.1/dist/js/tom-select.popular.min.js */ +import TomSelect from "tom-select/popular"; +import { add_init_fn } from "./init.ts"; + +type TomLoadCallback = TomSelect["loadCallback"]; function sqlpage_select_dropdown() { - /** @type {NodeListOf} */ - const selects = document.querySelectorAll("[data-pre-init=select-dropdown]"); + const selects = document.querySelectorAll( + "[data-pre-init=select-dropdown]", + ); for (const s of selects) { try { sqlpage_select_dropdown_individual(s); @@ -12,11 +16,7 @@ function sqlpage_select_dropdown() { } } -/** - * Initialize a select dropdown for a single element - * @param {HTMLSelectElement} s - The select element to initialize - */ -function sqlpage_select_dropdown_individual(s) { +function sqlpage_select_dropdown_individual(s: HTMLSelectElement) { s.removeAttribute("data-pre-init"); // See: https://github.com/orchidjs/tom-select/issues/716 // By default, TomSelect will not retain the focus if s is already focused @@ -27,10 +27,10 @@ function sqlpage_select_dropdown_individual(s) { load: sqlpage_load_options_source(s.dataset.options_source), valueField: "value", labelField: "label", - searchField: "label", - create: s.dataset.create_new, + searchField: ["label"], + create: !!s.dataset.create_new, maxOptions: null, - onItemAdd: function () { + onItemAdd: function (this: TomSelect) { this.setTextboxValue(""); this.refreshOptions(); }, @@ -49,10 +49,10 @@ function sqlpage_select_dropdown_individual(s) { }); } -function sqlpage_load_options_source(options_source) { +function sqlpage_load_options_source(options_source: string | undefined) { if (!options_source) return; - return async (query, callback) => { - const err = (label) => callback([{ label, value: "" }]); + return async (query: string, callback: TomLoadCallback) => { + const err = (label: string) => callback([{ label, value: "" }], []); const options_url = new URL(options_source, document.baseURI); options_url.searchParams.set("search", query); const resp = await fetch(options_url); @@ -88,7 +88,8 @@ function sqlpage_load_options_source(options_source) { ); } } - callback(results); + callback(results, []); }; } + add_init_fn(sqlpage_select_dropdown); diff --git a/frontend/types/globals.d.ts b/frontend/types/globals.d.ts new file mode 100644 index 000000000..12c0f7cb2 --- /dev/null +++ b/frontend/types/globals.d.ts @@ -0,0 +1,17 @@ +// Names the browser bundles rely on at runtime rather than through an import: +// libraries loaded on demand, and the state SQLPage publishes for page scripts. + +/** + * A library this project ships no type definitions for. Saying `unknown` + * instead would only move the guesswork to a cast at every call site. + */ +// biome-ignore lint/suspicious/noExplicitAny: that is what an untyped library is +type Untyped = any; + +/** Leaflet, loaded from a CDN by sqlpage_map when a page holds a map. */ +declare const L: Untyped; + +interface Window { + /** Every chart rendered on the page, in the order they were built. */ + charts?: unknown[]; +} diff --git a/lambda.Dockerfile b/lambda.Dockerfile index e7ea6349b..4d9128ffe 100644 --- a/lambda.Dockerfile +++ b/lambda.Dockerfile @@ -1,9 +1,17 @@ +FROM node:24-alpine AS frontend +WORKDIR /usr/src/sqlpage +COPY package.json package-lock.json tsconfig.json ./ +RUN npm ci --workspaces=false +COPY frontend/ frontend/ +RUN npm run build + FROM rust:1.95-alpine AS builder RUN rustup component add clippy rustfmt RUN apk add --no-cache musl-dev zip WORKDIR /usr/src/sqlpage RUN cargo init . COPY Cargo.toml Cargo.lock ./ +COPY --from=frontend /usr/src/sqlpage/frontend/dist frontend/dist RUN cargo build --release COPY . . RUN cargo build --release --features lambda-web diff --git a/package-lock.json b/package-lock.json index 7d97b7b69..7bc790b14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,16 @@ "workspaces": [ "tests/end-to-end" ], + "dependencies": { + "@tabler/core": "1.4.0", + "@tabler/icons-sprite": "3.44.0", + "apexcharts": "5.13.0", + "tom-select": "2.6.1" + }, "devDependencies": { "@biomejs/biome": "^2.5.4", "@types/node": "^26.2.0", + "esbuild": "^0.28.2", "typescript": "^7.0.2" } }, @@ -192,6 +199,513 @@ "node": ">=14.21.3" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@orchidjs/sifter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.1.0.tgz", + "integrity": "sha512-mYwHCfr736cIWWdhhSZvDbf90AKt2xyrJspKFC3qyIJG1LtrJeJunYEqCGG4Aq2ijENbc4WkOjszcvNaIAS/pQ==", + "license": "Apache-2.0", + "dependencies": { + "@orchidjs/unicode-variants": "^1.1.2" + } + }, + "node_modules/@orchidjs/unicode-variants": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.1.2.tgz", + "integrity": "sha512-5DobW1CHgnBROOEpFlEXytED5OosEWESFvg/VYmH0143oXcijYTprRYJTs+55HzGM4IqxiLFSuqEzu9mPNwVsA==", + "license": "Apache-2.0" + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@tabler/core": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.4.0.tgz", + "integrity": "sha512-5BigzOlbOH9N0Is4u0rYNRCiwtnUXWO57K9zwuscygcicAa8UV9MGaS4zTgQsZEtZ9tsNANhN/YD8gCBGKYCiw==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.11.8", + "bootstrap": "5.3.7" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz", + "integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-sprite": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-sprite/-/icons-sprite-3.44.0.tgz", + "integrity": "sha512-+6eag3is1gOW8II/1iYad7ZLLXrBO4skNuDDI/vNTKr4WmiDCpLf8d9OBRTve95TXYkjJrClJuFMrOsgmI+HKA==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.44.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, "node_modules/@types/node": { "version": "26.2.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", @@ -542,10 +1056,77 @@ "node": ">=16.20.0" } }, + "node_modules/apexcharts": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.13.0.tgz", + "integrity": "sha512-PJuXT6zdiCbv0IkX5cqkKFVIIh+9v3kqP9zsOHEGpIWi7DfTgzvfOKc8icw6G3/ulR3V1alDDUtOVH0zWCWGEQ==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/bootstrap": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", + "integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/end-to-end": { "resolved": "tests/end-to-end", "link": true }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -561,6 +1142,23 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/tom-select": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.6.1.tgz", + "integrity": "sha512-d/1kngVOQTGcI/2pVDfDLYjtjUgSSd3fSgkYUpi0y+yRtQQu2kzljj3aUdqMfqc45cjPvDEpfDt/hSX4awDFTg==", + "license": "Apache-2.0", + "dependencies": { + "@orchidjs/sifter": "^1.1.0", + "@orchidjs/unicode-variants": "^1.1.2" + }, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tom-select" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", diff --git a/package.json b/package.json index 79f9d613e..b6e03394a 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "sqlpage", "version": "1.0.0", "scripts": { + "build": "node frontend/build.mjs", "test": "biome check . && npm run typecheck && node --test \"tests/js/**/*.spec.ts\"", "typecheck": "tsc && tsc -p tests/js && tsc -p tests/end-to-end", "format": "biome format --write .", @@ -12,9 +13,16 @@ "url": "git+https://github.com/sqlpage/SQLPage.git" }, "license": "MIT", + "dependencies": { + "@tabler/core": "1.4.0", + "@tabler/icons-sprite": "3.44.0", + "apexcharts": "5.13.0", + "tom-select": "2.6.1" + }, "devDependencies": { "@biomejs/biome": "^2.5.4", "@types/node": "^26.2.0", + "esbuild": "^0.28.2", "typescript": "^7.0.2" }, "workspaces": [ diff --git a/scripts/predownload-offline-deps.sh b/scripts/predownload-offline-deps.sh index a74ebd360..2639047b9 100755 --- a/scripts/predownload-offline-deps.sh +++ b/scripts/predownload-offline-deps.sh @@ -3,41 +3,13 @@ set -euo pipefail cd "$(dirname "$0")/.." -cache_dir="target/sqlpage_artefacts" -mkdir -p "$cache_dir" - echo "[1/3] Prefetching Cargo dependencies" cargo fetch --locked echo "[2/3] Prefetching npm dependencies" npm ci --ignore-scripts --no-audit -if [ -f tests/end-to-end/package-lock.json ]; then - ( - cd tests/end-to-end - npm ci --ignore-scripts --no-audit - ) -fi - -echo "[3/3] Prefetching raw HTTP assets used by build.rs" -mapfile -t urls < <( - { - rg -o --no-filename 'https://[[:alnum:]][[:alnum:].-]*/[^" )]+' build.rs - rg -o --no-filename '^/\* !include https://[^ ]+' sqlpage/*.css sqlpage/*.js sqlpage/*.svg \ - | sed 's#^/\* !include ##' - } | sort -u -) - -for url in "${urls[@]}"; do - file_name=$(printf '%s' "$url" | sed -E 's/[^[:alnum:].-]/_/g') - cache_file="$cache_dir/$file_name" - - if [ -s "$cache_file" ]; then - echo " - Cached: $url" - continue - fi - echo " - Downloading: $url" - curl -fsSL "$url" -o "$cache_file" -done +echo "[3/3] Building the frontend assets the Rust build embeds" +npm run build echo "Done. Offline caches are ready." diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js deleted file mode 100644 index 98fa1ce3d..000000000 --- a/sqlpage/apexcharts.js +++ /dev/null @@ -1,336 +0,0 @@ -/* !include https://cdn.jsdelivr.net/npm/apexcharts@5.13.0/dist/apexcharts.min.js */ - -sqlpage_chart = (() => { - function sqlpage_chart() { - /** @type {NodeListOf} */ - const charts = document.querySelectorAll("[data-pre-init=chart]"); - for (const c of charts) { - try { - build_sqlpage_chart(c); - } catch (e) { - console.error(e); - } - } - } - - const tblrColors = [ - ["blue", "#1c7ed6", "#339af0"], - ["red", "#f03e3e", "#ff6b6b"], - ["green", "#37b24d", "#51cf66"], - ["pink", "#d6336c", "#f06595"], - ["purple", "#ae3ec9", "#cc5de8"], - ["orange", "#f76707", "#ff922b"], - ["cyan", "#1098ad", "#22b8cf"], - ["teal", "#0ca678", "#20c997"], - ["yellow", "#f59f00", "#fcc419"], - ["indigo", "#4263eb", "#5c7cfa"], - ["lime", "#74b816", "#94d82d"], - ["azure", "#339af0", "#339af0"], - ["gray", "#495057", "#adb5bd"], - ["black", "#000000", "#000000"], - ["white", "#ffffff", "#f8f9fa"], - ]; - const colorNames = Object.fromEntries( - tblrColors.flatMap(([name, dark, light]) => [ - [name, dark], - [`${name}-lt`, light], - ]), - ); - const isDarkTheme = document.body?.dataset?.bsTheme === "dark"; - - const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; - const APEXCHARTS_TYPE_ALIASES = { column: "bar" }; - const Y_WHEN_A_SERIES_SKIPS_A_LABEL = { - bar: 0, - line: null, - area: null, - scatter: null, - bubble: null, - heatmap: null, - }; - - /** @typedef {number|string|Date} XValue */ - /** @typedef { {name:string, data:{x:XValue,y:number|null,z?:number}[]} } ChartSeries */ - /** @typedef { { [name:string]: ChartSeries } } Series */ - - /** @param {XValue} x @returns {number|string} equal x values share a key */ - const x_key = (x) => (x instanceof Date ? x.getTime() : x); - - /** @param {ChartSeries[]} series */ - const x_is_text = (series) => typeof series[0]?.data[0]?.x === "string"; - - /** - * @param {ChartSeries[]} series - * @returns {XValue[]} every x the series hold, in their own order where they - * agree and in ascending order where they diverge - */ - function merged_x_values(series) { - const unread = series.map(({ data }) => data.map(({ x }) => x)); - const merged = new Map(); - while (unread.some((xs) => xs.length > 0)) { - const with_lowest_x = unread - .filter((xs) => xs.length > 0) - .reduce((a, b) => (b[0] < a[0] ? b : a)); - const x = /** @type {XValue} */ (with_lowest_x.shift()); - merged.set(x_key(x), x); - } - return [...merged.values()]; - } - - /** - * ApexCharts pairs points across series by index rather than by x, so a - * series that skips an x lands on the wrong one. Give every series the same - * amount of x values. - * - * @param {ChartSeries[]} series - * @param {number|null} y_when_missing what a series with no value at an x is - * worth there: zero to add nothing to a stack, null to leave a gap. - * @returns {ChartSeries[]} - */ - function align_series(series, y_when_missing) { - const all_x = merged_x_values(series); - return series.map(({ name, data }) => { - const by_x = new Map(data.map((point) => [x_key(point.x), point])); - return { - name, - data: all_x.map((x) => { - const point = by_x.get(x_key(x)); - return { ...point, x, y: point?.y ?? y_when_missing }; - }), - }; - }); - } - - /** - * @param {ChartSeries[]} series - * @param {string} chart_type - * @param {boolean} is_stacked - * @returns {ChartSeries[]} - */ - function align_series_for(series, chart_type, is_stacked) { - if (is_stacked) return align_series(series, 0); - if (x_is_text(series) && chart_type in Y_WHEN_A_SERIES_SKIPS_A_LABEL) - return align_series(series, Y_WHEN_A_SERIES_SKIPS_A_LABEL[chart_type]); - return series; - } - - // The unit tests load this file as a CommonJS module; browsers have no `module`. - if (typeof module !== "undefined") - module.exports = { align_series, align_series_for, merged_x_values }; - - /** @param {HTMLElement} c */ - function build_sqlpage_chart(c) { - const [data_element] = c.getElementsByTagName("data"); - const data = JSON.parse(data_element.textContent); - const chartContainer = /** @type {HTMLElement} */ ( - c.querySelector(".chart") - ); - chartContainer.innerHTML = ""; - const is_timeseries = !!data.time; - const chart_type = - APEXCHARTS_TYPE_ALIASES[data.type] || data.type || "line"; - const is_stacked = - !!data.stacked && STACKABLE_CHART_TYPES.includes(chart_type); - /** @type { Series } */ - const series_map = {}; - for (const [name, old_x, old_y, z] of data.points) { - series_map[name] = series_map[name] || { name, data: [] }; - let x = old_x; - let y = old_y; - if (is_timeseries) { - if (typeof x === "number") x = new Date(x * 1000); - else if (chart_type === "rangeBar" && Array.isArray(y)) - y = y.map((y) => new Date(y).getTime()); - else x = new Date(x); - } - series_map[name].data.push({ x, y, z }); - } - if (data.xmin == null) data.xmin = undefined; - if (data.xmax == null) data.xmax = undefined; - if (data.ymin == null) data.ymin = undefined; - if (data.ymax == null) data.ymax = undefined; - - const colors = [ - ...data.colors.filter((c) => c).map((c) => colorNames[c]), - ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? dark : light)), - ...tblrColors.map(([_, dark, light]) => (isDarkTheme ? light : dark)), - ]; - - let series = Object.values(series_map); - - let labels; - const categories = x_is_text(series); - if (chart_type === "pie") { - labels = data.points.map(([name, x, _y]) => x || name); - series = data.points.map(([_name, _x, y]) => Number.parseFloat(y)); - } else if (series.length > 1) - series = align_series_for(series, chart_type, is_stacked); - - const options = { - chart: { - type: chart_type, - fontFamily: "inherit", - background: "transparent", - parentHeightOffset: 0, - height: chartContainer.style.height, - stacked: is_stacked, - toolbar: { - show: !!data.toolbar, - }, - animations: { - enabled: false, - }, - zoom: { - enabled: false, - }, - }, - theme: { - mode: isDarkTheme ? "dark" : "light", - palette: "palette4", - }, - legend: { - show: data.show_legend === null || !!data.show_legend, - }, - dataLabels: { - enabled: !!data.labels, - dropShadow: { - enabled: true, - color: "var(--tblr-primary-bg-subtle)", - }, - formatter: - chart_type === "rangeBar" - ? (_val, { seriesIndex, w }) => w.config.series[seriesIndex].name - : chart_type === "pie" - ? (value, { seriesIndex, w }) => - `${w.config.labels[seriesIndex]}: ${value.toFixed()}%` - : (value) => value?.toLocaleString?.() || value, - }, - fill: { - type: chart_type === "area" ? "gradient" : "solid", - }, - stroke: { - width: - { - area: 3, - line: 2, - }[chart_type] || 0, - lineCap: "round", - curve: "smooth", - }, - xaxis: { - tooltip: { - enabled: false, - }, - min: data.xmin, - max: data.xmax, - title: { - text: data.xtitle || undefined, - }, - type: is_timeseries ? "datetime" : categories ? "category" : undefined, - labels: { - datetimeUTC: false, - }, - }, - yaxis: { - logarithmic: !!data.logarithmic, - min: data.ymin, - max: data.ymax, - stepSize: data.ystep, - tickAmount: data.yticks, - title: { - text: data.ytitle || undefined, - }, - }, - zaxis: { - title: { - text: data.ztitle || undefined, - }, - }, - markers: { - size: data.marker || 0, - strokeWidth: 0, - hover: { - sizeOffset: 5, - }, - }, - tooltip: { - fillSeriesColor: false, - custom: - chart_type === "bubble" || chart_type === "scatter" - ? bubbleTooltip - : undefined, - y: { - formatter: (value) => { - if (value == null) return ""; - if (is_timeseries && chart_type === "rangeBar") { - const d = new Date(value); - if (d.getHours() === 0 && d.getMinutes() === 0) - return d.toLocaleDateString(); - return d.toLocaleString(); - } - const str_val = value.toLocaleString(); - if (str_val.length > 10 && Number.isNaN(value)) - return value.toFixed(2); - return str_val; - }, - }, - }, - plotOptions: { - bar: { - horizontal: !!data.horizontal || chart_type === "rangeBar", - borderRadius: 5, - }, - bubble: { minBubbleRadius: 5 }, - }, - colors, - series, - }; - if (labels) options.labels = labels; - // tickamount is the number of intervals, not the number of ticks - if (data.xticks) options.xaxis.tickAmount = data.xticks; - const chart = new ApexCharts(chartContainer, options); - chart.render(); - if (window.charts) window.charts.push(chart); - else window.charts = [chart]; - c.removeAttribute("data-pre-init"); - } - - function bubbleTooltip({ seriesIndex, dataPointIndex, w }) { - const { name, data } = w.config.series[seriesIndex]; - const point = data[dataPointIndex]; - - const tooltip = document.createElement("div"); - tooltip.className = "apexcharts-tooltip-text"; - tooltip.style.fontFamily = "inherit"; - - const seriesName = document.createElement("div"); - seriesName.className = "apexcharts-tooltip-y-group"; - seriesName.style.fontWeight = "bold"; - seriesName.innerText = name; - tooltip.appendChild(seriesName); - - for (const axis of ["x", "y", "z"]) { - const value = point[axis]; - if (value == null) continue; - const axisValue = document.createElement("div"); - axisValue.className = "apexcharts-tooltip-y-group"; - let axis_conf = w.config[`${axis}axis`]; - if (axis_conf.length) axis_conf = axis_conf[0]; - const title = axis_conf.title.text || axis; - const labelSpan = document.createElement("span"); - labelSpan.className = "apexcharts-tooltip-text-y-label"; - labelSpan.innerText = `${title}: `; - axisValue.appendChild(labelSpan); - const valueSpan = document.createElement("span"); - valueSpan.className = "apexcharts-tooltip-text-y-value"; - valueSpan.innerText = value; - axisValue.appendChild(valueSpan); - tooltip.appendChild(axisValue); - } - return tooltip.outerHTML; - } - - return sqlpage_chart; -})(); - -add_init_fn(sqlpage_chart); diff --git a/sqlpage/globals.d.ts b/sqlpage/globals.d.ts deleted file mode 100644 index 25eba3ca7..000000000 --- a/sqlpage/globals.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Names the browser bundle relies on at runtime rather than through an import: -// libraries build.rs inlines ahead of our own code, libraries loaded on demand, -// and the objects SQLPage's scripts hang off the window for each other. - -/** - * A library this project ships no type definitions for. Saying `unknown` - * instead would only move the guesswork to a cast at every call site. - */ -// biome-ignore lint/suspicious/noExplicitAny: that is what an untyped library is -type Untyped = any; - -/** Leaflet, loaded from a CDN by sqlpage_map when a page holds a map. */ -declare const L: Untyped; - -/** ApexCharts, inlined ahead of apexcharts.js by build.rs. */ -declare const ApexCharts: Untyped; - -/** Tom Select, inlined ahead of tomselect.js by build.rs. */ -declare const TomSelect: Untyped; - -/** apexcharts.js publishes its initialiser under this name. */ -declare var sqlpage_chart: () => void; - -/** - * Tabler's bundled Bootstrap, inlined ahead of sqlpage.js by build.rs. Its - * widgets are untyped: naming a few of them here would only claim more than - * this file knows. - */ -interface TablerBootstrap { - [widget: string]: Untyped; -} - -interface Window { - /** Every chart rendered on the page, in the order they were built. */ - charts?: unknown[]; - tabler: { bootstrap: TablerBootstrap }; - bootstrap?: TablerBootstrap; -} diff --git a/sqlpage/tabler-icons.svg b/sqlpage/tabler-icons.svg deleted file mode 100644 index ccf4a71a6..000000000 --- a/sqlpage/tabler-icons.svg +++ /dev/null @@ -1 +0,0 @@ -/* !include https://cdn.jsdelivr.net/npm/@tabler/icons-sprite@3.44.0/dist/tabler-sprite.svg */ diff --git a/tests/end-to-end/chart-component.spec.ts b/tests/end-to-end/chart-component.spec.ts index 3c32f1019..6a78def95 100644 --- a/tests/end-to-end/chart-component.spec.ts +++ b/tests/end-to-end/chart-component.spec.ts @@ -15,7 +15,6 @@ declare global { }; }[]; } - function sqlpage_chart(): void; } type Row = [series: string, x: unknown, y: unknown, z?: unknown]; @@ -95,7 +94,9 @@ async function renderChart( const reportError = console.error; console.error = (...args) => failures.push(args.map(String).join(" ")); const before = window.charts?.length ?? 0; - sqlpage_chart(); + container.dispatchEvent( + new CustomEvent("fragment-loaded", { bubbles: true }), + ); console.error = reportError; const rendered = window.charts?.[before]; diff --git a/tests/end-to-end/globals.d.ts b/tests/end-to-end/globals.d.ts index 47ba8d7f0..583147367 100644 --- a/tests/end-to-end/globals.d.ts +++ b/tests/end-to-end/globals.d.ts @@ -1,5 +1,5 @@ // What the browser tests reach for on the page: widgets that the scripts under -// test attach to elements at runtime, and libraries the page loads itself. +// test attach to elements at runtime. interface TomSelectInstance { getValue(): string | string[]; @@ -12,10 +12,3 @@ interface HTMLElement { /** Attached by sqlpage_select_dropdown to every select it takes over. */ tomselect?: TomSelectInstance; } - -interface Window { - /** Tabler's bundled Bootstrap, inlined ahead of sqlpage.js by build.rs. */ - tabler?: { - bootstrap: { Toast: { getInstance(element: Element): unknown } }; - }; -} diff --git a/tests/end-to-end/map-component.spec.ts b/tests/end-to-end/map-component.spec.ts index 58bb66818..ff192ffc2 100644 --- a/tests/end-to-end/map-component.spec.ts +++ b/tests/end-to-end/map-component.spec.ts @@ -2,10 +2,6 @@ import { expect, type Page, test } from "@playwright/test"; const BASE = process.env.SQLPAGE_TEST_BASE ?? "http://localhost:8080/"; -declare global { - function sqlpage_map(): void; -} - type Marker = { coords?: string; title: string }; const PARIS = "48.85,2.35"; @@ -44,7 +40,9 @@ async function renderMap( const console_error = console.error; console.error = (...args) => logged.push(args.join(" ")); - sqlpage_map(); + container.dispatchEvent( + new CustomEvent("fragment-loaded", { bubbles: true }), + ); await new Promise((resolve) => setTimeout(resolve, 500)); console.error = console_error; diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index 7c9325b4c..a638e89b6 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -163,17 +163,8 @@ test("toast notifications initialize, stack, dismiss, and render safely", async await expect(stackOne).toBeVisible(); await expect(automatic).toBeHidden({ timeout: 7000 }); expect( - await automaticHandle?.evaluate((element) => { - const testWindow = window as Window & { - tabler: { - bootstrap: { - Toast: { getInstance(element: Element): unknown }; - }; - }; - }; - return testWindow.tabler.bootstrap.Toast.getInstance(element) === null; - }), - ).toBe(true); + await automaticHandle?.evaluate((element) => element.isConnected), + ).toBe(false); const dismissible = page.locator("#toast-dismissible"); await expect(dismissible).toHaveAttribute("data-bs-delay", "0"); diff --git a/tests/js/chart_series.spec.ts b/tests/js/chart_series.spec.ts index 7ef7ddc17..315ad91a9 100644 --- a/tests/js/chart_series.spec.ts +++ b/tests/js/chart_series.spec.ts @@ -1,31 +1,23 @@ import assert from "node:assert/strict"; -import { createRequire } from "node:module"; import test from "node:test"; - -const browser_globals_apexcharts_reads_when_it_loads = { - document: { body: null }, - add_init_fn: () => {}, -}; -Object.assign(globalThis, browser_globals_apexcharts_reads_when_it_loads); - -const require = createRequire(import.meta.url); -const { +import { align_series, align_series_for, + type ChartPoint, + type ChartSeries, merged_x_values, -} = require("../../sqlpage/apexcharts.js"); +} from "../../frontend/src/chart_series.ts"; const ADDS_NOTHING_TO_THE_STACK = 0; const LEAVES_A_GAP = null; const STACKED = true; const UNSTACKED = false; -type XValue = number | string | Date; -type Point = { x: XValue; y: number | string | null | number[]; z?: number }; -type Series = { name: string; data: Point[] }; - -const series = (name: string, ...data: Point[]): Series => ({ name, data }); -const xs = (s: Series) => s.data.map((p) => p.x); +const series = (name: string, ...data: ChartPoint[]): ChartSeries => ({ + name, + data, +}); +const xs = (s: ChartSeries) => s.data.map((p) => p.x); test("merged_x_values keeps the order the series agree on", () => { const merged = merged_x_values([ diff --git a/tsconfig.json b/tsconfig.json index 936b43629..42a0dc20a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,12 +4,11 @@ "lib": ["es2022", "dom", "dom.iterable"], "module": "preserve", "moduleResolution": "bundler", - "allowJs": true, - "checkJs": true, + "allowImportingTsExtensions": true, "noEmit": true, "strict": true, "noImplicitAny": false, "types": [] }, - "include": ["sqlpage/*.js", "sqlpage/globals.d.ts"] + "include": ["frontend/src", "frontend/types"] }