diff --git a/.dockerignore b/.dockerignore index 1fd56f7e4..7ad4005ac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ /target node_modules/ +frontend/dist/ .git/ sqlpage.db docs/ diff --git a/.github/actions/install-frontend-dependencies/action.yml b/.github/actions/install-frontend-dependencies/action.yml index 7e9e47b01..5ac9ddccb 100644 --- a/.github/actions/install-frontend-dependencies/action.yml +++ b/.github/actions/install-frontend-dependencies/action.yml @@ -1,5 +1,5 @@ -name: Install frontend dependencies -description: Set up Node.js and install the frontend workspace dependencies +name: Install and build the frontend +description: Set up Node.js, install the frontend workspace dependencies and bundle the browser assets runs: using: composite steps: @@ -9,3 +9,5 @@ runs: cache: npm - run: npm ci --ignore-scripts shell: bash + - run: npm run build + shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6646ca954..a5a9b3d41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,7 +259,7 @@ jobs: ref_scope="pr-${{ github.event.pull_request.number }}" fi - recipe_hash="${{ hashFiles('Dockerfile', '.cargo/**', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'scripts/**', 'sqlpage/**') }}" + recipe_hash="${{ hashFiles('Dockerfile', '.cargo/**', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'scripts/**', 'sqlpage/**', 'package.json', 'package-lock.json') }}" { echo "current=sqlpage-${ref_scope}${{ matrix.tag_suffix }}-${recipe_hash}" echo "main=sqlpage-main${{ matrix.tag_suffix }}-${recipe_hash}" @@ -339,7 +339,7 @@ jobs: ref_scope="pr-${{ github.event.pull_request.number }}" fi - recipe_hash="${{ hashFiles('Dockerfile', '.cargo/**', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'scripts/**', 'sqlpage/**') }}" + recipe_hash="${{ hashFiles('Dockerfile', '.cargo/**', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'scripts/**', 'sqlpage/**', 'package.json', 'package-lock.json') }}" { echo "artifact=sqlpage-${ref_scope}-linux-amd64-hurl-${recipe_hash}" echo "current=sqlpage-${ref_scope}-linux-amd64-${recipe_hash}" diff --git a/.gitignore b/.gitignore index 994459944..1a2971ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ x.sql xbed.sql **/sqlpage.bin node_modules/ +frontend/dist/ sqlpage/sqlpage.db tests_uploads/ /test-results/ diff --git a/Cargo.toml b/Cargo.toml index 2e31481ce..a8581d24d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ include = [ "/README.md", "/build.rs", "/sqlpage", + "/frontend/dist", "/node_modules/@tabler/core/dist/css/tabler.min.css", "/node_modules/@tabler/core/dist/css/tabler-vendors.min.css", "/node_modules/@tabler/core/dist/js/tabler.min.js", diff --git a/Dockerfile b/Dockerfile index 0c5bd3696..e30322ef2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,10 @@ FROM --platform=$BUILDPLATFORM node:26-slim AS frontend WORKDIR /usr/src/sqlpage COPY package.json package-lock.json ./ -RUN npm ci --omit=dev --ignore-scripts +RUN npm ci --ignore-scripts +COPY sqlpage/ sqlpage/ +COPY scripts/build-frontend.mjs scripts/ +RUN node scripts/build-frontend.mjs FROM --platform=$BUILDPLATFORM rust:1.95-slim AS builder @@ -20,6 +23,7 @@ COPY .cargo/ .cargo/ COPY Cargo.toml Cargo.lock build.rs ./ COPY sqlpage/ sqlpage/ COPY --from=frontend /usr/src/sqlpage/node_modules node_modules +COPY --from=frontend /usr/src/sqlpage/frontend/dist frontend/dist RUN /usr/local/bin/build-dependencies.sh COPY . . diff --git a/biome.json b/biome.json index 3ffd84277..c56f9d1c7 100644 --- a/biome.json +++ b/biome.json @@ -4,6 +4,7 @@ "includes": [ "**", "!**/*.svg", + "!frontend/dist", "!examples/official-site/pgconf", "!tests/end-to-end/test-results", "!.zed/*.json", diff --git a/build.rs b/build.rs index 9038a8fb9..ae753c42f 100644 --- a/build.rs +++ b/build.rs @@ -7,29 +7,35 @@ use std::io::Write; use std::path::{Path, PathBuf}; const SERVED_ASSETS: &[(&str, &[&str])] = &[ - ("sqlpage.js", &["@tabler/core/dist/js/tabler.min.js"]), ( - "sqlpage.css", + "frontend/dist/sqlpage.js", + &["node_modules/@tabler/core/dist/js/tabler.min.js"], + ), + ( + "sqlpage/sqlpage.css", &[ - "@tabler/core/dist/css/tabler.min.css", - "tom-select/dist/css/tom-select.bootstrap5.css", - "@tabler/core/dist/css/tabler-vendors.min.css", + "node_modules/@tabler/core/dist/css/tabler.min.css", + "node_modules/tom-select/dist/css/tom-select.bootstrap5.css", + "node_modules/@tabler/core/dist/css/tabler-vendors.min.css", ], ), - ("apexcharts.js", &["apexcharts/dist/apexcharts.min.js"]), ( - "tomselect.js", - &["tom-select/dist/js/tom-select.popular.min.js"], + "frontend/dist/apexcharts.js", + &["node_modules/apexcharts/dist/apexcharts.min.js"], ), - ("favicon.svg", &[]), + ( + "frontend/dist/tomselect.js", + &["node_modules/tom-select/dist/js/tom-select.popular.min.js"], + ), + ("sqlpage/favicon.svg", &[]), ]; -const ICON_SPRITE: &str = "@tabler/icons-sprite/dist/tabler-sprite.svg"; +const ICON_SPRITE: &str = "node_modules/@tabler/icons-sprite/dist/tabler-sprite.svg"; fn main() { println!("cargo:rerun-if-changed=build.rs"); - for &(name, libraries) in SERVED_ASSETS { - build_served_asset(name, libraries); + for &(source, libraries) in SERVED_ASSETS { + build_served_asset(source, libraries); } build_icon_map(); set_odbc_rpath(); @@ -39,29 +45,29 @@ fn out_dir() -> PathBuf { PathBuf::from(std::env::var("OUT_DIR").unwrap()) } -fn open_library(library: &str) -> File { - let path = Path::new("node_modules").join(library); - println!("cargo:rerun-if-changed={}", path.display()); - File::open(&path).unwrap_or_else(|err| { +fn open_input(path: &str) -> File { + println!("cargo:rerun-if-changed={path}"); + File::open(path).unwrap_or_else(|err| { panic!( - "Unable to read {}: {err}\n\ - The browser libraries come from npm: run `npm ci` before `cargo build`.", - path.display() + "Unable to read {path}: {err}\n\ + The browser assets are built by npm: \ + run `npm ci && npm run build` before `cargo build`." ) }) } -fn build_served_asset(name: &str, libraries: &[&str]) { - let source = Path::new("sqlpage").join(name); - println!("cargo:rerun-if-changed={}", source.display()); - - let built = out_dir().join(name); +fn build_served_asset(source: &str, libraries: &[&str]) { + let built = out_dir().join(Path::new(source).file_name().unwrap()); + // A minified library can end without a semicolon, and the bundle that + // follows opens with `(`, which JavaScript would read as a call to it. + let is_script = Path::new(source).extension().is_some_and(|ext| ext == "js"); + let separator: &[u8] = if is_script { b";\n" } else { b"\n" }; let mut gzipped = gzip::Encoder::new(File::create(&built).unwrap()).unwrap(); for library in libraries { - std::io::copy(&mut open_library(library), &mut gzipped).unwrap(); - gzipped.write_all(b"\n").unwrap(); + std::io::copy(&mut open_input(library), &mut gzipped).unwrap(); + gzipped.write_all(separator).unwrap(); } - std::io::copy(&mut File::open(&source).unwrap(), &mut gzipped).unwrap(); + std::io::copy(&mut open_input(source), &mut gzipped).unwrap(); gzipped .finish() .as_result() @@ -76,7 +82,7 @@ fn build_served_asset(name: &str, libraries: &[&str]) { fn build_icon_map() { let mut sprite = Vec::with_capacity(3 * 1024 * 1024); - open_library(ICON_SPRITE).read_to_end(&mut sprite).unwrap(); + open_input(ICON_SPRITE).read_to_end(&mut sprite).unwrap(); let mut icon_map = File::create(out_dir().join("icons.rs")).unwrap(); icon_map.write_all(b"[").unwrap(); extract_icons_from_sprite(&sprite, |name, content| { diff --git a/lambda.Dockerfile b/lambda.Dockerfile index 364f29460..a7594705f 100644 --- a/lambda.Dockerfile +++ b/lambda.Dockerfile @@ -1,7 +1,10 @@ FROM node:26-slim AS frontend WORKDIR /usr/src/sqlpage COPY package.json package-lock.json ./ -RUN npm ci --omit=dev --ignore-scripts +RUN npm ci --ignore-scripts +COPY sqlpage/ sqlpage/ +COPY scripts/build-frontend.mjs scripts/ +RUN node scripts/build-frontend.mjs FROM rust:1.95-alpine AS builder RUN rustup component add clippy rustfmt @@ -12,6 +15,7 @@ COPY Cargo.toml Cargo.lock ./ RUN cargo build --release COPY . . COPY --from=frontend /usr/src/sqlpage/node_modules node_modules +COPY --from=frontend /usr/src/sqlpage/frontend/dist frontend/dist RUN cargo build --release --features lambda-web RUN mv target/release/sqlpage bootstrap && \ strip --strip-all bootstrap && \ diff --git a/package-lock.json b/package-lock.json index 12d8cd6ac..4566dbbff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "devDependencies": { "@biomejs/biome": "^2.5.11", "@types/node": "^26.4.0", + "rolldown": "^1.2.8", "typescript": "^7.0.2" } }, @@ -201,6 +202,16 @@ "integrity": "sha512-5DobW1CHgnBROOEpFlEXytED5OosEWESFvg/VYmH0143oXcijYTprRYJTs+55HzGM4IqxiLFSuqEzu9mPNwVsA==", "license": "Apache-2.0" }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@playwright/test": { "version": "1.63.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", @@ -227,6 +238,268 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@tabler/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.5.0.tgz", @@ -667,6 +940,40 @@ "node": ">=20" } }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, "node_modules/tom-select": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.6.2.tgz", diff --git a/package.json b/package.json index 9270a9c80..d6d745735 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "sqlpage", "version": "1.0.0", "scripts": { + "build": "node scripts/build-frontend.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 .", @@ -21,6 +22,7 @@ "devDependencies": { "@biomejs/biome": "^2.5.11", "@types/node": "^26.4.0", + "rolldown": "^1.2.8", "typescript": "^7.0.2" }, "workspaces": [ diff --git a/scripts/build-frontend.mjs b/scripts/build-frontend.mjs new file mode 100644 index 000000000..6b6d319af --- /dev/null +++ b/scripts/build-frontend.mjs @@ -0,0 +1,24 @@ +import { mkdir } from "node:fs/promises"; +import { rolldown } from "rolldown"; + +const DIST = "frontend/dist"; + +const ENTRIES = ["sqlpage", "apexcharts", "tomselect"]; + +// An unresolved import is a warning, and rolldown then leaves the dependency +// out of the bundle instead of failing, so no warning may be ignored here. +function refuse(warning) { + throw new Error(`Unable to bundle the frontend: ${warning.message}`); +} + +async function bundle(entry) { + const build = await rolldown({ + input: { [entry]: `sqlpage/${entry}.js` }, + onwarn: refuse, + }); + await build.write({ dir: DIST, format: "iife", minify: true }); + await build.close(); +} + +await mkdir(DIST, { recursive: true }); +await Promise.all(ENTRIES.map(bundle)); diff --git a/sqlpage/apexcharts.js b/sqlpage/apexcharts.js index 002a9ba09..7fe20027b 100644 --- a/sqlpage/apexcharts.js +++ b/sqlpage/apexcharts.js @@ -1,4 +1,12 @@ -sqlpage_chart = (() => { +import { align_series_for, xaxis_type_for } from "./chart_series.js"; +import { add_init_fn } from "./init.js"; + +/** + * @typedef {import("./chart_series.js").ChartSeries} ChartSeries + * @typedef {import("./chart_series.js").Series} Series + */ + +const sqlpage_chart = (() => { function sqlpage_chart() { /** @type {NodeListOf} */ const charts = document.querySelectorAll("[data-pre-init=chart]"); @@ -37,103 +45,7 @@ sqlpage_chart = (() => { const isDarkTheme = document.body?.dataset?.bsTheme === "dark"; const STACKABLE_CHART_TYPES = ["line", "area", "bar"]; - const NUMERIC_X_CHART_TYPES = ["line", "area", "bar", "scatter", "bubble"]; 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 { {x:XValue, y:number|null, z?:number, fillColor?:string} } ChartPoint */ - /** @typedef { {name:string, data:ChartPoint[]} } ChartSeries */ - /** @typedef { Map } 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 @param {string} chart_type */ - function xaxis_type_for(series, chart_type, is_timeseries, is_horizontal) { - if (is_timeseries) return "datetime"; - if (x_is_text(series)) return "category"; - if ( - typeof series[0]?.data?.[0]?.x === "number" && - !is_horizontal && - NUMERIC_X_CHART_TYPES.includes(chart_type) - ) - return "numeric"; - } - - /** - * @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, - xaxis_type_for, - }; const referenceColor = colorNames[isDarkTheme ? "gray-lt" : "gray"]; diff --git a/sqlpage/chart_series.js b/sqlpage/chart_series.js new file mode 100644 index 000000000..d3134101e --- /dev/null +++ b/sqlpage/chart_series.js @@ -0,0 +1,93 @@ +/** @typedef {number|string|Date} XValue */ +/** @typedef { {x:XValue, y:number|string|number[]|null, z?:number, fillColor?:string} } ChartPoint */ +/** @typedef { {name:string, data:ChartPoint[]} } ChartSeries */ +/** @typedef { Map } Series */ + +const NUMERIC_X_CHART_TYPES = ["line", "area", "bar", "scatter", "bubble"]; + +const Y_WHEN_A_SERIES_SKIPS_A_LABEL = { + bar: 0, + line: null, + area: null, + scatter: null, + bubble: null, + heatmap: null, +}; + +/** @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 @param {string} chart_type */ +export function xaxis_type_for( + series, + chart_type, + is_timeseries, + is_horizontal, +) { + if (is_timeseries) return "datetime"; + if (x_is_text(series)) return "category"; + if ( + typeof series[0]?.data?.[0]?.x === "number" && + !is_horizontal && + NUMERIC_X_CHART_TYPES.includes(chart_type) + ) + return "numeric"; +} + +/** + * @param {ChartSeries[]} series + * @returns {XValue[]} 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) { + 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[]} + */ +export 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[]} + */ +export 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; +} diff --git a/sqlpage/init.js b/sqlpage/init.js new file mode 100644 index 000000000..d31f5fc25 --- /dev/null +++ b/sqlpage/init.js @@ -0,0 +1,5 @@ +export function add_init_fn(f) { + document.addEventListener("DOMContentLoaded", f); + document.addEventListener("fragment-loaded", f); + if (document.readyState !== "loading") setTimeout(f, 0); +} diff --git a/sqlpage/sqlpage.js b/sqlpage/sqlpage.js index 9560b11de..d9896eaa4 100644 --- a/sqlpage/sqlpage.js +++ b/sqlpage/sqlpage.js @@ -1,3 +1,5 @@ +import { add_init_fn } from "./init.js"; + const nonce = /** @type {HTMLScriptElement} */ (document.currentScript).nonce; function sqlpage_card() { @@ -347,12 +349,6 @@ 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) { const normalized = hash?.replace(/^#/, ""); try { diff --git a/sqlpage/tomselect.js b/sqlpage/tomselect.js index fa98d871f..b2d78801e 100644 --- a/sqlpage/tomselect.js +++ b/sqlpage/tomselect.js @@ -1,3 +1,5 @@ +import { add_init_fn } from "./init.js"; + function sqlpage_select_dropdown() { /** @type {NodeListOf} */ const selects = document.querySelectorAll("[data-pre-init=select-dropdown]"); diff --git a/tests/js/chart_series.spec.ts b/tests/js/chart_series.spec.ts index dc981d779..2afcf3b86 100644 --- a/tests/js/chart_series.spec.ts +++ b/tests/js/chart_series.spec.ts @@ -1,20 +1,11 @@ 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, merged_x_values, xaxis_type_for, -} = require("../../sqlpage/apexcharts.js"); +} from "../../sqlpage/chart_series.js"; const ADDS_NOTHING_TO_THE_STACK = 0; const LEAVES_A_GAP = null;